Gate set, fixed up front. Base set from PROTOCOL + markup-assist done condition: check integrity (1280/768/375), check copy --manifest copy.txt, check design, check composition, check color, check a11y contrast in BOTH themes (index.html?theme=dark and ?theme=light — the brief's URL parameter), check a11y focus, check a11y touch, scan scroll, scan handlers, check interactions. Added because the brief specifies behaviour (dynamic-markup): check breakpoints --sweep (three width regimes 768/1280, brief names it), check scroll (sticky top bar + sidebar, fixed bottom nav on phones, pinned order-ID column), scan scroll at 375x740 as well as the default 1280 (the orders table must be a real sideways scrollport on phones and the page must not overflow-x). check color also at 375 is not needed: the deltas/badges carry text at every width. No check animation / check motion: nothing animates on load (only interaction transitions), so there is no motion brief to verify; I will run check motion once to confirm the reduced-motion rule exists.
vlmkit demo · judgment log
Keel Analytics — Overview
How this SaaS analytics dashboard (app shell) was judged, in the order it happened: every gate run with its whole output, every screenshot that was looked at with what was seen in it, and where each defect came from. Each finished round keeps the pictures of one full-page walk per width; the other shots keep what was seen in them. Open the site.
- 5rounds
- 56gate runs, 5 failing
- 64screens in 44 shots, 33 kept
- 44looks recorded
- 11defects found by eye
- 5defects found by a gate
- 9gate false positives
The brief the builder was given
# Brief: analytics dashboard — "Keel Analytics" - **Directory**: `examples/sites/dashboard/` - **Pattern**: SaaS analytics dashboard (app shell) - **Language**: English ## What it is The Overview screen of **Keel Analytics**, a fictional web-analytics product, for a fictional store. One page, `index.html`, with all data inline and deterministic. ## Layout and behaviour - **App shell**: a left sidebar (logo, Overview (current), Traffic, Conversions, Customers, Settings) that collapses to icons with a toggle; a top bar with the page title, a date-range control (7 days / 30 days / 90 days), an "Export CSV" button and a user menu button showing initials. - **KPI cards**: Visitors, Conversion rate, Revenue, Average order value — each with its value, the change against the previous period, and an inline SVG sparkline. An increase or a decrease must read from an arrow and a sign in the text, not from colour alone. - **Main chart**: an SVG line chart of daily visitors for the selected range, with axes, gridlines and a legend (this period solid, previous period dashed). Hovering or focusing a data point shows a tooltip with the date and value, and the arrow keys move between points. - **Top channels**: a horizontal bar chart — Organic, Direct, Referral, Social, Email — with values and percentages. - **Recent orders**: a table of 12 orders (order ID, customer, date, channel, status, amount) with sortable columns (`aria-sort`), status badges carrying text (Paid / Pending / Refunded / Failed), and pagination of 6 rows per page. - The date range changes the KPI values, the chart and the channel bars (data for all three ranges is inline). - **Theme**: dark by default — people keep this open all day — with a light theme; `?theme=light` and `?theme=dark` force one. - **Widths**: sidebar expanded from 1280px, collapsed to icons from 768px, and below 768px a bottom navigation bar instead; the table scrolls sideways in its own container on phones, with the order ID column pinned. ## Visual direction Dense but calm: a dark slate ground, one teal accent, semantic colours only where they mean something, tabular figures everywhere numbers line up, a clear 8px spacing grid, cards that are visibly the same component. ## Required copy ``` Keel Analytics Overview Visitors Conversion rate Revenue Average order value Last 30 days Top channels Recent orders Export CSV Showing 1–6 of 12 orders ``` ## States to show (final round) Desktop dark and light; the 90-day range selected; a chart tooltip showing; the orders table sorted by amount; page 2 of the table; tablet with the sidebar collapsed; phone with the bottom navigation and the table scrolled sideways. ## Gates this brief leans on `check design` (the four KPI cards and the badges are one component each), `check color` (status and deltas must not be colour-only), `check a11y contrast` in both themes, `scan scroll` and `check scroll` (the table container, pinned column), `check interactions` (range control, sort, pagination, sidebar toggle), `check breakpoints --sweep`.
Defects
| Defect | Found by | What | Fix | Checked by |
|---|---|---|---|---|
| D1 | eye L1 on S1 |
The channels total line wraps at 1280 and leaves the day number '22' alone on line 2 ('…Aug 24 – Sep' / '22'); in light theme too (L4). | The total is now a row on the same three-column grid as the channel rows: 'Total visitors' | 52,858 | 100%. The value sits under the values column, and the date span that caused the wrap is gone (the range is already in the top bar). | L6 |
| D2 | eye L1 on S1 |
At 1280 the KPI card is too narrow for value+sparkline side by side, and the same container query also shrinks the value to 24px. The headline number is the card's first read and fits at 28px here; keep the stacked layout, drop the size change. | The narrow-card container query only stacks value and sparkline now; the value keeps 28px. Phones set 24px explicitly (it was 22px). | L6 |
| D3 | eye L1 on S1 |
Order IDs are set in the same bold as the amounts, so the ID column competes with the Amount column for the eye; the ID is a lookup key, the amount is the figure. | Row-header order IDs drop from 600 to 500 weight; amounts stay 600, so the figure column carries the emphasis. | L6 |
| D4 | eye L3 on S3 |
The always-on hint under the chart reads like a manual, says 'Hover' on a touch phone, and wraps to a dangling 'read each day.' at 375 (L1 noted the same line at desktop). | Removed the static hint paragraph. When the tooltip is opened from the keyboard it gains a last line '← → day · Home End'; pointer and touch tooltips carry no instructions. The arrow-key instruction also stays in the chart's aria-label. | L13 |
| D5 | eye L2 on S2 |
At 768 the orders table (min-width 720px) is wider than its 646px card: Amount is cut to a sliver of '$' with no cue that it scrolls. The brief reserves sideways scrolling for phones; at tablet the table should fit. | Dropped the table's fixed min-width:720px; the nowrap cells define its natural width. A container query on the orders card (≤760px) tightens cell padding to 12px (first/last cells keep 24px to align with the card text), so at 768 the six columns fit and only phones scroll. | L7 |
| D6 | eye L3 on S3 |
On phones the meta line wraps as 'Harbor & Pine · Last 30 days' / '· Aug 24 – Sep 22, 2026' — the second line starts with a stray separator dot. | On phones (#range-dates) takes a full line (flex-basis 100%) and loses its separator (::before content:none), so the meta reads 'Harbor & Pine · Last 30 days' / 'Aug 24 – Sep 22, 2026'. | L8 |
| D7 | eye L3 on S3 |
On phones the only sign that the orders table scrolls sideways is text clipped at the card edge ('Sep 22, 14:'); the pinned ID column has no edge either, so the pin is invisible until you scroll. | Scroll shadows in the scroll container's own background: a right-edge radial shadow (scroll-attached) with a surface-coloured cover (local-attached) that hides it once the last column is in view. The pinned ID column casts a shadow once scrollLeft>0 (.is-scrolled). No overlay element, so no text is covered. Second attempt, after L10 showed the background shadow was too faint and hidden behind the header: a wrapper div#orders-frame carries a 24px fade (::after, pointer-events none) over its right edge, header included, only while columns remain to the right (can-scroll-right from syncScroller). The pinned ID column shows a hairline whenever the table can scroll, and a cast shadow once it has scrolled. The background gradients are gone, so no text has an image in its background stack. | L11 |
| D8 | eye L5 on S5 |
In light theme the off-screen skip link's 32px-blur box-shadow bleeds into the top of the viewport as a grey smear over the brand row (S4 top-left, close-up S5). Found by eye only — invisible in dark. | The skip link's box-shadow now exists only on :focus (while it is on screen at top:16px), so nothing is cast into the viewport at rest. | L9 |
| D9 | eye L5 on S5 |
At 2x the brand mark reads as a smiley face (zig-zag 'eyes' over a curved 'smile'), not a keel or an analytics mark. | Redrew the mark as a sailboat in the sidebar ink on the teal tile: mainsail, jib, hull and a small keel fin. The mainsail's leading edge rises left-to-right like a chart line. Same paths in the phone top-bar copy. | L9 |
| D10 | gate G10 |
The focus sequence in the G10 report has an extra Tab stop at step 10: the whole chart <svg> (text '05001k1.5k…'), before the chart's roving point at step 11. The svg has no tabindex, but it has focusin/focusout listeners, and Chromium makes SVG elements with focus listeners focusable. Keyboard users land on a blank chart with no tooltip. | Moved pointermove/pointerdown/pointerleave/focusin/focusout/keydown from the <svg> to its HTML wrapper div#chart. Events from the points bubble there, and the svg no longer has focus listeners, so it is not a Tab stop. | G25 |
| D11 | gate G12 |
scan scroll lists #orders-scroll as a dead scrollport at 1280 (the table fits), yet it carries tabindex=0, so desktop keyboard users get a Tab stop that scrolls nothing. It is also the stop behind G10's 'reverse' jump. | syncScroller(): #orders-scroll gets role=region, aria-label 'Orders table, scrolls sideways' and tabindex=0 only while scrollWidth > clientWidth, and loses all three otherwise. It runs on load, on resize (ResizeObserver on the scroller), after every re-render and on scroll. | G25 |
| D12 | gate G15 |
check interactions finds 'Page 2' and 'Next' inert: no ARIA change and no announcement on Enter. The rows and 'Showing 7–12 of 12 orders' do change, but the status line is not a live region, so a screen-reader user hears nothing after paging. | #orders-status is aria-live=polite, so paging announces 'Showing 7–12 of 12 orders'; sorting keeps its own announcement. | G27 |
| D13 | gate G3 |
check design: 10 buttons in 5 styles. The sort headers (6) and the avatar are distinct components by design, but Export CSV and the pager are both secondary buttons and render as three styles: 36px/16px padding/600, 32px/12px padding/500 for Next, and 32px/0 padding/500 for the numbers. One secondary button should be one style. | The pager buttons now use .btn, the page's one secondary button: 36px tall, 12px side padding, 13px/600, 8px radius, 1px border. Export CSV moved from 16px to 12px padding to match. Digits are held square with min-width 36px. The current and disabled states are the only variants. | L6 |
| D14 | gate G23 |
Side effect of F7: check integrity now skips contrast for 30 text blocks at 1280 ('background-image/gradient in the stack — composite-background contrast is not deterministically measurable'). The scroll-shadow gradients sit behind every table cell at every width, including 1280 where the table fits and the shadow is fully covered, so the gate lost sight of the table text for nothing. | The two background gradients moved to .table-scroll.can-scroll; syncScroller() sets can-scroll only while scrollWidth > clientWidth. The table has no background image at 1280/tablet, where it fits; the shadow only exists where there is something to scroll to. | G24 |
| D15 | eye L17 on S17 |
Columns shift whenever the table is paged or sorted (S16 vs S17 vs S6: Date column x≈364/370, Channel ≈537/542). The auto layout re-fits the widths to each page's six rows, so the header row visibly wobbles on every page change. | lockColumns(): renders all 12 orders once, measures each column's natural width (table width:auto), pins those px widths on the header cells, then renders the current page. With every column specified, spare width is shared in proportion to fixed numbers, so no page or sort changes a column. It re-locks when the scroller resizes (padding regime, sidebar toggle). | L25 |
| D16 | eye L26 on S26 |
Light theme: the selected date range is a pale grey chip on a white track, the inverse of the dark theme's raised chip. The three segments read as one flat strip and the selection rests on bold weight alone (also on phones, L27). | Segmented control colours are now tokens. Dark is unchanged: surface track, surface-3 chip with an inset hairline. Light is a #edf0f4 track with a white chip, a 1px #c9d2dc ring and a soft drop shadow, so the selected range looks raised in both themes. | L28 |
Rounds
R1 · first draft
buildervlmkit check integrity index.htmlverdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 17 exempted)
verdict clean · findings 1 · fails 0 · warns 1 · exempted 17 · viewports 3
output, 29 line(s), 4.2s
vlmkit check integrity source: index.html verdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 17 exempted) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule near-misalignment=suspect 1280x800: 8 component(s), ink 14.8%, 119 text block(s) 768x900: 8 component(s), ink 16.0%, 119 text block(s) 375x700: 8 component(s), ink 16.9%, 115 text block(s) Findings: ! [near-misalignment] div.page-title @1280: div.page-title is off its siblings' shared top edge by 6px inside header.topbar — siblings align exactly; a 2-8px deviation is almost always an accident (stray margin/padding), not a design choice. Exempted candidates (the tool's call — audit the rule, not the page): - [text-collision] legend.sr-only @1280: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] legend.sr-only @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] span.brand-name @768: clipped away by span.brand-name (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-collision] legend.sr-only @768: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] span.brand-name @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(1) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(2) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(3) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(4) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] li.nav-item-end > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] span.sidebar-toggle-label @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] legend.sr-only @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] legend.sr-only @375: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there … 2 more
index.html · 1280×800 · full page, 2 screen(s)
Desktop 1280, dark. Screen 1: expanded sidebar (teal mark + "Keel Analytics", Overview current with a teal tint and a 3px left bar, Traffic/Conversions/Customers, Settings pinned at the bottom, "Collapse" in the foot). Top bar: "Overview" h1 with meta "Harbor & Pine · Last 30 days · Aug 24 – Sep 22, 2026"; right side segmented 7/30/90 days (30 selected, raised), Export CSV, MR avatar. The title block and the 36px controls share a vertical centre (~y32), so the tops differ by design. Four KPI cards read as one component: label, value, a full-width sparkline, and a delta row "↑ +13.0% vs previous 30 days". AOV reads "↓ −2.5%" in red, so up and down each carry an arrow and a sign. At this width the card is too narrow for value + sparkline side by side, so the value drops to 24px. It is the first thing to read, and it would carry more at 28px. Chart card: "Daily visitors" and "Aug 24 – Sep 22 against Jul 25 – Aug 23", legend right (solid This period, dashed Previous period), y 0–2.5k with gridlines, weekly x labels Aug 24…Sep 21, a teal line over a soft area and a grey dashed previous line, and an end dot. The hint line under the chart ("Hover the chart, or focus it…") reads like a manual. Top channels: five teal bars scaled to the max, with values and a muted % column aligned right. DEFECT: the total line "52,858 visitors across 5 channels, Aug 24 – Sep" wraps and leaves "22" alone on line 2. Screen 2: sticky top bar and sidebar hold. The table has faint up/down sort glyphs, and Date is active with a down chevron. Badges Paid/Pending/Refunded/Failed each carry text plus a dot. Amounts are right-aligned and bold, but the IDs are just as bold and compete with the amounts. The pager (Previous disabled grey, 1 current teal, 2, Next) sits opposite "Showing 1–6 of 12 orders", with the fictional-data footnote below.
#channels-totalThe channels total line wraps at 1280 and leaves the day number '22' alone on line 2 ('…Aug 24 – Sep' / '22'); in light theme too (L4).
.kpi-value @1280At 1280 the KPI card is too narrow for value+sparkline side by side, and the same container query also shrinks the value to 24px. The headline number is the card's first read and fits at 28px here; keep the stacked layout, drop the size change.
#orders-body th.col-idOrder IDs are set in the same bold as the amounts, so the ID column competes with the Amount column for the eye; the ID is a lookup key, the amount is the figure.
index.html · 768×1024 · full page, 2 screen(s)
Tablet 768x1024, dark. Screen 1: the sidebar is collapsed to a 72px icon rail: the mark centred, 4 nav icons with Overview tinted and its left bar, Settings at the foot, and the expand toggle below a divider. There are no labels at rest. The top bar has become two rows: title + meta with the MR avatar top-right, then the 7/30/90 segmented control and Export CSV left-aligned. The meta fits on one line. KPIs are 2x2, and here the card is wide enough for the value (28px) and the sparkline to sit side by side, with the delta row below. Deltas read ↑ +13.0%, ↑ +6.2%, ↑ +17.0% and ↓ −2.5%. The chart spans the full width and its labels are crisp, not scaled. Channels move to a full-width card below. Screen 2: the sticky two-row top bar holds. DEFECT: the Recent orders table is wider than its card at 768. The Amount header shows only its sort glyph at the right edge, and every amount is cut to a sliver of "$", with no shadow or fade to say the table scrolls. The brief puts sideways scrolling on phones only, and the amount is the column that matters most. Pager and "Showing 1–6 of 12 orders" fit on one row.
.table-scroll @768At 768 the orders table (min-width 720px) is wider than its 646px card: Amount is cut to a sliver of '$' with no cue that it scrolls. The brief reserves sideways scrolling for phones; at tablet the table should fit.
index.html · 375×812 · full page, 3 screen(s)
Phone 375x812, dark. Screen 1: the bottom navigation bar is fixed: Overview (teal, with a 3px bar on top), Traffic, Conversions, Customers, Settings, each an icon over an 11px label, and "Conversions" fits its fifth. Top bar: compact teal mark + "Overview", MR avatar right. DEFECT: the meta wraps as "Harbor & Pine · Last 30 days" / "· Aug 24 – Sep 22, 2026", so the second line starts with a stray separator dot. The segmented control and "Export CSV" share one row and just fit the 343px column. KPI 2x2: label, 22px value, full-width sparkline, "↑ +13.0%" and then "vs previous 30 days" on its own line. All four cards are the same height, and "Average order value" fits on one line. The chart card legend sits under the subtitle, and the x labels thin to Aug 24 / Sep 7 / Sep 21. Screen 2: the chart hint wraps to "…use the arrow keys, to" / "read each day." and says "Hover", which is meaningless on a touch phone. Channel bars are full width, and the total line fits on one line here. Screen 3: the table scrolls inside its card. The order ID column stays put while Date is cut at "Sep 22, 14:" at the right edge. That is the intended phone behaviour, but the only cue is the clipped text, with no edge shadow. The pager wraps under the status line. The footnote wraps to two lines. Nothing overlaps the bottom bar, because the content has bottom padding.
.chart-hintThe always-on hint under the chart reads like a manual, says 'Hover' on a touch phone, and wraps to a dangling 'read each day.' at 375 (L1 noted the same line at desktop).
.page-meta @375On phones the meta line wraps as 'Harbor & Pine · Last 30 days' / '· Aug 24 – Sep 22, 2026' — the second line starts with a stray separator dot.
.table-scroll @375On phones the only sign that the orders table scrolls sideways is text clipped at the card edge ('Sep 22, 14:'); the pinned ID column has no edge either, so the pin is invisible until you scroll.
vlmkit check copy index.html --manifest copy.txtmissing 0 · placeholders 0 · manifestLines 11 · statesExplored 1 · revealedOnly 0
output, 9 line(s), 1.4s
vlmkit check copy source: index.html status: ok rendered text: 1381 chars disclosure states: 1 explored (details / tabs / aria-expanded) manifest: 11 line(s), missing 0 No copy issues detected.
vlmkit check design index.htmlverdict: DRIFT (1 finding(s))
verdict drift · drifting 1 · roles 7
output, 27 line(s), 1.6s
vlmkit check design
source: index.html
verdict: DRIFT (1 finding(s))
exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule component-drift=suspect
roles judged: 3 of 7 seen, spacing values: 11
coverage: 21 of 308 visible element(s) carried an inferable role; 3 skipped as non-resting
no role: span x51, div x37, path x37, td x30, svg x26, li x16, +19 more tag(s)
A role comes from role="..." or from button/input/select/textarea/h1-h6. Layout elements
(div, span, p, a) have none, so a large skip count is normal — this gate judges components,
not every box. Add role="..." where an element IS a component to widen the coverage.
text-free: 1 (0 judged on box alone — font-size/weight is not observable without painted text)
Role reuse (instances / distinct styles; drift below 3x from 3 instances)
button 10 inst 5 styles reuse 2x 4 one-off drift
h3 4 inst 1 styles reuse 4x 0 one-off ok
h2 3 inst 1 styles reuse 3x 0 one-off ok
h1 1 inst 1 styles reuse 1x 1 one-off not judged
group 1 inst 1 styles reuse 1x 1 one-off not judged
img 1 inst 1 styles reuse 1x 1 one-off not judged
region 1 inst 1 styles reuse 1x 1 one-off not judged
not judged: h1 (1), group (1), img (1), region (1) — under --min-instances 3, so no finding can come from them.
To keep judging them: --min-instances 2 --min-reuse 2 (both — a 2-instance role cannot reach 3x however many instances the floor allows).
Findings
! [component-drift] button: 10 "button" elements render 5 distinct styles (used 6x, 1x, 1x, 1x, …; a system reuses each style 3x or more, and this role averages 2x). Dominant style, used 6x: padding 0/8/0/8, radius 6, 12px/600, border 0, bg rgba(0, 0, 0, 0). Deviating: button#export (padding 0/16/0/16, radius 8, 13px/600, border 1, bg rgb(19, 27, 38)) — differs in padding-right 8 → 16, padding-left 8 → 16, border-radius 6 → 8 and 3 more; button#user-button (padding 0/0/0/0, radius 50, 13px/700, border 1, bg rgb(23, 58, 58)) — differs in padding-right 8 → 0, padding-left 8 → 0, border-radius 6 → 50 and 4 more; div.table-foot>nav.pager>button.pager-btn (padding 0/0/0/0, radius 8, 13px/500, border 1, bg rgb(19, 27, 38)) — differs in padding-right 8 → 0, padding-left 8 → 0, border-radius 6 → 8 and 4 more and 1 more. This reports inconsistency, not which style is correct.button#export, nav.pager buttoncheck design: 10 buttons in 5 styles. The sort headers (6) and the avatar are distinct components by design, but Export CSV and the pager are both secondary buttons and render as three styles: 36px/16px padding/600, 32px/12px padding/500 for Next, and 32px/0 padding/500 for the numbers. One secondary button should be one style.
vlmkit check composition index.htmlverdict: COMPOSED (0 finding(s))
verdict composed · proximity 0 · flatSteps 0 · railNearMisses 0
output, 17 line(s), 1.6s
vlmkit check composition source: index.html verdict: COMPOSED (0 finding(s)) measured: 0 label(s), 3 heading level(s), 28 wide block(s) on 4 left / 5 right rail(s) — from 216 visible box(es) 11 label(s) not judged: they open a box that paints its own group edge, so nothing above them could be mis-grouped with Type hierarchy (largest instance of each declared level) h1 20px / 700 div.page-title>div.page-title-text>h1 h2 15px / 600 h2#chart-title h3 13px / 500 ul.kpi-grid>li.card>h3.kpi-label body 14px, largest 24px (1.71x), heaviest +300 weight Group gaps: 16px between heading-led groups vs 8px inside them (2x, 3 sample(s)). Context only — intact pages measure 0.86-3.00x, so this cannot carry a verdict. Proximity, alignment and type hierarchy all read consistently.
vlmkit check color index.htmlverdict: CONSISTENT (0 finding(s))
verdict consistent · base #0d131c · bodyInk #e6ecf3 · linkInk #8392a5 · invisibleControls 0 · colorOnlyLinks 0 · unreadableColors 0
output, 24 line(s), 1.7s
vlmkit check color
source: index.html
verdict: CONSISTENT (0 finding(s))
measured: 0 control(s), 6 link(s) in a text flow — from 248 visible box(es)
Palette (by painted area; reported, never judged — see the rejected candidates in the module docs)
surfaces 4 distinct
#0d131c 55.8% 2 el
#131b26 35.9% 18 el a.skip-link
#0a0f16 7.0% 1 el nav#sidebar
#18222f 1.3% 6 el thead>tr>th.col-id
ink 5 distinct
#e6ecf3 51.1% 45 el a.skip-link
#aab6c5 27.4% 26 el li>a.nav-link>span.nav-label
#8392a5 20.1% 24 el button#sidebar-toggle>span.sidebar-toggle-label
#86efac 0.9% 3 el tr>td>span.badge
#5eead4 0.5% 2 el li>a.nav-link>span.nav-label
marks 3 distinct
#223041 95.7% 84 el nav#sidebar
#2f3e52 3.7% 16 el button#export
#2dd4bf 0.6% 4 el div.table-foot>nav.pager>button.pager-btn
base #0d131c, body ink #e6ecf3, link ink #8392a5 on 7 element(s)vlmkit check a11y contrast 'index.html?theme=dark'output, 1 line(s), 0.8s
error: file not found: examples/sites/dashboard/index.html?theme=dark
vlmkit check a11y contrast 'index.html?theme=light'output, 1 line(s), 0.8s
error: file not found: examples/sites/dashboard/index.html?theme=light
vlmkit check a11y contrast 'file://$PWD/examples/sites/dashboard/index.html?theme=dark'inspected 119 · failures 0
output, 5 line(s), 1.5s
vlmkit check a11y contrast html: file://examples/sites/dashboard/index.html?theme=dark inspected 119 text-bearing element(s) ✓ 0 contrast failure(s) report: examples/sites/dashboard/test-results/a11y-contrast/index-8f853be8/report.md
vlmkit check a11y contrast 'file://$PWD/examples/sites/dashboard/index.html?theme=light'inspected 119 · failures 0
output, 5 line(s), 1.6s
vlmkit check a11y contrast html: file://examples/sites/dashboard/index.html?theme=light inspected 119 text-bearing element(s) ✓ 0 contrast failure(s) report: examples/sites/dashboard/test-results/a11y-contrast/index-7bdde28d/report.md
light theme, first draft
index.html?theme=light · 1280×800
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Desktop 1280, ?theme=light. The theme parameter works: #f3f5f8 ground, white cards with #e1e6ec hairlines, white sidebar, darker teal (#0d9488) for line, bars and mark, green #15803d "↑ +13.0%" and red "↓ −2.5%" deltas, and a pale-teal avatar with dark-teal "MR". The current nav item keeps its tint and left bar. The chart area fades to white and the dashed previous line is mid-grey. The selected "30 days" segment is only a pale grey fill with a hairline, a weaker selected state than in dark, but still readable because its weight steps to bold. The same "Aug 24 – Sep" / "22" orphan in the channels total, and the same manual-style hint under the chart, as in dark. DEFECT (eye only): a faint grey shadow smears across the top edge of the sidebar brand row, above "Keel Analytics", at x≈0–150, y≈0–6. It is invisible in the dark theme. Close-up in S5.
light theme, brand row close-up (skip-link shadow?)
index.html?theme=light · 1280×800 · element .brand · @2x
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up of the sidebar brand row, light theme. A soft grey shadow band runs along the top edge, from the left to about half the row's width, and fades downward. It is not a border: the row's real bottom hairline is crisp. The shape matches a box-shadow cast from something parked just above the viewport, and the skip link sits at top:-64px with a 32px-blur shadow. DEFECT (eye only). Also at this size the mark reads as a smiley: the zig-zag chart line looks like brows and eyes, and the curved "hull" under it like a smile. That is not a keel, and not what an analytics brand mark should suggest.
a.skip-linkIn light theme the off-screen skip link's 32px-blur box-shadow bleeds into the top of the viewport as a grey smear over the brand row (S4 top-left, close-up S5). Found by eye only — invisible in dark.
.brand-markAt 2x the brand mark reads as a smiley face (zig-zag 'eyes' over a curved 'smile'), not a keel or an analytics mark.
A page path with a query string is taken as a literal filename. Verbatim: 'error: file not found: examples/sites/dashboard/index.html?theme=dark' (G6, and G7 for ?theme=light). The PROTOCOL asks for check a11y contrast in every theme via the brief's URL parameter, so a relative path is the natural first try. Workaround used: an absolute file:// URL ('file:///home/user/vlmkit/examples/sites/dashboard/index.html?theme=light'), which the gate accepts and loads with the query (G8 dark, G9 light). judge.mjs shot accepts 'index.html?theme=light' as a relative path (S4), so the two tools disagree on the same string.
Same query-string path problem as G6; re-run as a file:// URL in G9 (light theme, 0 contrast failures). S4 shows the light theme renders from the same parameter.
vlmkit check a11y focus index.htmlsteps 23 · findings 2
output, 8 line(s), 1.7s
vlmkit check a11y focus
source: index.html
captured 23 focus step(s)
✗ 2 finding(s)
10 focusable element(s) are viewport-pinned (fixed / sticky); jumps into or out of them are not read as order defects
[reverse] Focus moved up by 188px (from `section.card.chart-card>div#chart>svg#chart-svg>g>circle` at y=426 to `div.workspace>main#main>div.main-inner>section.card.orders-card>div#orders-scroll` at y=238). Visual order is top-to-bottom; check `tabindex` or DOM order.
[skip-row] Focus jumped down by 341px (from `table.orders>thead>tr>th.num>button.sort` to `div.main-inner>section.card.orders-card>div.table-foot>nav.pager>button.pager-btn`). Confirm no focusable element was unintentionally skipped.
report: examples/sites/dashboard/test-results/a11y-focus-order/index-d0cf3299/report.mdsvg#chart-svgThe focus sequence in the G10 report has an extra Tab stop at step 10: the whole chart <svg> (text '05001k1.5k…'), before the chart's roving point at step 11. The svg has no tabindex, but it has focusin/focusout listeners, and Chromium makes SVG elements with focus listeners focusable. Keyboard users land on a blank chart with no tooltip.
vlmkit check a11y touch index.htmllevel AA · inspected 21 · failures 0 · wcagExempt 1
output, 6 line(s), 1.5s
vlmkit check a11y touch source: index.html level: WCAG AA (24×24 min) inspected 21 interactive element(s) ✓ 0 undersized target(s) 1 undersized target(s) excused by WCAG 2.5.8 itself (1 in a sentence) report: examples/sites/dashboard/test-results/a11y-touch/index-d0cf3299/report.md
vlmkit scan scroll index.htmlcontainers 0 · overflowX 0 · issues 2
output, 14 line(s), 1.4s
vlmkit scan scroll source: index.html (1280x720) status: warn exits 0 — 2 warn(s) did not fail this command. To gate on one: --rule clipped-content=suspect page: 1280x1285 — horizontal overflow 0px, vertical scroll 565px scroll containers: 0 (dead scrollports 1, clipped 2) Dead scrollports (declared scrollable, content fits): - #orders-scroll (overflow: auto auto) Issues: ! clipped-content #kpis-title: #kpis-title clips 118px of its content behind overflow: hidden — cut-off content unless the oversize is decorative. ! clipped-content legend.sr-only: legend.sr-only clips 68px of its content behind overflow: hidden — cut-off content unless the oversize is decorative.
#orders-scroll @1280scan scroll lists #orders-scroll as a dead scrollport at 1280 (the table fits), yet it carries tabindex=0, so desktop keyboard users get a Tab stop that scrolls nothing. It is also the stop behind G10's 'reverse' jump.
vlmkit scan scroll index.html --viewport 375x740containers 1 · overflowX 0 · issues 2
output, 16 line(s), 1.3s
vlmkit scan scroll source: index.html (375x740) status: warn exits 0 — 2 warn(s) did not fail this command. To gate on one: --rule clipped-content=suspect page: 375x2017 — horizontal overflow 0px, vertical scroll 1277px scroll containers: 1 (dead scrollports 0, clipped 2) Scroll containers: - #orders-scroll: axis=x overflow=379px box=(17,1435) 341x329 Issues: ! clipped-content #kpis-title: #kpis-title clips 118px of its content behind overflow: hidden — cut-off content unless the oversize is decorative. ! clipped-content legend.sr-only: legend.sr-only clips 68px of its content behind overflow: hidden — cut-off content unless the oversize is decorative. Contract hint: --json emits 1 expectedScrollports entry ready for a UI Contract.
vlmkit scan handlers index.htmlregistrations 22 · elements 13 · suspects 0
output, 24 line(s), 1.6s
vlmkit scan handlers source: index.html status: 1 warn(s) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule unprobed-handler-types=suspect registrations: 22 across 13 element(s) + globals, on a page presenting 24 control(s) - div.overview-row>section.card>div#chart>svg#chart-svg "05001k1.5k2k2.5kAug 24Aug 31Sep 7Sep 14S": pointermove, pointerdown, pointerleave, focusin, focusout, keydown (no role) - div.workspace>header.topbar>div.topbar-actions>fieldset#range "Date range 7 days 30 days 90 days": change (delegation container) - section.card>div#orders-scroll>table.orders>thead "Order Customer Date Channel Status Amoun": click (delegation container) - div.main-inner>section.card>div.table-foot>nav.pager "Previous 1 2 Next": click (delegation container) - main#main>div.main-inner>section.card>div#orders-scroll "Order Customer Date Channel Status Amoun": scroll (delegation container) - div.app>nav#sidebar>div.sidebar-foot>button#sidebar-toggle "Collapse": click - div.workspace>header.topbar>div.user>button#user-button "MR": click, keydown - div.workspace>header.topbar>div.user>div#user-menu "Maya Rahman maya@harborpine.example Acco": keydown, click (no role) - nav#sidebar>ul.nav-list>li>a.nav-link "Traffic": click - nav#sidebar>ul.nav-list>li>a.nav-link "Conversions": click - nav#sidebar>ul.nav-list>li>a.nav-link "Customers": click - nav#sidebar>ul.nav-list>li.nav-item-end>a.nav-link "Settings": click - div.workspace>header.topbar>div.topbar-actions>button#export "Export CSV": click - globals: document:pointerdown Issues: warn [unprobed-handler-types] 9 handler type(s) registered and NONE exercised: change, click, focusin, focusout, keydown, pointerdown, pointerleave, pointermove, scroll — this gate is an inventory and presses nothing. A clean result here says the wiring exists, not that it works. Run 'check interactions --handlers' to focus and activate the controls, add '--probe-drag' for the drag surfaces, or drive the rest with a 'verify flow' script.
vlmkit check interactions index.html --handlerselements 22 · suspects 0 · warns 4
output, 65 line(s), 18.5s
vlmkit check interactions source: index.html status: ok exits 0 — 5 warn(s) did not fail this command. To gate on one: --rule inert-control=suspect (also: unprobed-handler-types) interactive elements: 22 - [link] "Skip to content" focus✓ - [link] "Overview" focus✓ - [link] "Traffic" focus✓ - [link] "Conversions" focus✓ - [link] "Customers" focus✓ - [link] "Settings" focus✓ - [button] "Collapse sidebar" focus✓ | Enter: expanded true -> false - [radio] "7" unreachable | ArrowDown: focus moves - [radio] "30" focus✓ | ArrowDown: layout change | announces - [radio] "90" unreachable | ArrowDown: layout change | announces - [button] "Export CSV" focus✓ | Enter: layout change | announces - [button] "Account menu, Maya Rahman" focus✓ | Enter: expanded false -> true | opens menu (focus enters), arrows cycle | Esc closes+returns focus - [button] "Order" focus✓ | Enter: layout change | announces - [button] "Customer" focus✓ | Enter: layout change | announces - [button] "Date" focus✓ | Enter: layout change | announces - [button] "Channel" focus✓ | Enter: layout change | announces - [button] "Status" focus✓ | Enter: layout change | announces - [button] "Amount" focus✓ | Enter: layout change | announces - [button] "Previous" focus✓ | Enter: no response - [button] "Page 1" focus✓ | Enter: no response - [button] "Page 2" focus✓ | Enter: no response - [button] "Next" focus✓ | Enter: no response Issues: warn [inert-control] button "Previous" (section.card>div.table-foot>nav.pager>button.pager-btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Page 1" (section.card>div.table-foot>nav.pager>button.pager-btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Page 2" (section.card>div.table-foot>nav.pager>button.pager-btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Next" (section.card>div.table-foot>nav.pager>button.pager-btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. vlmkit scan handlers source: index.html status: 1 warn(s) registrations: 22 across 13 element(s) + globals, on a page presenting 24 control(s) - div.overview-row>section.card>div#chart>svg#chart-svg "05001k1.5k2k2.5kAug 24Aug 31Sep 7Sep 14S": pointermove, pointerdown, pointerleave, focusin, focusout, keydown (no role) - div.workspace>header.topbar>div.topbar-actions>fieldset#range "Date range 7 days 30 days 90 days": change (delegation container) - section.card>div#orders-scroll>table.orders>thead "Order Customer Date Channel Status Amoun": click (delegation container) - div.main-inner>section.card>div.table-foot>nav.pager "Previous 1 2 Next": click (delegation container) - main#main>div.main-inner>section.card>div#orders-scroll "Order Customer Date Channel Status Amoun": scroll (delegation container) - div.app>nav#sidebar>div.sidebar-foot>button#sidebar-toggle "Collapse": click - div.workspace>header.topbar>div.user>button#user-button "MR": click, keydown - div.workspace>header.topbar>div.user>div#user-menu "Maya Rahman maya@harborpine.example Acco": keydown, click (no role) - nav#sidebar>ul.nav-list>li>a.nav-link "Traffic": click - nav#sidebar>ul.nav-list>li>a.nav-link "Conversions": click - nav#sidebar>ul.nav-list>li>a.nav-link "Customers": click - nav#sidebar>ul.nav-list>li.nav-item-end>a.nav-link "Settings": click - div.workspace>header.topbar>div.topbar-actions>button#export "Export CSV": click - globals: document:pointerdown Pointer-drag gesture (real mouse input): - div.overview-row>section.card>div#chart>svg#chart-svg: feedback while held 13.25%, changed after release 13.32% Wheel gesture (real wheel input): - main#main>div.main-inner>section.card>div#orders-scroll: a 200px wheel moved 400px Issues: warn [unprobed-handler-types] Handler types registered but NOT exercised by this run's probes: change, click, focusin, focusout, keydown, pointerleave — the probe focuses a control and presses the key its role activates with, so a `click` handler on anything but a native control, and any type outside focus/blur/keyboard, is untested here. Verify those paths with a 'verify flow' script.
#orders-status, nav.pagercheck interactions finds 'Page 2' and 'Next' inert: no ARIA change and no announcement on Enter. The rows and 'Showing 7–12 of 12 orders' do change, but the status line is not a live region, so a screen-reader user hears nothing after paging.
vlmkit check breakpoints index.html --sweepbreakpoints 2 · issues 0 · sweepRanges 0
output, 11 line(s), 3.1s
vlmkit check breakpoints source: index.html status: ok width sweep: 320-1280px step 25 (39 widths) — clean breakpoints checked: 767, 768px (each at B-1 / B / B+1) 767px: clean (max-width: 767px) 768px: clean (min-width: 768px) All boundaries consistent.
vlmkit check scroll index.htmlpageScrolled 565 · stickyFixed 10 · engagedSticky 2 · snaps 0 · issues 0
output, 21 line(s), 1.5s
vlmkit check scroll source: index.html status: ok page scrolled: 565px sticky/fixed elements: 10 (sticky engaged by the scroll: 2) snap containers driven: 0 Sticky / fixed: - #sidebar: sticky top=0px viewport y 0 -> 0 - header.topbar: sticky top=0px viewport y 0 -> 0 - table.orders > thead:nth-of-type(1) > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 804 -> 239 - #orders-body > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 844 -> 279 - #orders-body > tr:nth-of-type(2) > th:nth-of-type(1): sticky viewport y 892 -> 327 - #orders-body > tr:nth-of-type(3) > th:nth-of-type(1): sticky viewport y 940 -> 375 - #orders-body > tr:nth-of-type(4) > th:nth-of-type(1): sticky viewport y 988 -> 423 - #orders-body > tr:nth-of-type(5) > th:nth-of-type(1): sticky viewport y 1036 -> 471 - #orders-body > tr:nth-of-type(6) > th:nth-of-type(1): sticky viewport y 1084 -> 519 - #toast: fixed top=670px viewport y 678 -> 678 No scroll-behavior issues detected.
vlmkit check scroll index.html --viewport 375x740pageScrolled 1110 · stickyFixed 9 · engagedSticky 0 · snaps 0 · issues 0
output, 20 line(s), 1.5s
vlmkit check scroll source: index.html status: ok page scrolled: 1110px sticky/fixed elements: 9 (sticky engaged by the scroll: 0) snap containers driven: 0 Sticky / fixed: - #sidebar: fixed top=675px viewport y 675 -> 675 - table.orders > thead:nth-of-type(1) > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 1436 -> 326 - #orders-body > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 1476 -> 366 - #orders-body > tr:nth-of-type(2) > th:nth-of-type(1): sticky viewport y 1524 -> 414 - #orders-body > tr:nth-of-type(3) > th:nth-of-type(1): sticky viewport y 1572 -> 462 - #orders-body > tr:nth-of-type(4) > th:nth-of-type(1): sticky viewport y 1620 -> 510 - #orders-body > tr:nth-of-type(5) > th:nth-of-type(1): sticky viewport y 1668 -> 558 - #orders-body > tr:nth-of-type(6) > th:nth-of-type(1): sticky viewport y 1716 -> 606 - #toast: fixed top=634px viewport y 642 -> 642 No scroll-behavior issues detected.
vlmkit check breakpoints index.html --breakpoints 768,1280 --sweepbreakpoints 2 · issues 0 · sweepRanges 0
output, 11 line(s), 3.4s
vlmkit check breakpoints source: index.html status: ok width sweep: 320-1380px step 25 (43 widths) — clean breakpoints checked: 768, 1280px (each at B-1 / B / B+1) 768px: clean 1280px: clean All boundaries consistent.
vlmkit check design index.html --jsonverdict drift · drifting 1 · roles 7
output, 205 line(s), 1.7s
{
"gate": "check.design",
"command": "check design",
"verdict": "pass",
"counts": {
"suspect": 0,
"warn": 1,
"info": 0
},
"findings": [
{
"rule": "component-drift",
"severity": "warn",
"message": "10 \"button\" elements render 5 distinct styles (used 6x, 1x, 1x, 1x, …; a system reuses each style 3x or more, and this role averages 2x). Dominant style, used 6x: padding 0/8/0/8, radius 6, 12px/600, border 0, bg rgba(0, 0, 0, 0). Deviating: button#export (padding 0/16/0/16, radius 8, 13px/600, border 1, bg rgb(19, 27, 38)) — differs in padding-right 8 → 16, padding-left 8 → 16, border-radius 6 → 8 and 3 more; button#user-button (padding 0/0/0/0, radius 50, 13px/700, border 1, bg rgb(23, 58, 58)) — differs in padding-right 8 → 0, padding-left 8 → 0, border-radius 6 → 50 and 4 more; div.table-foot>nav.pager>button.pager-btn (padding 0/0/0/0, radius 8, 13px/500, border 1, bg rgb(19, 27, 38)) — differs in padding-right 8 → 0, padding-left 8 → 0, border-radius 6 → 8 and 4 more and 1 more. This reports inconsistency, not which style is correct.",
"evidence": {
"role": "button"
}
}
],
"suppressed": [],
"retuned": [],
"report": {
"source": "index.html",
"roles": [
{
"role": "button",
"instances": 10,
"signatures": 5,
"reuse": 2,
"singletons": 4
},
{
"role": "h3",
"instances": 4,
"signatures": 1,
"reuse": 4,
"singletons": 0
},
{
"role": "h2",
"instances": 3,
"signatures": 1,
"reuse": 3,
"singletons": 0
},
{
"role": "h1",
"instances": 1,
"signatures": 1,
"reuse": 1,
"singletons": 1,
"notJudged": true
},
{
"role": "group",
"instances": 1,
"signatures": 1,
"reuse": 1,
"singletons": 1,
"notJudged": true
},
{
"role": "img",
"instances": 1,
"signatures": 1,
"reuse": 1,
"singletons": 1,
"notJudged": true
},
{
"role": "region",
"instances": 1,
"signatures": 1,
"reuse": 1,
"singletons": 1,
"notJudged": true
}
],
"findings": [
{
"kind": "component-drift",
"severity": "warn",
"role": "button",
"message": "10 \"button\" elements render 5 distinct styles (used 6x, 1x, 1x, 1x, …; a system reuses each style 3x or more, and this role averages 2x). Dominant style, used 6x: padding 0/8/0/8, radius 6, 12px/600, border 0, bg rgba(0, 0, 0, 0). Deviating: button#export (padding 0/16/0/16, radius 8, 13px/600, border 1, bg rgb(19, 27, 38)) — differs in padding-right 8 → 16, padding-left 8 → 16, border-radius 6 → 8 and 3 more; button#user-button (padding 0/0/0/0, radius 50, 13px/700, border 1, bg rgb(23, 58, 58)) — differs in padding-right 8 → 0, padding-left 8 → 0, border-radius 6 → 50 and 4 more; div.table-foot>nav.pager>button.pager-btn (padding 0/0/0/0, radius 8, 13px/500, border 1, bg rgb(19, 27, 38)) — differs in padding-right 8 → 0, padding-left 8 → 0, border-radius 6 → 8 and 4 more and 1 more. This reports inconsistency, not which style is correct."
}
],
"skipped": 284,
"skippedTags": [
{
"tag": "span",
"count": 51
},
{
"tag": "div",
"count": 37
},
{
"tag": "path",
"count": 37
},
{
"tag": "td",
"count": 30
},
{
"tag": "svg",
"count": 26
},
{
"tag": "li",
"count": 16
},
{
"tag": "p",
"count": 16
},
{
"tag": "th",
"count": 12
},
{
"tag": "text",
"count": 11
},
{
"tag": "circle",
"count": 7
},
{
"tag": "rect",
"count": 7
},
{
"tag": "tr",
"count": 7
},
{
"tag": "a",
"count": 6
},
{
"tag": "section",
"count": 4
},
{
"tag": "label",
"count": 3
},
{
"tag": "ul",
"count": 3
},
{
"tag": "g",
"count": 2
},
{
"tag": "nav",
"count": 2
},
{
"tag": "fieldset",
"count": 1
},
{
"tag": "header",
"count": 1
},
{
"tag": "main",
"count": 1
},
{
"tag": "ol",
"count": 1
},
{
"tag": "table",
"count": 1
},
{
"tag": "tbody",
"count": 1
},
{
"tag": "thead",
"count": 1
}
],
"judgedElements": 21,
"statefulSkipped": 3,
"exclusions": [],
"excludedElements": 0,
"unusedExcludes": [],
"unusedAllow": [],
"textFreeSamples": 1,
"textFreeFolded": 0,
"spacingValues": 11,
"thresholds": {
"minReuse": 3,
"minInstances": 3
},
"verdict": "drift"
}
}vlmkit check drift component index.html --selector .badge --output-dir /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/driftselector .badge · instances 6 · drifting 3
output, 33 line(s), 2.2s
vlmkit check drift component
html: examples/sites/dashboard/index.html selector: .badge
6 instance(s), reference = #0
verdict is set by tracked computed style; the pixel % is context, not the pass line
✗ instance #1 7 tracked properties differ 39.63% px Δ +22 / 0
border-top-color: rgb(134, 239, 172) → rgb(252, 211, 77)
border-right-color: rgb(134, 239, 172) → rgb(252, 211, 77)
border-bottom-color: rgb(134, 239, 172) → rgb(252, 211, 77)
border-left-color: rgb(134, 239, 172) → rgb(252, 211, 77)
background-color: rgba(74, 222, 128, 0.13) → rgba(251, 191, 36, 0.13)
color: rgb(134, 239, 172) → rgb(252, 211, 77)
and 1 more property
✓ instance #2 identical 0.00% px Δ 0 / 0
✗ instance #3 7 tracked properties differ 44.57% px Δ +30 / 0
border-top-color: rgb(134, 239, 172) → rgb(203, 213, 225)
border-right-color: rgb(134, 239, 172) → rgb(203, 213, 225)
border-bottom-color: rgb(134, 239, 172) → rgb(203, 213, 225)
border-left-color: rgb(134, 239, 172) → rgb(203, 213, 225)
background-color: rgba(74, 222, 128, 0.13) → rgba(148, 163, 184, 0.15)
color: rgb(134, 239, 172) → rgb(203, 213, 225)
and 1 more property
✓ instance #4 identical 0.00% px Δ 0 / 0
✗ instance #5 7 tracked properties differ 27.75% px Δ +9 / 0
border-top-color: rgb(134, 239, 172) → rgb(252, 165, 165)
border-right-color: rgb(134, 239, 172) → rgb(252, 165, 165)
border-bottom-color: rgb(134, 239, 172) → rgb(252, 165, 165)
border-left-color: rgb(134, 239, 172) → rgb(252, 165, 165)
background-color: rgba(74, 222, 128, 0.13) → rgba(248, 113, 113, 0.15)
color: rgb(134, 239, 172) → rgb(252, 165, 165)
and 1 more property
if a difference is intentional, declare it — it stays listed and a stale rule is reported:
--allow "border-top-color@.badge--pending;<why>"
report: /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift/report.mdvlmkit check drift component index.html --selector .kpi --output-dir /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift-kpiselector .kpi · instances 4 · drifting 2
output, 11 line(s), 1.9s
vlmkit check drift component
html: examples/sites/dashboard/index.html selector: .kpi
4 instance(s), reference = #0
verdict is set by tracked computed style; the pixel % is context, not the pass line
~ instance #1 no tracked property differs 4.25% px Δ 0 / 0
every property on the instance root matches and the palettes agree — this looks like different content
~ instance #2 no tracked property differs 2.83% px Δ 0 / 0
every property on the instance root matches and the palettes agree — this looks like different content
~ instance #3 no tracked property differs 4.39% px Δ 0 / 0
every property on the instance root matches and the palettes agree — this looks like different content
report: /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift-kpi/report.mdnear-misalignment on div.page-title (6px off the top edge its siblings share in header.topbar). The top bar is a grid with align-items:center. The title block is 48px tall (28px h1 + 20px meta) and the controls are 36px, so their tops differ by (48-36)/2 = 6px by construction while their centres coincide at y≈32 (S1-1: the 'Overview'/meta block and the segmented control, Export and avatar share one horizontal axis). Aligning the tops instead would lift the controls 6px above the bar's optical centre. The gate measures top edges only, and centring is the intent here.
The two clipped-content warns (#kpis-title 118px, legend.sr-only 68px) are my visually-hidden heading and fieldset legend: a 1px clip box that holds text for assistive technology only. check integrity classifies the same two elements as the exempted 'visually-hidden (sr-only) pattern' (G1 exempted list), so the two gates disagree about one pattern. Nothing a sighted user should see is cut off. The dead-scrollport line in this run is acted on as D11.
Same two sr-only elements as G12 (#kpis-title, legend.sr-only), reported as clipped-content at 375; check integrity exempts them as the visually-hidden pattern. The one real scroll container, #orders-scroll (axis x, 379px overflow), is exactly the phone behaviour the brief asks for, and page overflow-x is 0.
scan handlers is an inventory: no pointer-only control, and its one warn (9 handler types never exercised) says to run check interactions --handlers, which G15 did. The chart's keydown / focusin paths, which no probe fires, are covered by a verify flow in the next round.
The total is now a row on the same three-column grid as the channel rows: 'Total visitors' | 52,858 | 100%. The value sits under the values column, and the date span that caused the wrap is gone (the range is already in the top bar).
The narrow-card container query only stacks value and sparkline now; the value keeps 28px. Phones set 24px explicitly (it was 22px).
Row-header order IDs drop from 600 to 500 weight; amounts stay 600, so the figure column carries the emphasis.
Removed the static hint paragraph. When the tooltip is opened from the keyboard it gains a last line '← → day · Home End'; pointer and touch tooltips carry no instructions. The arrow-key instruction also stays in the chart's aria-label.
Dropped the table's fixed min-width:720px; the nowrap cells define its natural width. A container query on the orders card (≤760px) tightens cell padding to 12px (first/last cells keep 24px to align with the card text), so at 768 the six columns fit and only phones scroll.
On phones (#range-dates) takes a full line (flex-basis 100%) and loses its separator (::before content:none), so the meta reads 'Harbor & Pine · Last 30 days' / 'Aug 24 – Sep 22, 2026'.
Scroll shadows in the scroll container's own background: a right-edge radial shadow (scroll-attached) with a surface-coloured cover (local-attached) that hides it once the last column is in view. The pinned ID column casts a shadow once scrollLeft>0 (.is-scrolled). No overlay element, so no text is covered.
The skip link's box-shadow now exists only on :focus (while it is on screen at top:16px), so nothing is cast into the viewport at rest.
Redrew the mark as a sailboat in the sidebar ink on the teal tile: mainsail, jib, hull and a small keel fin. The mainsail's leading edge rises left-to-right like a chart line. Same paths in the phone top-bar copy.
Moved pointermove/pointerdown/pointerleave/focusin/focusout/keydown from the <svg> to its HTML wrapper div#chart. Events from the points bubble there, and the svg no longer has focus listeners, so it is not a Tab stop.
syncScroller(): #orders-scroll gets role=region, aria-label 'Orders table, scrolls sideways' and tabindex=0 only while scrollWidth > clientWidth, and loses all three otherwise. It runs on load, on resize (ResizeObserver on the scroller), after every re-render and on scroll.
#orders-status is aria-live=polite, so paging announces 'Showing 7–12 of 12 orders'; sorting keeps its own announcement.
The pager buttons now use .btn, the page's one secondary button: 36px tall, 12px side padding, 13px/600, 8px radius, 1px border. Export CSV moved from 16px to 12px padding to match. Digits are held square with min-width 36px. The current and disabled states are the only variants.
R2 · fix pass
buildervlmkit check integrity index.htmlverdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 20 exempted)
verdict clean · findings 1 · fails 0 · warns 1 · exempted 20 · viewports 3
output, 29 line(s), 4.4s
vlmkit check integrity source: index.html verdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 20 exempted) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule near-misalignment=suspect 1280x800: 8 component(s), ink 14.7%, 120 text block(s) 768x900: 8 component(s), ink 16.1%, 120 text block(s) 375x700: 8 component(s), ink 16.9%, 116 text block(s) Findings: ! [near-misalignment] div.page-title @1280: div.page-title is off its siblings' shared top edge by 6px inside header.topbar — siblings align exactly; a 2-8px deviation is almost always an accident (stray margin/padding), not a design choice. Exempted candidates (the tool's call — audit the rule, not the page): - [text-collision] legend.sr-only @1280: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] legend.sr-only @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [low-contrast-text] (page) @1280: 30 text block(s) skipped: background-image/gradient in the stack — composite-background contrast is not deterministically measurable (Layer B territory) - [text-collision] span.brand-name @768: clipped away by span.brand-name (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-collision] legend.sr-only @768: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] span.brand-name @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(1) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(2) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(3) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(4) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] li.nav-item-end > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] span.sidebar-toggle-label @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] legend.sr-only @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @768: visually-hidden (sr-only) pattern — 1px box, text for AT only … 5 more
.table-scrollSide effect of F7: check integrity now skips contrast for 30 text blocks at 1280 ('background-image/gradient in the stack — composite-background contrast is not deterministically measurable'). The scroll-shadow gradients sit behind every table cell at every width, including 1280 where the table fits and the shadow is fully covered, so the gate lost sight of the table text for nothing.
The two background gradients moved to .table-scroll.can-scroll; syncScroller() sets can-scroll only while scrollWidth > clientWidth. The table has no background image at 1280/tablet, where it fits; the shadow only exists where there is something to scroll to.
vlmkit check integrity index.htmlverdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 18 exempted)
verdict clean · findings 1 · fails 0 · warns 1 · exempted 18 · viewports 3
output, 29 line(s), 4.5s
vlmkit check integrity source: index.html verdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 18 exempted) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule near-misalignment=suspect 1280x800: 8 component(s), ink 14.7%, 120 text block(s) 768x900: 8 component(s), ink 16.1%, 120 text block(s) 375x700: 8 component(s), ink 16.9%, 116 text block(s) Findings: ! [near-misalignment] div.page-title @1280: div.page-title is off its siblings' shared top edge by 6px inside header.topbar — siblings align exactly; a 2-8px deviation is almost always an accident (stray margin/padding), not a design choice. Exempted candidates (the tool's call — audit the rule, not the page): - [text-collision] legend.sr-only @1280: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] legend.sr-only @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] span.brand-name @768: clipped away by span.brand-name (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-collision] legend.sr-only @768: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] span.brand-name @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(1) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(2) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(3) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(4) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] li.nav-item-end > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] span.sidebar-toggle-label @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] legend.sr-only @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] legend.sr-only @375: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there … 3 more
No 'background-image/gradient in the stack' skip in the integrity run at 1280; exempted count 20 → 18.
index.html · 1280×800 · full page, 2 screen(s)
Desktop 1280 dark, after the round-1 fixes. Screen 1: the KPI values are back at 28px in the stacked card (52,858 / 2.64% / $91,725 / $65.66) and are clearly the first read. Cards are 172px tall, and the sparklines and delta rows line up across all four. The chart card now ends 24px under its x labels with no hint paragraph, and it is the same height as Top channels. The channels footer reads "Total visitors | 52,858 | 100%" on one line, with the total right-aligned under the values column and 100% under the % column. The new mark in the sidebar is a small sailboat on the teal tile. Screen 2: order IDs are regular-weight (#KA-20417…), so the bold amounts now carry the table. Previous, 1, 2 and Next are all 36px like Export CSV, with the same radius and border. Previous is visibly disabled (grey text, no fill), and 1 carries the teal current state. "Showing 1–6 of 12 orders" is still opposite the pager. No new wrap or overlap anywhere.
index.html · 768×1024 · full page, 2 screen(s)
Tablet 768 dark. Screen 2: the orders table now fits its card, with Order / Customer / Date / Channel / Status / Amount all visible and 12px cell padding. Amount is right-aligned at the card edge, $184.00 … $76.20, and nothing is clipped, so the tablet no longer scrolls sideways. The header sort glyphs, the Date chevron and the badges are intact. Screen 1 is unchanged and good: icon rail, two-row top bar, 2x2 KPIs with value and sparkline side by side, and a full-width chart without the hint line. The channels footer "Total visitors 52,858 100%" aligns with the rows above it.
index.html · 375×812 · full page, 3 screen(s)
Phone 375 dark. Screen 1: the meta now breaks as "Harbor & Pine · Last 30 days" / "Aug 24 – Sep 22, 2026", and the second line has no leading dot. The sailboat mark sits beside "Overview", and MR is top-right. The segmented control and Export CSV still share one row. KPI values are 24px and still fit their cards, with "vs previous 30 days" on its own line. The chart has no hint line. Screen 2: channel rows and the total row are intact. Screen 3: the table's 12px padding shows Order, Customer and Date in full ("Sep 22, 14:32"), and the scroll edge now falls exactly at the next column boundary. The only cue that three more columns exist is a faint dark band down the right edge of the body rows, not the header. See the 2x close-up S10. Pager buttons are 36px and wrap under the status line.
light theme brand row after F8/F9
index.html?theme=light · 1280×800 · element .brand · @2x
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up of the brand row, light theme, after F8/F9. The top edge is clean white with no grey band, so the skip link no longer casts anything while it is parked off-screen. The new mark reads as a sailboat: a white mainsail and jib over a white hull with a short keel fin, on the teal rounded tile. It no longer reads as a face. "Keel Analytics" sits 12px to the right, on the tile's centre line.
phone orders card at rest (scroll cue)
index.html · 375×812 · element .orders-card · @2x
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up of the phone orders card at rest. The pinned column (#KA-20417…) has no edge of its own; it looks like any other column. Order, Customer and Date ("Sep 22, 14:32") are fully visible, and the viewport ends in the empty padding before Channel, so no text is cut. The right-edge shadow is a ~16px dark band visible down the six body rows only; the header row's opaque fill hides it. It is too faint for a first-time visitor to read as "more columns this way", and the hidden columns include Amount, the figure people come to the table for. The pager and status below are fine: Previous disabled, 1 current in teal, 2, Next.
Second attempt, after L10 showed the background shadow was too faint and hidden behind the header: a wrapper div#orders-frame carries a 24px fade (::after, pointer-events none) over its right edge, header included, only while columns remain to the right (can-scroll-right from syncScroller). The pinned ID column shows a hairline whenever the table can scroll, and a cast shadow once it has scrolled. The background gradients are gone, so no text has an image in its background stack.
phone orders card at rest, frame fade
index.html · 375×812 · element .orders-card · @2x
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up, phone orders card at rest, after the second D7 fix. A vertical hairline now separates the pinned ID column from Customer on every row, header included, so the column reads as fixed before anyone touches it. The right edge carries a clear dark fade about 24px wide over the header ("Date" cell) and all six rows, which reads as "more this way". Order, Customer and Date are fully legible, and the fade sits over the empty padding past "Sep 22, 14:32" without dimming any text. The pager below is unchanged.
phone orders card scrolled to Amount
index.html · 375×812 · element .orders-card · @2x · scroll-to .orders tbody tr:first-child td:last-child
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up, phone orders card after scrolling sideways to the Amount cell. The ID column stays pinned at the left with its hairline and a soft cast shadow, and Channel slides underneath it ("anic", "il", "ct"…), cut cleanly at the pinned column's opaque edge with no text showing through. Status badges (Paid / Pending / Paid / Refunded / Paid / Failed) and right-aligned Amount ($184.00 … $76.20) are in full view, and the right-edge fade is gone because the end is reached. The fixed bottom navigation overlaps the pager only because this element shot extends below the viewport at this scroll position; the main area's bottom padding clears it on a normal scroll.
Pinned hairline + right fade at rest (L11); pin and cast shadow hold while scrolled, fade gone at the end (L12).
vlmkit check a11y focus index.htmlsteps 21 · findings 1
output, 8 line(s), 2.1s
vlmkit check a11y focus
source: index.html
captured 21 focus step(s)
! 1 finding(s)
10 focusable element(s) are viewport-pinned (fixed / sticky); jumps into or out of them are not read as order defects
[skip-row] Focus jumped down by 341px (from `table.orders>thead>tr>th.num>button.sort` to `div.main-inner>section.card.orders-card>div.table-foot>nav.pager>button.btn.pager-btn`). Confirm no focusable element was unintentionally skipped.
report: examples/sites/dashboard/test-results/a11y-focus-order/index-d0cf3299/report.md
exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule skip-row=suspectFocus sequence goes Account menu → the chart's roving point (step 10); the bare svg stop is gone, 23 → 21 steps.
No #orders-scroll stop at 1280; the chart point is followed directly by the Order sort button; the 'reverse' finding is gone.
vlmkit scan scroll index.htmlcontainers 0 · overflowX 0 · issues 2
output, 14 line(s), 1.3s
vlmkit scan scroll source: index.html (1280x720) status: warn exits 0 — 2 warn(s) did not fail this command. To gate on one: --rule clipped-content=suspect page: 1280x1281 — horizontal overflow 0px, vertical scroll 561px scroll containers: 0 (dead scrollports 1, clipped 2) Dead scrollports (declared scrollable, content fits): - #orders-scroll (overflow: auto auto) Issues: ! clipped-content #kpis-title: #kpis-title clips 118px of its content behind overflow: hidden — cut-off content unless the oversize is decorative. ! clipped-content legend.sr-only: legend.sr-only clips 68px of its content behind overflow: hidden — cut-off content unless the oversize is decorative.
vlmkit check interactions index.html --handlerselements 22 · suspects 0 · warns 4
output, 65 line(s), 18.6s
vlmkit check interactions source: index.html status: ok exits 0 — 5 warn(s) did not fail this command. To gate on one: --rule inert-control=suspect (also: unprobed-handler-types) interactive elements: 22 - [link] "Skip to content" focus✓ - [link] "Overview" focus✓ - [link] "Traffic" focus✓ - [link] "Conversions" focus✓ - [link] "Customers" focus✓ - [link] "Settings" focus✓ - [button] "Collapse sidebar" focus✓ | Enter: expanded true -> false - [radio] "7" unreachable | ArrowDown: focus moves - [radio] "30" focus✓ | ArrowDown: layout change | announces - [radio] "90" unreachable | ArrowDown: layout change | announces - [button] "Export CSV" focus✓ | Enter: layout change | announces - [button] "Account menu, Maya Rahman" focus✓ | Enter: expanded false -> true | opens menu (focus enters), arrows cycle | Esc closes+returns focus - [button] "Order" focus✓ | Enter: layout change | announces - [button] "Customer" focus✓ | Enter: layout change | announces - [button] "Date" focus✓ | Enter: layout change | announces - [button] "Channel" focus✓ | Enter: layout change | announces - [button] "Status" focus✓ | Enter: layout change | announces - [button] "Amount" focus✓ | Enter: layout change | announces - [button] "Previous" focus✓ | Enter: no response - [button] "Page 1" focus✓ | Enter: no response - [button] "Page 2" focus✓ | Enter: no response | announces - [button] "Next" focus✓ | Enter: no response | announces Issues: warn [inert-control] button "Previous" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Page 1" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Page 2" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Next" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. vlmkit scan handlers source: index.html status: 1 warn(s) registrations: 22 across 13 element(s) + globals, on a page presenting 23 control(s) - div.main-inner>div.overview-row>section.card>div#chart "05001k1.5k2k2.5kAug 24Aug 31Sep 7Sep 14S": pointermove, pointerdown, pointerleave, focusin, focusout, keydown (no role) - div.workspace>header.topbar>div.topbar-actions>fieldset#range "Date range 7 days 30 days 90 days": change (delegation container) - div#orders-frame>div#orders-scroll>table.orders>thead "Order Customer Date Channel Status Amoun": click (delegation container) - div.main-inner>section.card>div.table-foot>nav.pager "Previous 1 2 Next": click (delegation container) - div.main-inner>section.card>div#orders-frame>div#orders-scroll "Order Customer Date Channel Status Amoun": scroll (delegation container) - div.app>nav#sidebar>div.sidebar-foot>button#sidebar-toggle "Collapse": click - div.workspace>header.topbar>div.user>button#user-button "MR": click, keydown - header.topbar>div.user>div#user-menu-panel>div#user-menu "Account settings Light theme Sign out": keydown, click (no role) - nav#sidebar>ul.nav-list>li>a.nav-link "Traffic": click - nav#sidebar>ul.nav-list>li>a.nav-link "Conversions": click - nav#sidebar>ul.nav-list>li>a.nav-link "Customers": click - nav#sidebar>ul.nav-list>li.nav-item-end>a.nav-link "Settings": click - div.workspace>header.topbar>div.topbar-actions>button#export "Export CSV": click - globals: document:pointerdown Pointer-drag gesture (real mouse input): - div.main-inner>div.overview-row>section.card>div#chart: feedback while held 12.20%, changed after release 12.27% Wheel gesture (real wheel input): - div.main-inner>section.card>div#orders-frame>div#orders-scroll: a 200px wheel moved 400px Issues: warn [unprobed-handler-types] Handler types registered but NOT exercised by this run's probes: change, click, focusin, focusout, keydown, pointerleave — the probe focuses a control and presses the key its role activates with, so a `click` handler on anything but a native control, and any type outside focus/blur/keyboard, is untested here. Verify those paths with a 'verify flow' script.
'Page 2' and 'Next' now report 'announces' (the live status line). The inert-control warn on them persists for another reason, noted separately.
vlmkit check design index.htmlverdict: DRIFT (1 finding(s))
verdict drift · drifting 1 · roles 6
output, 26 line(s), 1.6s
vlmkit check design
source: index.html
verdict: DRIFT (1 finding(s))
exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule component-drift=suspect
roles judged: 4 of 6 seen, spacing values: 11
coverage: 24 of 313 visible element(s) carried an inferable role; 3 skipped as non-resting
no role: span x54, div x40, path x39, td x30, svg x26, p x15, +19 more tag(s)
A role comes from role="..." or from button/input/select/textarea/h1-h6. Layout elements
(div, span, p, a) have none, so a large skip count is normal — this gate judges components,
not every box. Add role="..." where an element IS a component to widen the coverage.
text-free: 1 (0 judged on box alone — font-size/weight is not observable without painted text)
Role reuse (instances / distinct styles; drift below 3x from 3 instances)
button 10 inst 3 styles reuse 3.33x 1 one-off ok
group 5 inst 2 styles reuse 2.5x 1 one-off drift
h3 4 inst 1 styles reuse 4x 0 one-off ok
h2 3 inst 1 styles reuse 3x 0 one-off ok
h1 1 inst 1 styles reuse 1x 1 one-off not judged
img 1 inst 1 styles reuse 1x 1 one-off not judged
not judged: h1 (1), img (1) — under --min-instances 3, so no finding can come from them.
To keep judging them: --min-instances 2 --min-reuse 2 (both — a 2-instance role cannot reach 3x however many instances the floor allows).
Findings
! [component-drift] group: 5 "group" elements render 2 distinct styles (used 4x, 1x; a system reuses each style 3x or more, and this role averages 2.5x). Dominant style, used 4x: padding 16/24/24/24, radius 12, 14px/400, border 1, bg rgb(19, 27, 38). Deviating: svg#chart-svg (padding 0/0/0/0, radius 0, 14px/400, border 0, bg rgba(0, 0, 0, 0)) — differs in padding-top 16 → 0, padding-right 24 → 0, padding-bottom 24 → 0 and 4 more. This reports inconsistency, not which style is correct.D13 confirmed from the gate side: button reuse is now 3.33x (10 instances, 3 styles: the sort headers, the shared .btn and the avatar), verdict ok. (D13 was verified earlier by look L6.) The new 'group' drift is two different components sharing one ARIA role: the 4 KPI cards (role=group since this round, one style) and the chart's svg#chart-svg (role=group + aria-roledescription 'line chart'). They are not meant to look alike. The final run declares the svg with --allow instead of renaming a correct role.
chart tooltip from the keyboard (end point, then ← ←)
index.html · 1280×800 · element .chart-card · focus .c-pt[data-i="29"] · press ArrowLeft · press ArrowLeft
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Chart card close-up at 1280. Focus was put on the Sep 22 end point and moved twice with ArrowLeft. The tooltip reads "Sun, Sep 20", then a solid teal swatch "This period 1,672", then a dashed grey swatch "Aug 21 1,645". A last row, separated by a hairline, reads "← → day · Home End"; that hint exists only because the tooltip was opened from the keyboard. It sits to the LEFT of the point because the right side would overflow the card, and it is vertically centred on the point, covering the line between ~Sep 9 and Sep 19 but not the active day. A thin vertical crosshair runs through Sep 20. The focused point is a hollow teal ring, and the Sep 22 end point stays a filled dot, so both the current day and the latest day are distinct. There is no static hint paragraph under the chart any more; the card ends at the x-axis labels. Values use tabular figures and align right in the tooltip.
Static hint removed; the keyboard hint appears inside the keyboard-opened tooltip only.
R3 · interactions and states
buildervlmkit verify flow index.html --flow flow.jsonverdict: DONE (14/14 steps)
done true · passed 14 · total 14
output, 60 line(s), 2.2s
vlmkit verify flow
source: index.html
verdict: DONE (14/14 steps)
✓ step 1: Choose the 90-day range
✓ #range-label text~"Last 90 days"
✓ #range-dates text~"Jun 25 – Sep 22, 2026"
✓ [data-kpi=visitors] .kpi-value text~"143,800"
✓ [data-kpi=conversion] .delta text~"−2.0%"
✓ [data-kpi=visitors] .delta-ctx text~"vs previous 90 days"
✓ #channels-total text~"143,800"
✓ .c-pt count=90
✓ step 2: Focus the latest day on the chart
✓ #chart-tip visible
✓ #chart-tip text~"Tue, Sep 22"
✓ step 3: ArrowLeft moves one day back
✓ .c-pt[data-i="88"] focused
✓ .c-pt[data-i="88"] tabindex=0
✓ .c-pt[data-i="89"] tabindex=-1
✓ #chart-tip text~"Mon, Sep 21"
✓ step 4: Home jumps to the first day of the range
✓ .c-pt[data-i="0"] focused
✓ #chart-tip text~"Thu, Jun 25"
✓ step 5: Sort the orders by amount
✓ th.num aria-sort=ascending
✓ th[aria-sort] count=1
✓ #orders-body tr:first-child .cell-amount text~"$37.80"
✓ step 6: Sort by amount again: largest first
✓ th.num aria-sort=descending
✓ #orders-body tr:first-child .cell-amount text~"$318.90"
✓ #orders-status text~"Showing 1–6 of 12 orders"
✓ step 7: Go to page 2
✓ #orders-status text~"Showing 7–12 of 12 orders"
✓ .pager-btn[data-page="2"] aria-current=page
✓ .pager-btn[data-page="next"] aria-disabled=true
✓ .pager-btn[data-page="prev"] aria-disabled=false
✓ #orders-body tr count=6
✓ step 8: Next does nothing on the last page
✓ #orders-status text~"Showing 7–12 of 12 orders"
✓ step 9: Collapse the sidebar to icons
✓ html data-sidebar=collapsed
✓ #sidebar-toggle aria-expanded=false
✓ #sidebar-toggle aria-label=Expand sidebar
✓ step 10: Open the account menu from the keyboard
✓ #user-menu visible
✓ #user-button aria-expanded=true
✓ #user-menu > [role=menuitem]:first-child focused
✓ step 11: ArrowDown reaches the theme switch
✓ #theme-item focused
✓ step 12: Switch to the light theme
✓ html data-theme=light
✓ #theme-item aria-checked=true
✓ #user-menu visible
✓ step 13: Escape closes the menu and returns focus
✓ #user-menu hidden
✓ #user-button aria-expanded=false
✓ #user-button focused
✓ step 14: Export CSV confirms the file
✓ #toast text~"keel-overview-last-90-days.csv"90-day range selected
index.html · 1280×800 · click label.seg:nth-of-type(3)
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Desktop 1280 after clicking "90 days". The segment is raised and bold, and the meta reads "Harbor & Pine · Last 90 days · Jun 25 – Sep 22, 2026". KPIs: 143,800 ↑ +25.9%, 2.56% ↓ −2.0% in red, $246,162 ↑ +23.6%, $66.76 ↑ +0.2%, all "vs previous 90 days". The 90-day sparklines are smoothed (7-day window), so each shows a trend rather than noise; the visitors line climbs steadily. Chart: "Jun 25 – Sep 22 against Mar 27 – Jun 24", fortnightly x labels Jun 25 … Sep 17 with no collisions, and the dashed previous line shows its weekly rhythm under the solid one. Channels: Organic 62,840 43.7% … Email 10,785 7.5%, Total visitors 143,800 100%. $246,162 still fits its card at 28px. Every range-dependent element changed together; nothing else moved.
chart tooltip on hover (Sep 9)
index.html · 1280×800 · element .overview-row · hover .c-pt[data-i="16"]
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Chart and channels close-up at 1280, pointer hovering the Sep 9 point. The tooltip "Wed, Sep 9", "This period 2,106", "Aug 10 1,569" sits to the right of the point, top-aligned near the 2.5k gridline, with no keyboard hint because it was opened by pointer. The crosshair runs through Sep 9 from the top gridline to the axis, and the hovered point is a hollow teal ring on the peak. The Sep 22 end dot stays filled. The tooltip covers the line from ~Sep 10 to Sep 14 but not the point being read. Weekday and comparison date are right: Sep 22 is a Tuesday, so Sep 9 is a Wednesday, and day 17 of the Jul 25 period is Aug 10.
orders sorted by amount, largest first
index.html · 1280×800 · element .orders-card · click th.num .sort · click th.num .sort
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Orders card at 1280 after clicking Amount twice. Amount is the active column: white label, a single down chevron to its left, while Date is back to the faint two-way glyph. Rows are largest first: $318.90 Priya Natarajan Paid, $265.00 Ethan Brooks Refunded, $212.40, $184.00, $143.60, $129.99. The status line reads "Showing 1–6 of 12 orders", with page 1 current and Previous disabled. Column left edges here: Customer x≈177, Date x≈364, Channel x≈537, Status x≈676 in this 976px crop.
orders page 2
index.html · 1280×800 · element .orders-card · click .pager-btn[data-page="2"]
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Orders card at 1280 on page 2 (default date sort). "Showing 7–12 of 12 orders". Page 2 has the teal current state, Next is disabled (grey, no fill), and Previous is enabled. The rows are Sep 20 21:40 Sofia Marino … Sep 18 20:19 Ethan Brooks, badges Paid/Paid/Pending/Paid/Paid/Refunded. DEFECT: against S16 and the page-1 table in S6, the columns have moved. Customer starts at x≈175 (177 in S16), Date at ≈370 (364), Channel at ≈542 (537), Status at ≈679 (676). The auto table layout re-fits each page's six rows, so the header and every column shift a few pixels whenever you page or sort.
table.ordersColumns shift whenever the table is paged or sorted (S16 vs S17 vs S6: Date column x≈364/370, Channel ≈537/542). The auto layout re-fits the widths to each page's six rows, so the header row visibly wobbles on every page change.
account menu open
index.html · 1280×800 · click #user-button
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Desktop 1280 with the account menu open (clicked). The MR avatar gets a teal ring with a gap. The 248px panel drops 8px below it, aligned to its right edge, over the AOV card, with a strong border and shadow. It holds a header ("Maya Rahman", "maya@harborpine.example" in muted 12px) over a hairline, "Account settings" highlighted as the focused first item, "Light theme" with an off switch at the right, a separator, and "Sign out". The items are 36px tall, text is left-aligned on one inset, and nothing is clipped at the viewport edge.
tablet, collapsed rail, hover tooltip on Conversions
index.html · 768×1024 · hover .nav-link[data-label="Conversions"]
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Tablet 768 with the pointer over the Conversions icon in the collapsed rail. The item gets its hover fill, and a small dark "Conversions" tooltip appears 16px to the right, vertically centred on the icon. It floats over the Visitors card's label, as a transient tooltip should, and the label is readable again as soon as it goes. The other rail icons, the current-page tint on Overview, and the Settings / expand controls at the foot are unchanged. The top bar and KPIs are as in S7.
tablet, sidebar expanded with the toggle
index.html · 768×1024 · click #sidebar-toggle · wait 400
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Tablet 768 after pressing the rail's expand toggle. The 240px sidebar returns with labels (Overview current, Traffic, Conversions, Customers, Settings) and the foot button now reads "Collapse". The workspace is 528px wide and everything reflows instead of overflowing. The two-row top bar still fits, and the KPI cards are narrower, so they switch to value-over-sparkline with 28px values and one-line deltas. The chart legend wraps under the subtitle, and the chart re-renders at the new width with crisp labels. There is no horizontal scroll and nothing is clipped.
toast after Export CSV
index.html · 1280×800 · click #export · wait 300
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Desktop 1280 just after Export CSV. A toast in the tip style (dark fill, strong border, shadow) sits 24px from the bottom-right corner: "Exported keel-overview-last-30-days.csv (30 days)." It overlaps the top-right of the Recent orders card, is transient (role=status, hides after 4s), and blocks no control.
desktop, sidebar collapsed with the toggle
index.html · 1280×800 · click #sidebar-toggle · wait 400
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Desktop 1280 after pressing "Collapse". The sidebar is the 72px rail: the mark, 4 icons with Overview tinted and its left bar, Settings and the expand toggle at the foot. The KPI cards grow to ~272px, wide enough for value and sparkline side by side with the delta row under them, so all four cards change layout together. The chart re-renders wider (Aug 24 … Sep 21 spaced out, no scaling), and the channels card keeps its rows and the Total visitors line. The table header shows all six columns across the wider card.
lockColumns(): renders all 12 orders once, measures each column's natural width (table width:auto), pins those px widths on the header cells, then renders the current page. With every column specified, spare width is shared in proportion to fixed numbers, so no page or sort changes a column. It re-locks when the scroller resizes (padding regime, sidebar toggle).
orders page 1, default sort (columns locked)
index.html · 1280×800 · element .orders-card
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Orders card at 1280, page 1, default date sort, after F16. Column left edges in this 976px crop: Order x≈25, Customer ≈176, Date ≈370 (active, white, down chevron), Channel ≈542, Status ≈679, and Amount right-aligned to ≈951. Rows run #KA-20417 Lena Okafor … #KA-20412 Marcus Bell, with no truncation and even 48px rows. Spare width sits mostly in the Customer and Date columns, which read comfortably. The pager is unchanged.
orders sorted by amount (columns locked)
index.html · 1280×800 · element .orders-card · click th.num .sort · click th.num .sort
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Same card after sorting by Amount twice (largest first). Amount is active with a down chevron, and the rows run $318.90 → $129.99. The column edges are exactly where they were in S23: Customer ≈176, Date ≈370, Channel ≈542, Status ≈679, Amount ≈951. The header did not move when the sort changed.
orders page 2 (columns locked)
index.html · 1280×800 · element .orders-card · click .pager-btn[data-page="2"]
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Same card on page 2 (date sort): "Showing 7–12 of 12 orders", 2 current, Next disabled. The column edges again match S23 and S24 (Customer ≈176, Date ≈370, Channel ≈542, Status ≈679, Amount ≈951), even though this page's widest customer ("Hannah Schmidt") and badges differ from page 1's. Paging and sorting no longer shift anything.
Column edges identical across page 1 (L23), amount sort (L24) and page 2 (L25).
light theme, full page
index.html?theme=light · 1280×800 · full page, 2 screen(s)
Desktop 1280, ?theme=light, full page, current build. Screen 1: #f3f5f8 ground, white cards with #e1e6ec hairlines, white sidebar with Overview in a pale-teal pill, and a pale-teal avatar with dark-teal "MR". The sailboat mark is teal with white sails. KPI values are near-black at 28px, with green "↑ +13.0%", "↑ +6.2%", "↑ +17.0%" and red "↓ −2.5%". The chart's teal-600 line runs over a pale area, and the grey dashed previous line stays visible where it crosses the area. The channel bars are teal on a #edf0f4 track, and the Total visitors row is aligned. DEFECT (eye): in the top bar the selected "30 days" segment is a pale grey chip (#edf0f4) on a white track, inverted from the dark theme's raised chip. At a glance the selection reads from the bold weight alone, and the three segments look like one flat strip. Screen 2: the table header is #f6f8fa. Badges are tinted pills with dark text: Paid green-800 on green-100, Pending amber-800, Refunded slate, Failed red-800. The pager's current page is teal-bordered, and the fictional-data footnote sits at the bottom. No shadow smear at the top edge.
.segmented (light)Light theme: the selected date range is a pale grey chip on a white track, the inverse of the dark theme's raised chip. The three segments read as one flat strip and the selection rests on bold weight alone (also on phones, L27).
light theme, full page
index.html?theme=light · 375×812 · full page, 3 screen(s)
Phone 375, ?theme=light, full page. Screen 1: the compact mark and "Overview", the meta on two clean lines, and the same weak grey selected segment as on desktop ("30 days" barely separates from its neighbours). The KPI 2x2 cards are white with 24px values, and the green/red deltas carry arrows and signs. Screen 2: channel bars and the total row are fine. The table header row is grey, the pinned ID column has its hairline, and the right edge carries a grey fade, still readable as "more columns". Screen 3: the pager and footnote are fine. The bottom bar is white with a top hairline, and Overview is teal with its 3px indicator. Nothing overlaps the bar.
Segmented control colours are now tokens. Dark is unchanged: surface track, surface-3 chip with an inset hairline. Light is a #edf0f4 track with a white chip, a 1px #c9d2dc ring and a soft drop shadow, so the selected range looks raised in both themes.
light theme segmented control after F17
index.html?theme=light · 1280×800 · element .topbar-actions · @2x
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up of the top-bar actions, light theme, after F17. The date-range track is now pale grey (#edf0f4) with a hairline, and "30 days" sits in a white chip with a crisp 1px ring and a soft shadow, so it reads as raised and selected before you read the bold weight. "7 days" and "90 days" are mid-grey on the track. Export CSV beside it is a white button with the same 8px radius family and the same height, and the two controls share a baseline and a top edge.
dark theme segmented control after F17 (unchanged?)
index.html · 1280×800 · element .topbar-actions · @2x
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up of the same controls in the default dark theme, to confirm F17 left it alone. The track is the card surface (#131b26) with a hairline, and "30 days" sits in a lighter #1f2b3a chip with an inset ring and bold white text. "7 days" and "90 days" are muted. The selection is as clear as before, and Export CSV matches in height and radius.
Light: white raised chip on a grey track; dark unchanged (L29).
vlmkit check drift component index.html --selector .badge --json --output-dir /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2selector .badge · instances 6 · drifting 3
output, 816 line(s), 2.0s
{
"gate": "check.drift.component",
"command": "check drift component",
"verdict": "fail",
"counts": {
"suspect": 3,
"warn": 0,
"info": 0
},
"findings": [
{
"rule": "instance-drift",
"severity": "suspect",
"message": "instance #1 is styled differently from reference #0 — 7 computed properties differ: border-top-color rgb(134, 239, 172) → rgb(252, 211, 77); border-right-color rgb(134, 239, 172) → rgb(252, 211, 77); border-bottom-color rgb(134, 239, 172) → rgb(252, 211, 77); border-left-color rgb(134, 239, 172) → rgb(252, 211, 77); and 3 more (39.68% of pixels, size delta 22x0px)",
"selector": ".badge",
"evidence": {
"diffRatio": 0.3968421052631579,
"bboxDeltas": {
"width": 22,
"height": 0
},
"styleDeltas": [
{
"property": "border-top-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "border-right-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "border-bottom-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "border-left-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "background-color",
"reference": "rgba(74, 222, 128, 0.13)",
"candidate": "rgba(251, 191, 36, 0.13)"
},
{
"property": "color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "outline-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
}
]
}
},
{
"rule": "instance-drift",
"severity": "suspect",
"message": "instance #3 is styled differently from reference #0 — 7 computed properties differ: border-top-color rgb(134, 239, 172) → rgb(203, 213, 225); border-right-color rgb(134, 239, 172) → rgb(203, 213, 225); border-bottom-color rgb(134, 239, 172) → rgb(203, 213, 225); border-left-color rgb(134, 239, 172) → rgb(203, 213, 225); and 3 more (45.43% of pixels, size delta 30x0px)",
"selector": ".badge",
"evidence": {
"diffRatio": 0.4542857142857143,
"bboxDeltas": {
"width": 30,
"height": 0
},
"styleDeltas": [
{
"property": "border-top-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "border-right-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "border-bottom-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "border-left-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "background-color",
"reference": "rgba(74, 222, 128, 0.13)",
"candidate": "rgba(148, 163, 184, 0.15)"
},
{
"property": "color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "outline-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
}
]
}
},
{
"rule": "instance-drift",
"severity": "suspect",
"message": "instance #5 is styled differently from reference #0 — 7 computed properties differ: border-top-color rgb(134, 239, 172) → rgb(252, 165, 165); border-right-color rgb(134, 239, 172) → rgb(252, 165, 165); border-bottom-color rgb(134, 239, 172) → rgb(252, 165, 165); border-left-color rgb(134, 239, 172) → rgb(252, 165, 165); and 3 more (26.79% of pixels, size delta 9x0px)",
"selector": ".badge",
"evidence": {
"diffRatio": 0.2679365079365079,
"bboxDeltas": {
"width": 9,
"height": 0
},
"styleDeltas": [
{
"property": "border-top-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "border-right-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "border-bottom-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "border-left-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "background-color",
"reference": "rgba(74, 222, 128, 0.13)",
"candidate": "rgba(248, 113, 113, 0.15)"
},
{
"property": "color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "outline-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
}
]
}
}
],
"suppressed": [],
"retuned": [],
"report": {
"html": "examples/sites/dashboard/index.html",
"selector": ".badge",
"instanceCount": 6,
"referenceIndex": 0,
"instances": [
{
"index": 0,
"screenshotPath": "/tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2/instance-0.png",
"bbox": {
"x": 951.171875,
"y": 847.5,
"width": 53.34375,
"height": 24
},
"style": {
"display": "inline-flex",
"visibility": "visible",
"opacity": "1",
"max-width": "none",
"max-height": "none",
"min-width": "0px",
"min-height": "0px",
"margin-top": "0px",
"margin-right": "0px",
"margin-bottom": "0px",
"margin-left": "0px",
"padding-top": "0px",
"padding-right": "8px",
"padding-bottom": "0px",
"padding-left": "8px",
"border-top-width": "0px",
"border-right-width": "0px",
"border-bottom-width": "0px",
"border-left-width": "0px",
"border-top-color": "rgb(134, 239, 172)",
"border-right-color": "rgb(134, 239, 172)",
"border-bottom-color": "rgb(134, 239, 172)",
"border-left-color": "rgb(134, 239, 172)",
"border-top-style": "none",
"border-right-style": "none",
"border-bottom-style": "none",
"border-left-style": "none",
"border-radius": "12px",
"background-color": "rgba(74, 222, 128, 0.13)",
"background-image": "none",
"color": "rgb(134, 239, 172)",
"font-size": "12px",
"font-weight": "600",
"font-family": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Liberation Sans\", sans-serif",
"font-style": "normal",
"text-decoration": "none",
"text-align": "left",
"text-transform": "none",
"line-height": "16px",
"letter-spacing": "normal",
"word-spacing": "0px",
"white-space": "nowrap",
"flex-direction": "row",
"flex-wrap": "nowrap",
"flex-grow": "0",
"flex-shrink": "1",
"align-items": "center",
"justify-content": "normal",
"gap": "6px",
"position": "static",
"top": "auto",
"right": "auto",
"bottom": "auto",
"left": "auto",
"overflow": "visible",
"overflow-x": "visible",
"overflow-y": "visible",
"box-shadow": "none",
"cursor": "auto",
"aspect-ratio": "auto",
"outline-width": "0px",
"outline-style": "none",
"outline-color": "rgb(134, 239, 172)",
"outline-offset": "0px"
},
"classList": "span.badge.badge--paid"
},
{
"index": 1,
"screenshotPath": "/tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2/instance-1.png",
"bbox": {
"x": 951.171875,
"y": 895.5,
"width": 75.34375,
"height": 24
},
"style": {
"display": "inline-flex",
"visibility": "visible",
"opacity": "1",
"max-width": "none",
"max-height": "none",
"min-width": "0px",
"min-height": "0px",
"margin-top": "0px",
"margin-right": "0px",
"margin-bottom": "0px",
"margin-left": "0px",
"padding-top": "0px",
"padding-right": "8px",
"padding-bottom": "0px",
"padding-left": "8px",
"border-top-width": "0px",
"border-right-width": "0px",
"border-bottom-width": "0px",
"border-left-width": "0px",
"border-top-color": "rgb(252, 211, 77)",
"border-right-color": "rgb(252, 211, 77)",
"border-bottom-color": "rgb(252, 211, 77)",
"border-left-color": "rgb(252, 211, 77)",
"border-top-style": "none",
"border-right-style": "none",
"border-bottom-style": "none",
"border-left-style": "none",
"border-radius": "12px",
"background-color": "rgba(251, 191, 36, 0.13)",
"background-image": "none",
"color": "rgb(252, 211, 77)",
"font-size": "12px",
"font-weight": "600",
"font-family": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Liberation Sans\", sans-serif",
"font-style": "normal",
"text-decoration": "none",
"text-align": "left",
"text-transform": "none",
"line-height": "16px",
"letter-spacing": "normal",
"word-spacing": "0px",
"white-space": "nowrap",
"flex-direction": "row",
"flex-wrap": "nowrap",
"flex-grow": "0",
"flex-shrink": "1",
"align-items": "center",
"justify-content": "normal",
"gap": "6px",
"position": "static",
"top": "auto",
"right": "auto",
"bottom": "auto",
"left": "auto",
"overflow": "visible",
"overflow-x": "visible",
"overflow-y": "visible",
"box-shadow": "none",
"cursor": "auto",
"aspect-ratio": "auto",
"outline-width": "0px",
"outline-style": "none",
"outline-color": "rgb(252, 211, 77)",
"outline-offset": "0px"
},
"classList": "span.badge.badge--pending"
},
{
"index": 2,
"screenshotPath": "/tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2/instance-2.png",
"bbox": {
"x": 951.171875,
"y": 923.5,
"width": 53.34375,
"height": 24
},
"style": {
"display": "inline-flex",
"visibility": "visible",
"opacity": "1",
"max-width": "none",
"max-height": "none",
"min-width": "0px",
"min-height": "0px",
"margin-top": "0px",
"margin-right": "0px",
"margin-bottom": "0px",
"margin-left": "0px",
"padding-top": "0px",
"padding-right": "8px",
"padding-bottom": "0px",
"padding-left": "8px",
"border-top-width": "0px",
"border-right-width": "0px",
"border-bottom-width": "0px",
"border-left-width": "0px",
"border-top-color": "rgb(134, 239, 172)",
"border-right-color": "rgb(134, 239, 172)",
"border-bottom-color": "rgb(134, 239, 172)",
"border-left-color": "rgb(134, 239, 172)",
"border-top-style": "none",
"border-right-style": "none",
"border-bottom-style": "none",
"border-left-style": "none",
"border-radius": "12px",
"background-color": "rgba(74, 222, 128, 0.13)",
"background-image": "none",
"color": "rgb(134, 239, 172)",
"font-size": "12px",
"font-weight": "600",
"font-family": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Liberation Sans\", sans-serif",
"font-style": "normal",
"text-decoration": "none",
"text-align": "left",
"text-transform": "none",
"line-height": "16px",
"letter-spacing": "normal",
"word-spacing": "0px",
"white-space": "nowrap",
"flex-direction": "row",
"flex-wrap": "nowrap",
"flex-grow": "0",
"flex-shrink": "1",
"align-items": "center",
"justify-content": "normal",
"gap": "6px",
"position": "static",
"top": "auto",
"right": "auto",
"bottom": "auto",
"left": "auto",
"overflow": "visible",
"overflow-x": "visible",
"overflow-y": "visible",
"box-shadow": "none",
"cursor": "auto",
"aspect-ratio": "auto",
"outline-width": "0px",
"outline-style": "none",
"outline-color": "rgb(134, 239, 172)",
"outline-offset": "0px"
},
"classList": "span.badge.badge--paid"
},
{
"index": 3,
"screenshotPath": "/tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2/instance-3.png",
"bbox": {
"x": 951.171875,
"y": 610.5,
"width": 83.34375,
"height": 24
},
"style": {
"display": "inline-flex",
"visibility": "visible",
"opacity": "1",
"max-width": "none",
"max-height": "none",
"min-width": "0px",
"min-height": "0px",
"margin-top": "0px",
"margin-right": "0px",
"margin-bottom": "0px",
"margin-left": "0px",
"padding-top": "0px",
"padding-right": "8px",
"padding-bottom": "0px",
"padding-left": "8px",
"border-top-width": "0px",
"border-right-width": "0px",
"border-bottom-width": "0px",
"border-left-width": "0px",
"border-top-color": "rgb(203, 213, 225)",
"border-right-color": "rgb(203, 213, 225)",
"border-bottom-color": "rgb(203, 213, 225)",
"border-left-color": "rgb(203, 213, 225)",
"border-top-style": "none",
"border-right-style": "none",
"border-bottom-style": "none",
"border-left-style": "none",
"border-radius": "12px",
"background-color": "rgba(148, 163, 184, 0.15)",
"background-image": "none",
"color": "rgb(203, 213, 225)",
"font-size": "12px",
"font-weight": "600",
"font-family": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Liberation Sans\", sans-serif",
"font-style": "normal",
"text-decoration": "none",
"text-align": "left",
"text-transform": "none",
"line-height": "16px",
"letter-spacing": "normal",
"word-spacing": "0px",
"white-space": "nowrap",
"flex-direction": "row",
"flex-wrap": "nowrap",
"flex-grow": "0",
"flex-shrink": "1",
"align-items": "center",
"justify-content": "normal",
"gap": "6px",
"position": "static",
"top": "auto",
"right": "auto",
"bottom": "auto",
"left": "auto",
"overflow": "visible",
"overflow-x": "visible",
"overflow-y": "visible",
"box-shadow": "none",
"cursor": "auto",
"aspect-ratio": "auto",
"outline-width": "0px",
"outline-style": "none",
"outline-color": "rgb(203, 213, 225)",
"outline-offset": "0px"
},
"classList": "span.badge.badge--refunded"
},
{
"index": 4,
"screenshotPath": "/tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2/instance-4.png",
"bbox": {
"x": 951.171875,
"y": 658.5,
"width": 53.34375,
"height": 24
},
"style": {
"display": "inline-flex",
"visibility": "visible",
"opacity": "1",
"max-width": "none",
"max-height": "none",
"min-width": "0px",
"min-height": "0px",
"margin-top": "0px",
"margin-right": "0px",
"margin-bottom": "0px",
"margin-left": "0px",
"padding-top": "0px",
"padding-right": "8px",
"padding-bottom": "0px",
"padding-left": "8px",
"border-top-width": "0px",
"border-right-width": "0px",
"border-bottom-width": "0px",
"border-left-width": "0px",
"border-top-color": "rgb(134, 239, 172)",
"border-right-color": "rgb(134, 239, 172)",
"border-bottom-color": "rgb(134, 239, 172)",
"border-left-color": "rgb(134, 239, 172)",
"border-top-style": "none",
"border-right-style": "none",
"border-bottom-style": "none",
"border-left-style": "none",
"border-radius": "12px",
"background-color": "rgba(74, 222, 128, 0.13)",
"background-image": "none",
"color": "rgb(134, 239, 172)",
"font-size": "12px",
"font-weight": "600",
"font-family": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Liberation Sans\", sans-serif",
"font-style": "normal",
"text-decoration": "none",
"text-align": "left",
"text-transform": "none",
"line-height": "16px",
"letter-spacing": "normal",
"word-spacing": "0px",
"white-space": "nowrap",
"flex-direction": "row",
"flex-wrap": "nowrap",
"flex-grow": "0",
"flex-shrink": "1",
"align-items": "center",
"justify-content": "normal",
"gap": "6px",
"position": "static",
"top": "auto",
"right": "auto",
"bottom": "auto",
"left": "auto",
"overflow": "visible",
"overflow-x": "visible",
"overflow-y": "visible",
"box-shadow": "none",
"cursor": "auto",
"aspect-ratio": "auto",
"outline-width": "0px",
"outline-style": "none",
"outline-color": "rgb(134, 239, 172)",
"outline-offset": "0px"
},
"classList": "span.badge.badge--paid"
},
{
"index": 5,
"screenshotPath": "/tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2/instance-5.png",
"bbox": {
"x": 951.171875,
"y": 706.5,
"width": 62.6875,
"height": 24
},
"style": {
"display": "inline-flex",
"visibility": "visible",
"opacity": "1",
"max-width": "none",
"max-height": "none",
"min-width": "0px",
"min-height": "0px",
"margin-top": "0px",
"margin-right": "0px",
"margin-bottom": "0px",
"margin-left": "0px",
"padding-top": "0px",
"padding-right": "8px",
"padding-bottom": "0px",
"padding-left": "8px",
"border-top-width": "0px",
"border-right-width": "0px",
"border-bottom-width": "0px",
"border-left-width": "0px",
"border-top-color": "rgb(252, 165, 165)",
"border-right-color": "rgb(252, 165, 165)",
"border-bottom-color": "rgb(252, 165, 165)",
"border-left-color": "rgb(252, 165, 165)",
"border-top-style": "none",
"border-right-style": "none",
"border-bottom-style": "none",
"border-left-style": "none",
"border-radius": "12px",
"background-color": "rgba(248, 113, 113, 0.15)",
"background-image": "none",
"color": "rgb(252, 165, 165)",
"font-size": "12px",
"font-weight": "600",
"font-family": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Liberation Sans\", sans-serif",
"font-style": "normal",
"text-decoration": "none",
"text-align": "left",
"text-transform": "none",
"line-height": "16px",
"letter-spacing": "normal",
"word-spacing": "0px",
"white-space": "nowrap",
"flex-direction": "row",
"flex-wrap": "nowrap",
"flex-grow": "0",
"flex-shrink": "1",
"align-items": "center",
"justify-content": "normal",
"gap": "6px",
"position": "static",
"top": "auto",
"right": "auto",
"bottom": "auto",
"left": "auto",
"overflow": "visible",
"overflow-x": "visible",
"overflow-y": "visible",
"box-shadow": "none",
"cursor": "auto",
"aspect-ratio": "auto",
"outline-width": "0px",
"outline-style": "none",
"outline-color": "rgb(252, 165, 165)",
"outline-offset": "0px"
},
"classList": "span.badge.badge--failed"
}
],
"deltas": [
{
"candidateIndex": 1,
"diffRatio": 0.3968421052631579,
"diffPixels": 754,
"totalPixels": 1900,
"bboxDeltas": {
"width": 22,
"height": 0
},
"paletteOnlyInRef": 10,
"paletteOnlyInCand": 11,
"styleDeltas": [
{
"property": "border-top-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "border-right-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "border-bottom-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "border-left-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "background-color",
"reference": "rgba(74, 222, 128, 0.13)",
"candidate": "rgba(251, 191, 36, 0.13)"
},
{
"property": "color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
},
{
"property": "outline-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 211, 77)"
}
],
"exemptedStyleDeltas": []
},
{
"candidateIndex": 2,
"diffRatio": 0,
"diffPixels": 0,
"totalPixels": 1350,
"bboxDeltas": {
"width": 0,
"height": 0
},
"paletteOnlyInRef": 0,
"paletteOnlyInCand": 0,
"styleDeltas": [],
"exemptedStyleDeltas": []
},
{
"candidateIndex": 3,
"diffRatio": 0.4542857142857143,
"diffPixels": 954,
"totalPixels": 2100,
"bboxDeltas": {
"width": 30,
"height": 0
},
"paletteOnlyInRef": 9,
"paletteOnlyInCand": 10,
"styleDeltas": [
{
"property": "border-top-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "border-right-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "border-bottom-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "border-left-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "background-color",
"reference": "rgba(74, 222, 128, 0.13)",
"candidate": "rgba(148, 163, 184, 0.15)"
},
{
"property": "color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
},
{
"property": "outline-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(203, 213, 225)"
}
],
"exemptedStyleDeltas": []
},
{
"candidateIndex": 4,
"diffRatio": 0,
"diffPixels": 0,
"totalPixels": 1350,
"bboxDeltas": {
"width": 0,
"height": 0
},
"paletteOnlyInRef": 0,
"paletteOnlyInCand": 0,
"styleDeltas": [],
"exemptedStyleDeltas": []
},
{
"candidateIndex": 5,
"diffRatio": 0.2679365079365079,
"diffPixels": 422,
"totalPixels": 1575,
"bboxDeltas": {
"width": 9,
"height": 0
},
"paletteOnlyInRef": 10,
"paletteOnlyInCand": 14,
"styleDeltas": [
{
"property": "border-top-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "border-right-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "border-bottom-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "border-left-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "background-color",
"reference": "rgba(74, 222, 128, 0.13)",
"candidate": "rgba(248, 113, 113, 0.15)"
},
{
"property": "color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
},
{
"property": "outline-color",
"reference": "rgb(134, 239, 172)",
"candidate": "rgb(252, 165, 165)"
}
],
"exemptedStyleDeltas": []
}
],
"reportPath": "/tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift2/report.md",
"allowRuleCount": 0
}
}vlmkit check drift component index.html --selector .badge --output-dir /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift3 --allow 'color@.badge--pending;semantic status colour: Pending is amber, the text says Pending' --allow '*-color@.badge--pending;border/outline/background colours follow the status colour' --allow 'color@.badge--refunded;semantic status colour: Refunded is neutral slate, the text says Refunded' --allow '*-color@.badge--refunded;border/outline/background colours follow the status colour' --allow 'color@.badge--failed;semantic status colour: Failed is red, the text says Failed' --allow '*-color@.badge--failed;border/outline/background colours follow the status colour'selector .badge · instances 6 · drifting 3
output, 28 line(s), 2.1s
vlmkit check drift component
html: examples/sites/dashboard/index.html selector: .badge
6 instance(s), reference = #0
verdict is set by tracked computed style; the pixel % is context, not the pass line
~ instance #1 all 7 difference(s) exempted 39.68% px Δ +22 / 0
exempted border-top-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
exempted border-right-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
exempted border-bottom-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
exempted border-left-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
and 3 more exempted
every property on the instance root matches, but 21 colour(s) appear in one instance and not the other — a styling difference on a descendant or in an untracked property, not necessarily content
✓ instance #2 identical 0.00% px Δ 0 / 0
~ instance #3 all 7 difference(s) exempted 45.43% px Δ +30 / 0
exempted border-top-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
exempted border-right-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
exempted border-bottom-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
exempted border-left-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
and 3 more exempted
every property on the instance root matches, but 19 colour(s) appear in one instance and not the other — a styling difference on a descendant or in an untracked property, not necessarily content
✓ instance #4 identical 0.00% px Δ 0 / 0
~ instance #5 all 7 difference(s) exempted 26.79% px Δ +9 / 0
exempted border-top-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
exempted border-right-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
exempted border-bottom-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
exempted border-left-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
and 3 more exempted
every property on the instance root matches, but 24 colour(s) appear in one instance and not the other — a styling difference on a descendant or in an untracked property, not necessarily content
report: /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift3/report.mdFirst badge run, without declarations: the Pending/Refunded/Failed badges 'drift' from Paid only in color, background-color and five currentColor-derived properties (4 border colours, outline-color); height delta 0, width delta = label length. That is the component's semantic colour variants. G31 re-runs with those 7 properties declared per variant (--allow color@/.*-color@.badge--pending|refunded|failed) and exits 0, verifying the brief's 'badges are one component'.
Two of the four inert-control warns are correct no-ops, not dead controls. 'Previous' is aria-disabled=true on page 1 (disabled rather than removed, so keyboard focus is never dropped), and 'Page 1' is the current page. Pressing either should do nothing. G29 (verify flow step 8) shows the same rule for Next on the last page: a forced click leaves 'Showing 7–12 of 12 orders' unchanged. The other two ('Page 2', 'Next') were a real silence, fixed as D12.
check interactions contradicts itself on the pager. The inventory line reads '[button] "Page 2" focus✓ | Enter: no response | announces' (same for Next), and the issue list still says 'warn [inert-control] button "Page 2" … shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe.' The announcement it recorded is not counted as a response. Nor is aria-current moving onto the pressed page button, or the six rows' text changing. verify flow (G29 step 7) asserts all three. Previous / Page 1 in this run are the correct no-ops explained in the G15 note.
skip-row: focus jumps 341px from the Amount sort header to the pager's Previous button because the six table rows hold only text and status badges, with no links or controls (S23 shows every cell). Nothing focusable is skipped; making rows focusable just to shorten the jump would add six empty Tab stops. The gate asks to 'confirm no focusable element was unintentionally skipped' — confirmed.
The dead scrollport at 1280 is deliberate. #orders-scroll keeps overflow-x:auto at every width so the table can never push its card wider: on phones it scrolls (G13, 379px), and on a tablet with the sidebar opened the workspace is 528px (S20). Since D11 it is a focusable, labelled region only while it actually scrolls, so at 1280 it is neither a Tab stop nor a landmark. The two clipped-content rows are the sr-only heading and legend, as explained in the G12 note.
The relative path with ?theme=dark was read as a filename (tool note N2). Re-run as a file:// URL in G8 (dark, 119 elements, 0 contrast failures); the final round runs both themes the same way.
A --json copy of the badge drift run, used only to name the seventh differing property (outline-color, derived from currentColor like the four border colours). Its findings are the undeclared variants of G21; G31 declares them and exits 0.
vlmkit scan handlers index.html --probe touch,hoverregistrations 22 · elements 13 · suspects 0
output, 24 line(s), 1.7s
vlmkit scan handlers source: index.html status: 1 warn(s) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule unprobed-handler-types=suspect registrations: 22 across 13 element(s) + globals, on a page presenting 23 control(s) - div.main-inner>div.overview-row>section.card>div#chart "05001k1.5k2k2.5kAug 24Aug 31Sep 7Sep 14S": pointermove, pointerdown, pointerleave, focusin, focusout, keydown (no role) - div.workspace>header.topbar>div.topbar-actions>fieldset#range "Date range 7 days 30 days 90 days": change (delegation container) - div#orders-frame>div#orders-scroll>table.orders>thead "Order Customer Date Channel Status Amoun": click (delegation container) - div.main-inner>section.card>div.table-foot>nav.pager "Previous 1 2 Next": click (delegation container) - div.main-inner>section.card>div#orders-frame>div#orders-scroll "Order Customer Date Channel Status Amoun": scroll (delegation container) - div.app>nav#sidebar>div.sidebar-foot>button#sidebar-toggle "Collapse": click - div.workspace>header.topbar>div.user>button#user-button "MR": click, keydown - header.topbar>div.user>div#user-menu-panel>div#user-menu "Account settings Light theme Sign out": keydown, click (no role) - nav#sidebar>ul.nav-list>li>a.nav-link "Traffic": click - nav#sidebar>ul.nav-list>li>a.nav-link "Conversions": click - nav#sidebar>ul.nav-list>li>a.nav-link "Customers": click - nav#sidebar>ul.nav-list>li.nav-item-end>a.nav-link "Settings": click - div.workspace>header.topbar>div.topbar-actions>button#export "Export CSV": click - globals: document:pointerdown Issues: warn [unprobed-handler-types] 9 handler type(s) registered and NONE exercised: change, click, focusin, focusout, keydown, pointerdown, pointerleave, pointermove, scroll — this gate is an inventory and presses nothing. A clean result here says the wiring exists, not that it works. Run 'check interactions --handlers' to focus and activate the controls, add '--probe-drag' for the drag surfaces, or drive the rest with a 'verify flow' script.
scan handlers --probe touch,hover printed no touch or hover section at all, and its only warn is still '9 handler type(s) registered and NONE exercised: change, click, focusin, focusout, keydown, pointerdown, pointerleave, pointermove, scroll'. The chart's pointerdown/pointermove/pointerleave surface is exactly what a finger drives, but the touch family apparently taps only touch* listeners and says nothing when it finds none. A probe that silently does nothing reads as coverage. judge.mjs shot and verify flow have no tap action either (both click with a mouse).
The one change in this log that no look and no gate saw. By the Pointer Events model a touch pointer fires pointerleave right after pointerup, and the chart hid its tooltip on pointerleave, so on a phone a tap would flash the tooltip and drop it. No tool here can drive a touch tap (see the note on G32), so this was reasoned from the code, not observed. Change: pointerleave from a touch pointer is ignored, a pointerdown outside the chart hides the tooltip, and the svg gets touch-action:pan-y so a horizontal finger drag scrubs the days while vertical swipes still scroll the page. Mouse and keyboard behaviour is unchanged; the flow in the final round re-asserts it.
keyboard focus on the date range (after ArrowRight → 90 days)
index.html · 1280×800 · element .topbar-actions · @2x · focus input[name=range][value="30"] · press ArrowRight
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up of the top-bar actions after focusing the "30 days" radio and pressing ArrowRight. Selection moved with the arrow (native radio group): "90 days" is now the raised, bold chip, and a 2px teal focus ring with a 1px offset surrounds exactly that chip, not the whole group and not the invisible input. "7 days" and "30 days" are muted on the track. Export CSV is unfocused. The ring does not clip against the track border.
keyboard focus on the Amount sort button
index.html · 1280×800 · element .orders thead · @2x · focus th.num .sort
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
2x close-up of the orders header row with keyboard focus on the Amount sort button. A 2px teal ring with rounded corners surrounds "◇ Amount" at the right end and fits inside the 40px header without being cut. Date stays the active sort (white label, down chevron), and the other headers are muted with faint two-way glyphs. The focused column reads differently from the sorted one, so a keyboard user can tell "where I am" from "what is sorted".
keyboard focus on a sidebar link
index.html · 1280×800 · element .sidebar · focus .nav-link[data-label="Traffic"]
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Sidebar at 1280 with keyboard focus on "Traffic". A teal 2px ring runs round the full 40px row with the 8px radius, sitting just inside the sidebar's 12px gutter, so it is not clipped by the sidebar edge. Overview keeps its current-page tint and left bar, which makes focus and current page two different signals. Settings and Collapse at the foot are unaffected.
R4 · final check
buildervlmkit check integrity index.htmlverdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 17 exempted)
verdict clean · findings 1 · fails 0 · warns 1 · exempted 17 · viewports 3
output, 29 line(s), 4.4s
vlmkit check integrity source: index.html verdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 17 exempted) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule near-misalignment=suspect 1280x800: 8 component(s), ink 14.8%, 120 text block(s) 768x900: 8 component(s), ink 16.1%, 120 text block(s) 375x700: 8 component(s), ink 17.0%, 116 text block(s) Findings: ! [near-misalignment] div.page-title @1280: div.page-title is off its siblings' shared top edge by 6px inside header.topbar — siblings align exactly; a 2-8px deviation is almost always an accident (stray margin/padding), not a design choice. Exempted candidates (the tool's call — audit the rule, not the page): - [text-collision] legend.sr-only @1280: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] legend.sr-only @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] span.brand-name @768: clipped away by span.brand-name (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-collision] legend.sr-only @768: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] span.brand-name @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(1) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(2) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(3) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(4) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] li.nav-item-end > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] span.sidebar-toggle-label @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] legend.sr-only @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] legend.sr-only @375: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there … 2 more
vlmkit check copy index.html --manifest copy.txtmissing 0 · placeholders 0 · manifestLines 11 · statesExplored 1 · revealedOnly 0
output, 9 line(s), 1.4s
vlmkit check copy source: index.html status: ok rendered text: 1286 chars disclosure states: 1 explored (details / tabs / aria-expanded) manifest: 11 line(s), missing 0 No copy issues detected.
vlmkit check design index.html --allow 'svg#chart-svg;the chart'\''s point group shares the ARIA role group with the KPI cards but is a different component (a line chart, aria-roledescription), not a card'verdict: COHERENT (0 finding(s))
verdict coherent · drifting 0 · roles 6
output, 27 line(s), 1.6s
vlmkit check design
source: index.html
verdict: COHERENT (0 finding(s))
roles judged: 4 of 6 seen, spacing values: 11
coverage: 24 of 313 visible element(s) carried an inferable role; 3 skipped as non-resting
no role: span x54, div x40, path x39, td x30, svg x26, p x15, +19 more tag(s)
A role comes from role="..." or from button/input/select/textarea/h1-h6. Layout elements
(div, span, p, a) have none, so a large skip count is normal — this gate judges components,
not every box. Add role="..." where an element IS a component to widen the coverage.
text-free: 1 (0 judged on box alone — font-size/weight is not observable without painted text)
Role reuse (instances / distinct styles; drift below 3x from 3 instances)
button 10 inst 3 styles reuse 3.33x 1 one-off ok
group 4 inst 1 styles reuse 4x 0 one-off ok
h3 4 inst 1 styles reuse 4x 0 one-off ok
h2 3 inst 1 styles reuse 3x 0 one-off ok
h1 1 inst 1 styles reuse 1x 1 one-off not judged
img 0 inst 0 styles reuse 0x 0 one-off not judged
not judged: h1 (1), img (0) — under --min-instances 3, so no finding can come from them.
To keep judging them: --min-instances 2 --min-reuse 2 (both — a 2-instance role cannot reach 3x however many instances the floor allows).
allowed: 1 group instance(s) declared deliberate and left out of the reuse figure
allowed: 1 img instance(s) declared deliberate and left out of the reuse figure
No design drift detected.vlmkit check composition index.htmlverdict: COMPOSED (0 finding(s))
verdict composed · proximity 0 · flatSteps 0 · railNearMisses 0
output, 17 line(s), 1.9s
vlmkit check composition source: index.html verdict: COMPOSED (0 finding(s)) measured: 0 label(s), 3 heading level(s), 28 wide block(s) on 4 left / 5 right rail(s) — from 219 visible box(es) 11 label(s) not judged: they open a box that paints its own group edge, so nothing above them could be mis-grouped with Type hierarchy (largest instance of each declared level) h1 20px / 700 div.page-title>div.page-title-text>h1 h2 15px / 600 h2#chart-title h3 13px / 500 h3#kpi-visitors body 14px, largest 28px (2x), heaviest +300 weight Group gaps: 16px between heading-led groups vs 8px inside them (2x, 3 sample(s)). Context only — intact pages measure 0.86-3.00x, so this cannot carry a verdict. Proximity, alignment and type hierarchy all read consistently.
vlmkit check color index.htmlverdict: CONSISTENT (0 finding(s))
verdict consistent · base #0d131c · bodyInk #e6ecf3 · linkInk #8392a5 · invisibleControls 0 · colorOnlyLinks 0 · unreadableColors 0
output, 24 line(s), 1.7s
vlmkit check color
source: index.html
verdict: CONSISTENT (0 finding(s))
measured: 0 control(s), 6 link(s) in a text flow — from 251 visible box(es)
Palette (by painted area; reported, never judged — see the rejected candidates in the module docs)
surfaces 4 distinct
#0d131c 55.8% 2 el
#131b26 35.9% 18 el a.skip-link
#0a0f16 7.1% 1 el nav#sidebar
#18222f 1.3% 6 el thead>tr>th.col-id
ink 5 distinct
#e6ecf3 54.0% 46 el a.skip-link
#aab6c5 28.9% 27 el li>a.nav-link>span.nav-label
#8392a5 15.6% 23 el button#sidebar-toggle>span.sidebar-toggle-label
#86efac 0.9% 3 el tr>td>span.badge
#5eead4 0.6% 2 el li>a.nav-link>span.nav-label
marks 3 distinct
#223041 95.6% 84 el nav#sidebar
#2f3e52 3.8% 16 el button#export
#2dd4bf 0.7% 4 el div.table-foot>nav.pager>button.btn
base #0d131c, body ink #e6ecf3, link ink #8392a5 on 7 element(s)vlmkit check a11y contrast 'file://$PWD/examples/sites/dashboard/index.html?theme=dark'inspected 120 · failures 0
output, 5 line(s), 1.5s
vlmkit check a11y contrast html: file://examples/sites/dashboard/index.html?theme=dark inspected 120 text-bearing element(s) ✓ 0 contrast failure(s) report: examples/sites/dashboard/test-results/a11y-contrast/index-8f853be8/report.md
vlmkit check a11y contrast 'file://$PWD/examples/sites/dashboard/index.html?theme=light'inspected 120 · failures 0
output, 5 line(s), 1.5s
vlmkit check a11y contrast html: file://examples/sites/dashboard/index.html?theme=light inspected 120 text-bearing element(s) ✓ 0 contrast failure(s) report: examples/sites/dashboard/test-results/a11y-contrast/index-7bdde28d/report.md
vlmkit check a11y focus index.htmlsteps 21 · findings 1
output, 8 line(s), 1.6s
vlmkit check a11y focus
source: index.html
captured 21 focus step(s)
! 1 finding(s)
10 focusable element(s) are viewport-pinned (fixed / sticky); jumps into or out of them are not read as order defects
[skip-row] Focus jumped down by 341px (from `table.orders>thead>tr>th.num>button.sort` to `div.main-inner>section.card.orders-card>div.table-foot>nav.pager>button.btn.pager-btn`). Confirm no focusable element was unintentionally skipped.
report: examples/sites/dashboard/test-results/a11y-focus-order/index-d0cf3299/report.md
exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule skip-row=suspectvlmkit check a11y touch index.htmllevel AA · inspected 20 · failures 0 · wcagExempt 1
output, 6 line(s), 1.6s
vlmkit check a11y touch source: index.html level: WCAG AA (24×24 min) inspected 20 interactive element(s) ✓ 0 undersized target(s) 1 undersized target(s) excused by WCAG 2.5.8 itself (1 in a sentence) report: examples/sites/dashboard/test-results/a11y-touch/index-d0cf3299/report.md
vlmkit scan scroll index.htmlcontainers 0 · overflowX 0 · issues 2
output, 14 line(s), 1.4s
vlmkit scan scroll source: index.html (1280x720) status: warn exits 0 — 2 warn(s) did not fail this command. To gate on one: --rule clipped-content=suspect page: 1280x1281 — horizontal overflow 0px, vertical scroll 561px scroll containers: 0 (dead scrollports 1, clipped 2) Dead scrollports (declared scrollable, content fits): - #orders-scroll (overflow: auto auto) Issues: ! clipped-content #kpis-title: #kpis-title clips 118px of its content behind overflow: hidden — cut-off content unless the oversize is decorative. ! clipped-content legend.sr-only: legend.sr-only clips 68px of its content behind overflow: hidden — cut-off content unless the oversize is decorative.
vlmkit scan scroll index.html --viewport 375x740containers 1 · overflowX 0 · issues 2
output, 16 line(s), 1.5s
vlmkit scan scroll source: index.html (375x740) status: warn exits 0 — 2 warn(s) did not fail this command. To gate on one: --rule clipped-content=suspect page: 375x1993 — horizontal overflow 0px, vertical scroll 1253px scroll containers: 1 (dead scrollports 0, clipped 2) Scroll containers: - #orders-scroll: axis=x overflow=273px box=(17,1407) 341x329 Issues: ! clipped-content #kpis-title: #kpis-title clips 118px of its content behind overflow: hidden — cut-off content unless the oversize is decorative. ! clipped-content legend.sr-only: legend.sr-only clips 68px of its content behind overflow: hidden — cut-off content unless the oversize is decorative. Contract hint: --json emits 1 expectedScrollports entry ready for a UI Contract.
vlmkit scan handlers index.htmlregistrations 23 · elements 13 · suspects 0
output, 24 line(s), 1.8s
vlmkit scan handlers source: index.html status: 1 warn(s) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule unprobed-handler-types=suspect registrations: 23 across 13 element(s) + globals, on a page presenting 23 control(s) - div.main-inner>div.overview-row>section.card>div#chart "05001k1.5k2k2.5kAug 24Aug 31Sep 7Sep 14S": pointermove, pointerdown, pointerleave, focusin, focusout, keydown (no role) - div.workspace>header.topbar>div.topbar-actions>fieldset#range "Date range 7 days 30 days 90 days": change (delegation container) - div#orders-frame>div#orders-scroll>table.orders>thead "Order Customer Date Channel Status Amoun": click (delegation container) - div.main-inner>section.card>div.table-foot>nav.pager "Previous 1 2 Next": click (delegation container) - div.main-inner>section.card>div#orders-frame>div#orders-scroll "Order Customer Date Channel Status Amoun": scroll (delegation container) - div.app>nav#sidebar>div.sidebar-foot>button#sidebar-toggle "Collapse": click - div.workspace>header.topbar>div.user>button#user-button "MR": click, keydown - header.topbar>div.user>div#user-menu-panel>div#user-menu "Account settings Light theme Sign out": keydown, click (no role) - nav#sidebar>ul.nav-list>li>a.nav-link "Traffic": click - nav#sidebar>ul.nav-list>li>a.nav-link "Conversions": click - nav#sidebar>ul.nav-list>li>a.nav-link "Customers": click - nav#sidebar>ul.nav-list>li.nav-item-end>a.nav-link "Settings": click - div.workspace>header.topbar>div.topbar-actions>button#export "Export CSV": click - globals: document:pointerdown×2 Issues: warn [unprobed-handler-types] 9 handler type(s) registered and NONE exercised: change, click, focusin, focusout, keydown, pointerdown, pointerleave, pointermove, scroll — this gate is an inventory and presses nothing. A clean result here says the wiring exists, not that it works. Run 'check interactions --handlers' to focus and activate the controls, add '--probe-drag' for the drag surfaces, or drive the rest with a 'verify flow' script.
vlmkit check interactions index.html --handlerselements 22 · suspects 0 · warns 4
output, 65 line(s), 18.6s
vlmkit check interactions source: index.html status: ok exits 0 — 5 warn(s) did not fail this command. To gate on one: --rule inert-control=suspect (also: unprobed-handler-types) interactive elements: 22 - [link] "Skip to content" focus✓ - [link] "Overview" focus✓ - [link] "Traffic" focus✓ - [link] "Conversions" focus✓ - [link] "Customers" focus✓ - [link] "Settings" focus✓ - [button] "Collapse sidebar" focus✓ | Enter: expanded true -> false - [radio] "7" unreachable | ArrowDown: focus moves - [radio] "30" focus✓ | ArrowDown: layout change | announces - [radio] "90" unreachable | ArrowDown: layout change | announces - [button] "Export CSV" focus✓ | Enter: layout change | announces - [button] "Account menu, Maya Rahman" focus✓ | Enter: expanded false -> true | opens menu (focus enters), arrows cycle | Esc closes+returns focus - [button] "Order" focus✓ | Enter: layout change | announces - [button] "Customer" focus✓ | Enter: layout change | announces - [button] "Date" focus✓ | Enter: layout change | announces - [button] "Channel" focus✓ | Enter: layout change | announces - [button] "Status" focus✓ | Enter: layout change | announces - [button] "Amount" focus✓ | Enter: layout change | announces - [button] "Previous" focus✓ | Enter: no response - [button] "Page 1" focus✓ | Enter: no response - [button] "Page 2" focus✓ | Enter: no response | announces - [button] "Next" focus✓ | Enter: no response | announces Issues: warn [inert-control] button "Previous" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Page 1" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Page 2" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. warn [inert-control] button "Next" (section.card>div.table-foot>nav.pager>button.btn) shows no observable response to Enter — no ARIA change, no layout change. Dead control, or its response is outside this probe. vlmkit scan handlers source: index.html status: 1 warn(s) registrations: 23 across 13 element(s) + globals, on a page presenting 23 control(s) - div.main-inner>div.overview-row>section.card>div#chart "05001k1.5k2k2.5kAug 24Aug 31Sep 7Sep 14S": pointermove, pointerdown, pointerleave, focusin, focusout, keydown (no role) - div.workspace>header.topbar>div.topbar-actions>fieldset#range "Date range 7 days 30 days 90 days": change (delegation container) - div#orders-frame>div#orders-scroll>table.orders>thead "Order Customer Date Channel Status Amoun": click (delegation container) - div.main-inner>section.card>div.table-foot>nav.pager "Previous 1 2 Next": click (delegation container) - div.main-inner>section.card>div#orders-frame>div#orders-scroll "Order Customer Date Channel Status Amoun": scroll (delegation container) - div.app>nav#sidebar>div.sidebar-foot>button#sidebar-toggle "Collapse": click - div.workspace>header.topbar>div.user>button#user-button "MR": click, keydown - header.topbar>div.user>div#user-menu-panel>div#user-menu "Account settings Light theme Sign out": keydown, click (no role) - nav#sidebar>ul.nav-list>li>a.nav-link "Traffic": click - nav#sidebar>ul.nav-list>li>a.nav-link "Conversions": click - nav#sidebar>ul.nav-list>li>a.nav-link "Customers": click - nav#sidebar>ul.nav-list>li.nav-item-end>a.nav-link "Settings": click - div.workspace>header.topbar>div.topbar-actions>button#export "Export CSV": click - globals: document:pointerdown×2 Pointer-drag gesture (real mouse input): - div.main-inner>div.overview-row>section.card>div#chart: feedback while held 12.20%, changed after release 12.27% Wheel gesture (real wheel input): - div.main-inner>section.card>div#orders-frame>div#orders-scroll: a 200px wheel moved 400px Issues: warn [unprobed-handler-types] Handler types registered but NOT exercised by this run's probes: change, click, focusin, focusout, keydown, pointerleave — the probe focuses a control and presses the key its role activates with, so a `click` handler on anything but a native control, and any type outside focus/blur/keyboard, is untested here. Verify those paths with a 'verify flow' script.
vlmkit check breakpoints index.html --breakpoints 768,1280 --sweepbreakpoints 2 · issues 0 · sweepRanges 0
output, 11 line(s), 3.5s
vlmkit check breakpoints source: index.html status: ok width sweep: 320-1380px step 25 (43 widths) — clean breakpoints checked: 768, 1280px (each at B-1 / B / B+1) 768px: clean 1280px: clean All boundaries consistent.
vlmkit check scroll index.htmlpageScrolled 561 · stickyFixed 10 · engagedSticky 2 · snaps 0 · issues 0
output, 21 line(s), 1.5s
vlmkit check scroll source: index.html status: ok page scrolled: 561px sticky/fixed elements: 10 (sticky engaged by the scroll: 2) snap containers driven: 0 Sticky / fixed: - #sidebar: sticky top=0px viewport y 0 -> 0 - header.topbar: sticky top=0px viewport y 0 -> 0 - table.orders > thead:nth-of-type(1) > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 796 -> 235 - #orders-body > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 836 -> 275 - #orders-body > tr:nth-of-type(2) > th:nth-of-type(1): sticky viewport y 884 -> 323 - #orders-body > tr:nth-of-type(3) > th:nth-of-type(1): sticky viewport y 932 -> 371 - #orders-body > tr:nth-of-type(4) > th:nth-of-type(1): sticky viewport y 980 -> 419 - #orders-body > tr:nth-of-type(5) > th:nth-of-type(1): sticky viewport y 1028 -> 467 - #orders-body > tr:nth-of-type(6) > th:nth-of-type(1): sticky viewport y 1076 -> 515 - #toast: fixed top=670px viewport y 678 -> 678 No scroll-behavior issues detected.
vlmkit check scroll index.html --viewport 375x740pageScrolled 1110 · stickyFixed 9 · engagedSticky 0 · snaps 0 · issues 0
output, 20 line(s), 1.6s
vlmkit check scroll source: index.html status: ok page scrolled: 1110px sticky/fixed elements: 9 (sticky engaged by the scroll: 0) snap containers driven: 0 Sticky / fixed: - #sidebar: fixed top=675px viewport y 675 -> 675 - table.orders > thead:nth-of-type(1) > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 1408 -> 298 - #orders-body > tr:nth-of-type(1) > th:nth-of-type(1): sticky viewport y 1448 -> 338 - #orders-body > tr:nth-of-type(2) > th:nth-of-type(1): sticky viewport y 1496 -> 386 - #orders-body > tr:nth-of-type(3) > th:nth-of-type(1): sticky viewport y 1544 -> 434 - #orders-body > tr:nth-of-type(4) > th:nth-of-type(1): sticky viewport y 1592 -> 482 - #orders-body > tr:nth-of-type(5) > th:nth-of-type(1): sticky viewport y 1640 -> 530 - #orders-body > tr:nth-of-type(6) > th:nth-of-type(1): sticky viewport y 1688 -> 578 - #toast: fixed top=634px viewport y 642 -> 642 No scroll-behavior issues detected.
vlmkit verify flow index.html --flow flow.jsonverdict: DONE (14/14 steps)
done true · passed 14 · total 14
output, 60 line(s), 2.6s
vlmkit verify flow
source: index.html
verdict: DONE (14/14 steps)
✓ step 1: Choose the 90-day range
✓ #range-label text~"Last 90 days"
✓ #range-dates text~"Jun 25 – Sep 22, 2026"
✓ [data-kpi=visitors] .kpi-value text~"143,800"
✓ [data-kpi=conversion] .delta text~"−2.0%"
✓ [data-kpi=visitors] .delta-ctx text~"vs previous 90 days"
✓ #channels-total text~"143,800"
✓ .c-pt count=90
✓ step 2: Focus the latest day on the chart
✓ #chart-tip visible
✓ #chart-tip text~"Tue, Sep 22"
✓ step 3: ArrowLeft moves one day back
✓ .c-pt[data-i="88"] focused
✓ .c-pt[data-i="88"] tabindex=0
✓ .c-pt[data-i="89"] tabindex=-1
✓ #chart-tip text~"Mon, Sep 21"
✓ step 4: Home jumps to the first day of the range
✓ .c-pt[data-i="0"] focused
✓ #chart-tip text~"Thu, Jun 25"
✓ step 5: Sort the orders by amount
✓ th.num aria-sort=ascending
✓ th[aria-sort] count=1
✓ #orders-body tr:first-child .cell-amount text~"$37.80"
✓ step 6: Sort by amount again: largest first
✓ th.num aria-sort=descending
✓ #orders-body tr:first-child .cell-amount text~"$318.90"
✓ #orders-status text~"Showing 1–6 of 12 orders"
✓ step 7: Go to page 2
✓ #orders-status text~"Showing 7–12 of 12 orders"
✓ .pager-btn[data-page="2"] aria-current=page
✓ .pager-btn[data-page="next"] aria-disabled=true
✓ .pager-btn[data-page="prev"] aria-disabled=false
✓ #orders-body tr count=6
✓ step 8: Next does nothing on the last page
✓ #orders-status text~"Showing 7–12 of 12 orders"
✓ step 9: Collapse the sidebar to icons
✓ html data-sidebar=collapsed
✓ #sidebar-toggle aria-expanded=false
✓ #sidebar-toggle aria-label=Expand sidebar
✓ step 10: Open the account menu from the keyboard
✓ #user-menu visible
✓ #user-button aria-expanded=true
✓ #user-menu > [role=menuitem]:first-child focused
✓ step 11: ArrowDown reaches the theme switch
✓ #theme-item focused
✓ step 12: Switch to the light theme
✓ html data-theme=light
✓ #theme-item aria-checked=true
✓ #user-menu visible
✓ step 13: Escape closes the menu and returns focus
✓ #user-menu hidden
✓ #user-button aria-expanded=false
✓ #user-button focused
✓ step 14: Export CSV confirms the file
✓ #toast text~"keel-overview-last-90-days.csv"vlmkit check drift component index.html --selector .kpi --output-dir /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift-kpi2selector .kpi · instances 4 · drifting 2
output, 11 line(s), 2.7s
vlmkit check drift component
html: examples/sites/dashboard/index.html selector: .kpi
4 instance(s), reference = #0
verdict is set by tracked computed style; the pixel % is context, not the pass line
~ instance #1 no tracked property differs 4.33% px Δ 0 / 0
every property on the instance root matches and the palettes agree — this looks like different content
~ instance #2 no tracked property differs 3.00% px Δ 0 / 0
every property on the instance root matches and the palettes agree — this looks like different content
~ instance #3 no tracked property differs 4.36% px Δ 0 / 0
every property on the instance root matches and the palettes agree — this looks like different content
report: /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift-kpi2/report.mdvlmkit check drift component index.html --selector .badge --output-dir /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift4 --allow 'color@.badge--pending;semantic status colour: Pending is amber, the text says Pending' --allow '*-color@.badge--pending;border/outline/background colours follow the status colour' --allow 'color@.badge--refunded;semantic status colour: Refunded is neutral slate, the text says Refunded' --allow '*-color@.badge--refunded;border/outline/background colours follow the status colour' --allow 'color@.badge--failed;semantic status colour: Failed is red, the text says Failed' --allow '*-color@.badge--failed;border/outline/background colours follow the status colour'selector .badge · instances 6 · drifting 3
output, 28 line(s), 2.0s
vlmkit check drift component
html: examples/sites/dashboard/index.html selector: .badge
6 instance(s), reference = #0
verdict is set by tracked computed style; the pixel % is context, not the pass line
~ instance #1 all 7 difference(s) exempted 39.68% px Δ +22 / 0
exempted border-top-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
exempted border-right-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
exempted border-bottom-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
exempted border-left-color: rgb(134, 239, 172) → rgb(252, 211, 77) — user exemption (*-color@.badge--pending): border/outline/background colours follow the status colour
and 3 more exempted
every property on the instance root matches, but 21 colour(s) appear in one instance and not the other — a styling difference on a descendant or in an untracked property, not necessarily content
✓ instance #2 identical 0.00% px Δ 0 / 0
~ instance #3 all 7 difference(s) exempted 45.43% px Δ +30 / 0
exempted border-top-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
exempted border-right-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
exempted border-bottom-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
exempted border-left-color: rgb(134, 239, 172) → rgb(203, 213, 225) — user exemption (*-color@.badge--refunded): border/outline/background colours follow the status colour
and 3 more exempted
every property on the instance root matches, but 19 colour(s) appear in one instance and not the other — a styling difference on a descendant or in an untracked property, not necessarily content
✓ instance #4 identical 0.00% px Δ 0 / 0
~ instance #5 all 7 difference(s) exempted 26.79% px Δ +9 / 0
exempted border-top-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
exempted border-right-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
exempted border-bottom-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
exempted border-left-color: rgb(134, 239, 172) → rgb(252, 165, 165) — user exemption (*-color@.badge--failed): border/outline/background colours follow the status colour
and 3 more exempted
every property on the instance root matches, but 24 colour(s) appear in one instance and not the other — a styling difference on a descendant or in an untracked property, not necessarily content
report: /tmp/claude-0/-home-user-vlmkit/663fea0b-e0f8-5798-a548-7572e9f0176c/scratchpad/drift4/report.mdvlmkit check motion index.htmlactiveAnimations 0 · running 0 · issues 0
output, 22 line(s), 1.4s
vlmkit check motion source: index.html status: ok samples: 8 active animations: 0 active transitions: 8 running animations: 0 paused animations: 0 reduced-motion rule: yes Motion samples: - div.app: animation=none 0s running; transition=grid-template-columns 0.2s - path.toggle-chevron: animation=none 0s running; transition=transform 0.2s - #channels > li:nth-of-type(1) > div:nth-of-type(2) > div:nth-of-type(1): animation=none 0s running; transition=width 0.3s - #channels > li:nth-of-type(2) > div:nth-of-type(2) > div:nth-of-type(1): animation=none 0s running; transition=width 0.3s - #channels > li:nth-of-type(3) > div:nth-of-type(2) > div:nth-of-type(1): animation=none 0s running; transition=width 0.3s - #channels > li:nth-of-type(4) > div:nth-of-type(2) > div:nth-of-type(1): animation=none 0s running; transition=width 0.3s - #channels > li:nth-of-type(5) > div:nth-of-type(2) > div:nth-of-type(1): animation=none 0s running; transition=width 0.3s - #toast: animation=none 0s running; transition=opacity, transform, visibility 0.15s, 0.15s, 0s No motion issues detected.
final: dark, full page
index.html · 1280×800 · full page, 2 screen(s)
2 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Final, desktop 1280 dark, full page. Screen 1: expanded sidebar with the sailboat mark, Overview current (tint + left bar), Settings and Collapse at the foot. The top bar reads "Overview" / "Harbor & Pine · Last 30 days · Aug 24 – Sep 22, 2026", and the controls on the right share its centre line (the page-title warn): raised "30 days", Export CSV, MR. Four identical KPI cards with 28px values, sparklines, and arrow+sign deltas (↑ +13.0%, ↑ +6.2%, ↑ +17.0%, ↓ −2.5% in red). The chart card and Top channels are equal height; the chart has no hint paragraph, and the channel total sits on the values grid. Screen 2: the sticky top bar holds. Table columns sit on the locked widths (Customer ≈448, Date ≈642 in page coordinates). IDs are regular weight and amounts bold, and the badges carry text and dots. The 36px pager matches Export CSV, and the footnote says the data is fictional. No wrap, clip or overlap anywhere. The 8px rhythm is visible: 16px gutters between cards, 24px card padding, 8px between label, value and delta.
final: dark, full page
index.html · 375×812 · full page, 3 screen(s)
Final, phone 375 dark, full page. Screen 1: the fixed bottom bar (Overview current with a 3px top bar, then Traffic, Conversions, Customers, Settings), the compact mark with "Overview", the meta on two lines with no stray dot, and 7/30/90 + Export CSV on one row. KPI 2x2 with 24px values, full-width sparklines, and deltas above "vs previous 30 days". Screen 2: chart, then Top channels with the total row. Screen 3: the orders table in its own scroller. The pinned ID column has a hairline, and a dark fade at the right edge, header included, says more columns follow. The pager has 36px buttons, and the footnote is clear of the bottom bar. No horizontal page scroll.
final: light, full page
index.html?theme=light · 1280×800 · full page, 2 screen(s)
Final, desktop 1280 ?theme=light, full page. Screen 1: white cards on #f3f5f8, a white sidebar with Overview in pale teal, and "30 days" now a raised white chip on a grey track, the same raised-selection reading as dark. KPI values are near-black, with green ↑ and red ↓ deltas, teal sparklines, the teal chart line and bars, and the grey dashed previous line. Screen 2: the grey table header, badges as tinted pills with dark text, the teal current page, and the footnote. No smear at the top edge, and the brand row is clean. Both themes carry the same hierarchy: the number first, then the trend, then the context.
final state: 90-day range selected
index.html · 1280×800 · click label.seg:nth-of-type(3)
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Final state, 90-day range selected (desktop dark). "90 days" is the raised segment, and the meta reads "Last 90 days · Jun 25 – Sep 22, 2026". KPIs: 143,800 ↑ +25.9%, 2.56% ↓ −2.0% (red, arrow and minus sign), $246,162 ↑ +23.6%, $66.76 ↑ +0.2%, each "vs previous 90 days", with 7-day-smoothed sparklines. The chart compares against Mar 27 – Jun 24 with fortnightly labels Jun 25 … Sep 17, and the dashed previous line shows its weekly rhythm. Channels: Organic 62,840 43.7% … Email 10,785 7.5%, Total visitors 143,800 100%. The table is untouched, as it should be (recent orders do not depend on the range).
final state: chart tooltip showing (hover Sep 16)
index.html · 1280×800 · element .chart-card · hover .c-pt[data-i="23"]
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Final state, chart tooltip showing (pointer on Sep 16, desktop dark). The tooltip reads "Wed, Sep 16", then a solid swatch "This period 1,852", then a dashed swatch "Aug 17 1,676". It flips to the LEFT of the point because the right side lacks the 200px it needs, and sits vertically centred on the point. A crosshair runs from the top gridline to the axis, the hovered day is a hollow teal ring, and the Sep 22 end dot stays filled. There is no keyboard hint because the pointer opened it. The legend (solid This period / dashed Previous period) above matches the swatches.
final state: orders sorted by amount
index.html · 1280×800 · element .orders-card · click th.num .sort · click th.num .sort
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Final state, orders sorted by amount (largest first). Amount's header is white with a single down chevron, and every other header has the faint two-way glyph. The rows are $318.90 (Priya Natarajan, Direct, Paid), $265.00 (Ethan Brooks, Refunded), $212.40, $184.00, $143.60, $129.99. "Showing 1–6 of 12 orders", page 1 current, Previous disabled. Columns sit on the locked widths (Customer ≈176, Date ≈370, Status ≈679 in the crop).
final state: page 2 of the table
index.html · 1280×800 · element .orders-card · click .pager-btn[data-page="2"]
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Final state, page 2 of the table (default date sort). "Showing 7–12 of 12 orders". Page 2 has the teal current state, Next is disabled (muted text, no fill), and Previous is active. The rows run from Sep 20 21:40 Sofia Marino to Sep 18 20:19 Ethan Brooks, with badges Paid, Paid, Pending, Paid, Paid, Refunded, each carrying its word, not only a colour. The column edges are identical to S38 and to page 1.
final state: tablet, sidebar collapsed to icons
index.html · 768×1024 · full page, 2 screen(s)
Final state, tablet 768 with the sidebar collapsed to icons (the default from 768 to 1279). The 72px rail holds the mark, 4 icons with Overview tinted and its left bar, Settings at the foot, and the expand toggle. The two-row top bar has title + meta + MR, then the range + Export CSV. KPIs are 2x2 with value and sparkline side by side, and the chart is full width. Screen 2: channels, then Recent orders fitting its card at 12px padding with all six columns visible (Amount right-aligned at the edge), and the pager on one row. No horizontal scroll, and the sticky two-row top bar holds.
final state: phone, bottom navigation + table scrolled sideways
index.html · 375×812 · scroll-to .orders tbody tr:nth-child(3) td:last-child
1 screen(s), looked at when taken; not kept once the round was done — a round keeps one full-page walk per width.
Final state, phone 375 with the bottom navigation and the orders table scrolled sideways. The fixed bottom bar shows Overview current (teal, 3px top bar), Traffic, Conversions, Customers and Settings. In the card, the ID column (#KA-20417 …) stays pinned at the left with its hairline, Channel is sliding under it ("anic", "il", "ct"…), and Status badges and bold Amounts ($184.00 … $76.20) fill the view. The right-edge fade is gone because the last column is reached. The status line and pager sit below the table, and the footnote clears the bottom bar.
Final integrity run: 0 fail. The one warn is the same near-misalignment of div.page-title (6px top-edge offset from centring a 48px title block against 36px controls, align-items:center). Explained with evidence in the note on G1; S33 shows the shared centre line.
Final focus run: 21 stops in visual order. The only warn is the skip-row over the six table rows, which hold no controls (explained in the note on G25; S38/S39 show the rows).
Final scan scroll at 1280: page overflow-x 0. The #orders-scroll dead scrollport is deliberate (note on G26), and the two clipped-content warns are the sr-only heading and legend that check integrity exempts (note on G12).
Final scan scroll at 375: the one scroll container is #orders-scroll (axis x, 273px), as the brief asks, and page overflow-x is 0. The two clipped-content warns are the sr-only heading and legend (note on G13).
Inventory-only warn (no handler type exercised). Exercised by check interactions --handlers (G45) and by the 14-step verify flow (G49), which drives the chart keys, sort, pager, sidebar, menu and theme.
Final interactions run: status ok. The four inert-control warns are the pager again. Previous (aria-disabled on page 1) and Page 1 (current) are correct no-ops (note on G15). Page 2 and Next respond and announce, as the gate's own inventory line says ('Enter: no response | announces'); the rule ignores that (tool note on G27). The response is asserted by verify flow G49 step 7.
Keel Analytics Overview, built and judged in 4 rounds. 16 defects fixed and verified: 11 found by eye (channel-total orphan, KPI value shrink, bold IDs competing with amounts, a manual-style chart hint, the tablet table cut at Amount, a stray separator dot on phones, a too-faint scroll cue (two fixes), a skip-link shadow smear visible only in light, a logo that read as a smiley, columns that wobble when paging, a flat selected segment in light) and 5 by gates (an SVG made focusable by its focus listeners, a Tab stop on a table that did not scroll, a silent pager, button-style drift, and a scroll-shadow gradient that hid the table's contrast from measurement). Final gate set: integrity, copy (0 missing), design (coherent), composition, color, contrast in both themes (0 failures each), focus, touch, scan scroll at 1280 and 375, scan handlers, interactions --handlers, breakpoints 768/1280 with sweep, check scroll at two widths, verify flow 14/14, drift component for the KPI cards and badges, and check motion. All exit 0; each remaining warn is explained in a note. One change, touch-tap tooltip persistence, was reasoned from the code because no tool here can drive a tap; a decision note says so.
R5 · independent review
reviewervlmkit check integrity index.htmlverdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 17 exempted)
verdict clean · findings 1 · fails 0 · warns 1 · exempted 17 · viewports 3
output, 29 line(s), 4.2s
vlmkit check integrity source: index.html verdict: NO DEFECTS, 1 WARN (0 fail, 1 warn, 17 exempted) exits 0 — 1 warn(s) did not fail this command. To gate on one: --rule near-misalignment=suspect 1280x800: 8 component(s), ink 14.8%, 120 text block(s) 768x900: 8 component(s), ink 16.1%, 120 text block(s) 375x700: 8 component(s), ink 17.0%, 116 text block(s) Findings: ! [near-misalignment] div.page-title @1280: div.page-title is off its siblings' shared top edge by 6px inside header.topbar — siblings align exactly; a 2-8px deviation is almost always an accident (stray margin/padding), not a design choice. Exempted candidates (the tool's call — audit the rule, not the page): - [text-collision] legend.sr-only @1280: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] legend.sr-only @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @1280: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] span.brand-name @768: clipped away by span.brand-name (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-collision] legend.sr-only @768: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there - [text-clipped] span.brand-name @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(1) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(2) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(3) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] ul.nav-list > li:nth-of-type(4) > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] li.nav-item-end > a:nth-of-type(1) > span:nth-of-type(1) @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] span.sidebar-toggle-label @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] legend.sr-only @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-clipped] #kpis-title @768: visually-hidden (sr-only) pattern — 1px box, text for AT only - [text-collision] legend.sr-only @375: clipped away by legend.sr-only (overflow is not visible) — its box still overlaps text below the clip, but no glyph is painted there … 2 more
vlmkit check color index.htmlverdict: CONSISTENT (0 finding(s))
verdict consistent · base #0d131c · bodyInk #e6ecf3 · linkInk #8392a5 · invisibleControls 0 · colorOnlyLinks 0 · unreadableColors 0
output, 24 line(s), 1.6s
vlmkit check color
source: index.html
verdict: CONSISTENT (0 finding(s))
measured: 0 control(s), 6 link(s) in a text flow — from 251 visible box(es)
Palette (by painted area; reported, never judged — see the rejected candidates in the module docs)
surfaces 4 distinct
#0d131c 55.8% 2 el
#131b26 35.9% 18 el a.skip-link
#0a0f16 7.1% 1 el nav#sidebar
#18222f 1.3% 6 el thead>tr>th.col-id
ink 5 distinct
#e6ecf3 54.0% 46 el a.skip-link
#aab6c5 28.9% 27 el li>a.nav-link>span.nav-label
#8392a5 15.6% 23 el button#sidebar-toggle>span.sidebar-toggle-label
#86efac 0.9% 3 el tr>td>span.badge
#5eead4 0.6% 2 el li>a.nav-link>span.nav-label
marks 3 distinct
#223041 95.6% 84 el nav#sidebar
#2f3e52 3.8% 16 el button#export
#2dd4bf 0.7% 4 el div.table-foot>nav.pager>button.btn
base #0d131c, body ink #e6ecf3, link ink #8392a5 on 7 element(s)vlmkit check a11y contrast 'file://$PWD/examples/sites/dashboard/index.html?theme=light'inspected 120 · failures 0
output, 5 line(s), 1.5s
vlmkit check a11y contrast html: file://examples/sites/dashboard/index.html?theme=light inspected 120 text-bearing element(s) ✓ 0 contrast failure(s) report: examples/sites/dashboard/test-results/a11y-contrast/index-7bdde28d/report.md
vlmkit verify flow index.html --flow flow.jsonverdict: DONE (14/14 steps)
done true · passed 14 · total 14
output, 60 line(s), 2.2s
vlmkit verify flow
source: index.html
verdict: DONE (14/14 steps)
✓ step 1: Choose the 90-day range
✓ #range-label text~"Last 90 days"
✓ #range-dates text~"Jun 25 – Sep 22, 2026"
✓ [data-kpi=visitors] .kpi-value text~"143,800"
✓ [data-kpi=conversion] .delta text~"−2.0%"
✓ [data-kpi=visitors] .delta-ctx text~"vs previous 90 days"
✓ #channels-total text~"143,800"
✓ .c-pt count=90
✓ step 2: Focus the latest day on the chart
✓ #chart-tip visible
✓ #chart-tip text~"Tue, Sep 22"
✓ step 3: ArrowLeft moves one day back
✓ .c-pt[data-i="88"] focused
✓ .c-pt[data-i="88"] tabindex=0
✓ .c-pt[data-i="89"] tabindex=-1
✓ #chart-tip text~"Mon, Sep 21"
✓ step 4: Home jumps to the first day of the range
✓ .c-pt[data-i="0"] focused
✓ #chart-tip text~"Thu, Jun 25"
✓ step 5: Sort the orders by amount
✓ th.num aria-sort=ascending
✓ th[aria-sort] count=1
✓ #orders-body tr:first-child .cell-amount text~"$37.80"
✓ step 6: Sort by amount again: largest first
✓ th.num aria-sort=descending
✓ #orders-body tr:first-child .cell-amount text~"$318.90"
✓ #orders-status text~"Showing 1–6 of 12 orders"
✓ step 7: Go to page 2
✓ #orders-status text~"Showing 7–12 of 12 orders"
✓ .pager-btn[data-page="2"] aria-current=page
✓ .pager-btn[data-page="next"] aria-disabled=true
✓ .pager-btn[data-page="prev"] aria-disabled=false
✓ #orders-body tr count=6
✓ step 8: Next does nothing on the last page
✓ #orders-status text~"Showing 7–12 of 12 orders"
✓ step 9: Collapse the sidebar to icons
✓ html data-sidebar=collapsed
✓ #sidebar-toggle aria-expanded=false
✓ #sidebar-toggle aria-label=Expand sidebar
✓ step 10: Open the account menu from the keyboard
✓ #user-menu visible
✓ #user-button aria-expanded=true
✓ #user-menu > [role=menuitem]:first-child focused
✓ step 11: ArrowDown reaches the theme switch
✓ #theme-item focused
✓ step 12: Switch to the light theme
✓ html data-theme=light
✓ #theme-item aria-checked=true
✓ #user-menu visible
✓ step 13: Escape closes the menu and returns focus
✓ #user-menu hidden
✓ #user-button aria-expanded=false
✓ #user-button focused
✓ step 14: Export CSV confirms the file
✓ #toast text~"keel-overview-last-90-days.csv"Overview, dark (default)
index.html · 1280×800 · full page, 2 screen(s)
Desktop, dark default (2 screens). Screen 1: a dark slate app shell — the sidebar with the teal Keel logo, Overview marked by a teal bar and a tinted pill, Settings and Collapse pinned at the bottom; the top bar with "Overview", "Harbor & Pine · Last 30 days · Aug 24 – Sep 22, 2026", the 7/30/90 segmented control (30 selected), Export CSV and the MR avatar. Four KPI cards of identical construction: label, a large tabular value, a sparkline, and a delta with an arrow and a signed number (↑ +13.0% in green, ↓ −2.5% in red), so the direction reads without colour. The chart has y and x axes, faint gridlines, a legend (solid this period, dashed previous) and a highlighted last point; the channel bars carry values and percentages and a total. Screen 2: the top bar stays pinned; the orders table has a sorted Date column (bold, chevron), status badges with a dot and a word, right-aligned amounts, and the pager with page 1 marked. Everything sits on one grid; no collisions.
Overview, dark (default)
index.html · 375×812 · full page, 3 screen(s)
Phone (3 screens). The top bar reflows to title, meta and avatar, with the range control and Export CSV on a second row; KPI cards go 2x2 without losing their deltas. A bottom navigation bar with five labelled icons is pinned to the screen bottom, Overview marked by a teal line above it; the page leaves room so the footer clears it (screen 3). The orders table scrolls sideways inside its card: the Order column is pinned with a divider and a fade on the right edge says there is more. The pager fits on one row. Nothing overflows the page.
Overview, light, tablet
index.html?theme=light · 768×1024 · full page, 2 screen(s)
Tablet, light theme (2 screens). The sidebar collapses to an icon rail with the active item's teal bar kept; the top bar puts its controls on a second row. KPI cards put the sparkline beside the value, which uses the wider card well. In light, the palette inverts cleanly: white cards on a pale grey ground, the teal accent darkened for contrast, badges on pale tints (Paid green, Pending amber, Refunded grey, Failed red), all still carrying their words. The table fits without scrolling at this width. Nothing looks like a dark-theme leftover.
Independent review: integrity (its one warn is the builder's noted false positive), color, light-theme contrast and the 14-step verify flow re-run green; three fresh full-page shots at desktop dark, phone and tablet light show nothing the builder's log missed. No defects raised.
Reviewer round: gates green on re-run; dark, light, desktop, tablet and phone all hold up.
































