Visual regression TDDs (overflow / chrome-overlap / proportion / sibling-collision)
develop specs/develop/visual-regression-tdds.kmd
Every Koder UI surface — web, Flutter (mobile/desktop), Android native, iOS native, TV — MUST ship visual-regression TDDs covering four categories: (1) viewport overflow, (2) chrome overlap (browser URL bar / OS nav bar / IME / notch), (3) decorative-element proportion, and (4) sibling decorative collision (intra-container shapes piling onto each other at narrow viewports). Companion to specs/develop/docs-mobile-responsiveness.kmd (which covers structural responsiveness) and specs/app-layout/safe-area.kmd (which covers system chrome insets). This spec formalizes the TDD contract — what to generate, what to assert, which viewports, when to run.
Quando esta spec se aplica
Triggers primários
- Author or regenerate visual regression TDDs
Todos os triggers
- Ship a new UI surface in any Koder app or site
- Audit a UI for hidden mobile / chrome / proportion regressions
- Define the test suite for /k-test on a new module
Corpo da especificação
Spec — Visual regression TDDs
Scope
Every interactive Koder surface MUST ship the three TDD categories below
under tests/regression/visual/. Targets:
- Web: docs site, landing pages, web apps (admin/dashboards).
- Flutter: mobile + desktop + web + TV.
- Android native: Kotlin/Compose apps.
- iOS native: when shipped.
- CLI / TUI: column-width adaptation only (category C overflow variant); other categories N/A.
Out of scope: static text files, server-only services with no UI, markdown rendered without page chrome.
R1 — Categories (the four TDDs)
Category A — Viewport overflow
For every named layout state (default, drawer-open, modal-open, keyboard-open, etc.) at every R2 viewport, assert that no rendered element crosses the viewport edge. Concretely:
∀ element e ∈ document.querySelectorAll('*'):
rect = e.getBoundingClientRect()
assert rect.right ≤ viewport.width
assert rect.left ≥ 0
assert rect.bottom ≤ visualViewport.height + scrollY
assert rect.top ≥ 0 - scrollY
Exemptions: explicitly opted-out elements may carry the data attribute
data-visual-overflow="allowed-by:<spec-ref>" (e.g.
allowed-by:components/carousel.kmd § R3 marquee). Audit reads the
attribute and skips. No unmarked exemptions — bugs hide there.
Category B — Chrome overlap
Specifically tests dynamic chrome that grows / shrinks at runtime:
- Web mobile (Chrome / Safari): URL bar enters during scroll-up.
Test that
position: fixed; bottom: 0controls + last items of sidebar drawers + bottom sheets + FABs stay reachable. - iOS Safari: home indicator gesture area.
- Android system chrome: status bar, 3-button nav bar, gesture pill, IME (keyboard).
- Display cutouts: notch, hole-punch, Dynamic Island in landscape.
Required assertions, per viewport per chrome state:
- The last item of any drawer/sidebar is fully visible —
lastChild.getBoundingClientRect().bottom ≤ visualViewport.height. - No critical control sits under chrome — buttons, links, form
fields tested for intersection with the chrome regions reported by
the OS or simulated by the test harness (
window.visualViewportfor web,MediaQuery.viewPaddingfor Flutter). 100dvhcontainers shrink correctly — web only; if the layout setsheight: 100vh(nodvhfallback above it) the assertion fails with a hint pointing to specs/app-layout/safe-area.kmd § Web.
Category C — Decorative-element proportion
Decorative shapes (hero blobs, mascot illustrations, divider
ornaments) must scale proportionally to their container, NOT bottom
out on a fixed min value that overshoots small viewports.
Assertions:
No element with
data-kds-decorative="true"exceeds 33% of its container width on Compact (< 600 dp) viewports.Visual-area parity — sibling decoratives in the same row must not differ in perceived width by more than 12% (`Math.abs(a.width
- b.width) / Math.max(a.width, b.width) ≤ 0.12`).
clamp()floor sanity — extract the px floor of everywidth: clamp(MIN, vw, MAX)declaration. Floor MUST be ≤ vw value at 360 dp viewport (otherwise the floor wins and the shape doesn't scale below tablet). Audit grep:tools/koder-css-audit clamp-floor --viewport 360.Decorative margin — silhouette never touches container edges. Per viewport in R2, for every
data-kds-decorative="true"element measuregetBoundingClientRect()against its nearest positioned ancestor (the "container"). Assert:- At rest:
rect.top ≥ container.top + 4% × container.height - At rest:
rect.bottom ≤ container.bottom − 4% × container.height - At rest:
rect.left ≥ container.left + 4% × container.width - At rest:
rect.right ≤ container.right − 4% × container.width - At every keyframe peak (sample at 25 / 50 / 75% of each
named animation), the same 4% margin holds. The harness drives
Element.getAnimations()[i].currentTimeto each peak before measuring.
Rationale: keyframe peaks routinely push silhouettes past the resting bounding box (translate Y −55%, scale 1.15 etc.); checking only with
animation-play-state: pausedmisses the worst frames. The 4% floor is a soft minimum — designers may raise it per surface via--kds-decorative-margin: 6%;on the container, which the audit reads viagetComputedStyle(container).getPropertyValue(...).- At rest:
Category D — Sibling decorative collision
Decorative siblings (hero shapes, mascot trio, illustration cluster) inside the same positioned container MUST NOT pile onto each other when the viewport shrinks. Category C guarantees each shape stays within its own clamp budget; Category D guarantees the cluster remains visually parseable as N separate shapes.
Concretely, for every container holding ≥ 2 elements with
data-kds-decorative="true" (or matching the same
data-kds-decorative-group="<name>"), per viewport in R2:
∀ par (a, b) de shapes irmãs no mesmo container, em cada keyframe peak:
rect_a = a.getBoundingClientRect()
rect_b = b.getBoundingClientRect()
overlap_area = max(0, min(a.right, b.right) - max(a.left, b.left))
× max(0, min(a.bottom, b.bottom) - max(a.top, b.top))
min_area = min(rect_a.width × rect_a.height, rect_b.width × rect_b.height)
assert overlap_area / min_area ≤ 0.05 # ≤ 5% of the smaller shape
Floor is 5% of the smaller shape's area — small grazing touch
(decorative blend) tolerated; pile-on (the pink-blue-yellow trio in
the user-reported #057 KDS hero bug at 390 dp) flagged. Surface may
raise the floor per-container with
--kds-decorative-collision-floor: 12%; for intentional overlapping
collages (audit reads via getComputedStyle); raising the floor
requires a spec § reference in the same PR (mirror R6 allow-list
rule).
Run at the 3 phone rows of R2 plus phone-360l (landscape — notch
state often triggers extra collision). Sample at keyframe peaks via
the same Element.getAnimations() mechanism as Category C R1.C.4.
Rationale: a hero cluster sized for desktop (3 shapes laid out horizontally with 24 dp gaps) collapses to a vertical-ish pile on a 360 dp portrait viewport when the container narrows below the sum of the shapes' min widths. Either the layout reflows (preferable — column stack with gap reset) or the shapes scale down further (must preserve gap proportionally). Category D fails on both regression modes; the fix is structural, not per-shape clamp tweaks.
Category E — Scroll & reachability
Category A guarantees nothing overflows the viewport unexpectedly. But content legitimately exceeds the viewport — a long form, a settings list, a wide table. When it does, the surface MUST be scrollable and every interactive element MUST be reachable by scrolling, vertically and/or horizontally. A form taller than the screen whose submit button sits below the fold with no scroll is a hard bug (the user cannot submit); so is a wide table clipped with no horizontal scroll.
For every named layout state, at every R2 viewport, when the content bounding box exceeds the viewport on an axis:
content = union of all element bounding boxes (pre-scroll)
overflowsY = content.height > viewport.height
overflowsX = content.width > viewport.width
if overflowsY or overflowsX:
# 1. A scroll container exists on the overflowing axis
assert exists scrollable ancestor with
(overflowsY → scrollHeight > clientHeight) and/or
(overflowsX → scrollWidth > clientWidth)
# 2. Every focusable / form element is reachable by scrolling
∀ e ∈ interactive elements (inputs, buttons, links, controls):
scroll e into view (driver: scrollIntoView / Scrollable.ensureVisible /
koder-vdev pointer scroll / touch drag)
rect = e.getBoundingClientRect() after scroll
assert rect fully inside the viewport (− chrome insets, Category B)
# 3. The scroll reaches BOTH ends (no dead zone)
assert can scroll to scrollTop/Left = 0 and to the max extent
# 4. The submit / primary action of any form is reachable (explicit)
assert form.submitControl reachable + inside viewport after scroll
- Axes: test vertical when
overflowsY, horizontal whenoverflowsX, both when both. A surface that overflows horizontally on a phone but is not horizontally scrollable fails (the classic "wide table clipped"). - Scroll driver per surface: web →
element.scrollIntoView/ Playwrightmouse.wheel; Flutter →Scrollable.ensureVisible/WidgetTester.drag/fling; Android Compose →performScrollTo/performTouchInput { swipe() }; native shell / mobile end-to-end →koder-vdev(pointer scroll wheel, or a touch drag — gesture-tdds.kmd). - Reduced/large text interaction: re-run Category E at the largest supported text-scale (a form that fit at 1.0× often overflows at 2.0× accessibility scaling — the regression Category E is built to catch).
- Reachability ≠ presence: Category A's overflow exemption
(
data-visual-overflow) does NOT exempt Category E — an element allowed to overflow must still be scroll-reachable unless it is purely decorative (data-kds-decorative).
Rationale: the most damaging mobile bug class is "the button is off-screen and there's no scroll" — invisible to a static screenshot (Category A may even pass if the element is clipped out of the captured frame). Category E asserts the behavioral contract: big content scrolls, every control is reachable, both ends and both axes.
R2 — Test viewport matrix (mandatory)
Mirrors docs-mobile-responsiveness.kmd § R5 extended with two
landscape rows:
| ID | Width × Height | Device class | Notes |
|---|---|---|---|
phone-360p | 360 × 800 | Android phone portrait | The cheap-end baseline |
phone-390p | 390 × 844 | iPhone 15 portrait | iOS Safari URL-bar dance |
phone-360l | 800 × 360 | Phone landscape | Notch cutout test |
phone-se | 320 × 568 | iPhone SE | Smallest reasonable mobile |
tablet-p | 768 × 1024 | Tablet portrait | Medium class |
tablet-l | 1024 × 768 | Tablet landscape | |
laptop | 1280 × 800 | Laptop | Expanded class |
desktop | 1440 × 900 | Desktop | Wide |
ultrawide | 1920 × 1080 | Large class baseline |
CI MUST run categories A and B at every row. Category C runs at the
3 phone rows (decoratives matter on the floor). Category D runs at
the 3 phone rows + phone-360l (landscape notch surfaces extra
collision states). Category E runs at every row (any viewport can
overflow) plus the largest text-scale pass at the 3 phone rows (the
accessibility-scaling overflow case).
R3 — Chrome states to enumerate (Category B)
Per R2 phone row, simulate each chrome state:
| State | Web (visualViewport.height) | Flutter (viewPadding) |
|---|---|---|
| URL bar visible | width × (height - 56) (Chrome Android) / (height - 44) (Safari iOS) | n/a (handled by SDK) |
| URL bar hidden | width × height | n/a |
| IME open | width × (height - 280) typical Latin keyboard | bottom: 280 |
| 3-button nav | bottom: 48 reserved | already in viewPadding.bottom |
| Gesture pill | bottom: 24 reserved | already in viewPadding.bottom |
| Notch landscape (390l) | left: 44 or right: 44 | left/right: 44 |
The audit harness drives the viewport through every state and runs the A/B assertions for each.
R4 — Generation contract
/k-test (TDD generator) MUST emit the visual-regression suite when
the module has any of:
- A
koder.toml[ui]block declaringsurfaces = [...]. - A
pubspec.yamlwithflutterSDK dep. - A
package.jsonwithreact,vue,svelte,@playwright/test,templ(Go), or@koder/web-kitdep. - An
android/app/src/main/AndroidManifest.xmlwith<activity>.
Generated tests live under <module>/tests/regression/visual/.
File naming: <viewport-id>-<category>.spec.{ts,dart,kt}. Snapshots
live in the sibling __snapshots__/ directory and ARE committed
(per policies/regression-tests.kmd — snapshots are golden, not
artifacts).
The generator MUST emit a Category E (scroll & reachability) suite for
every surface that can present content exceeding the viewport — which is
every form, list, table, or settings surface (i.e. effectively all
non-trivial surfaces). For surfaces declared as scrollable in code
(SingleChildScrollView/ListView/overflow:auto/ScrollView) it is
mandatory; the generator scaffolds the scroll-driver per the surface kind
(R1.E driver list).
Template engines:
- Web → Playwright + the snippet in
specs/web-apps/responsive-smoke.test.js, extended with category A/B/C assertions; Category E viascrollIntoViewmouse.wheelreach checks.
- Flutter →
flutter_test+golden_toolkitfor snapshots +MediaQueryoverrides for viewport states; Category E viaScrollable.ensureVisible+tester.drag/fling. - Android native → Compose UI Test (
createComposeRule()) withsetContent+onRoot().assertExists()overflow checks; Category E viaperformScrollTo()+performTouchInput { swipe() }. - Native shell / mobile end-to-end → Category E driven by
koder-vdev(pointer scroll, or touch drag —specs/develop/gesture-tdds.kmd).
R5 — Run cadence
| Trigger | Categories | Action on failure |
|---|---|---|
Pre-commit (/k-commit § 5c) | A + B + D + E --fast (3 phone rows only) | Block commit |
Pre-release (/k-ship per module) | A + B + C + D + E all rows | Block release |
| Nightly CI | A + B + C + D + E all rows + chrome states + text-scale | Open ticket auto |
/k-housekeep | C + D + E (clamp-floor + collision + scroll-reachability sanity) | Open ticket auto |
R6 — Allow-list anti-pattern
If a bug is found that the audit doesn't catch, the fix is to add a new assertion, not to allow-list the failing element. Allow-list entries (R1 data-visual-overflow attribute) require a spec reference and reviewer sign-off (PR description must link the spec § allowing the exemption).
R7 — Existing live gaps (snapshot at ratification)
At time of ratification (2026-05-21), the following surfaces have NO visual-regression suite:
tools/design-gen(KDS docs site) — depended on Lighthouse Mobile- manual review. Gap caught by user-reported bugs same day:
(1) hero shapes overshooting bounding box at 22vw / 18vw clamp
floors on Compact, (2) sidebar drawer last item clipped by Chrome
Android URL bar, (3) topbar gear icon clipping right viewport edge,
(4) hero-shape trio (pink square + blue triangle + yellow circle)
piling onto each other at 390 dp portrait — caught by new
Category D (item 4 added 2026-05-21 evening following user-
reported mobile screenshot).
Open ticket:
tools/design-gen#057follow-up.
- manual review. Gap caught by user-reported bugs same day:
(1) hero shapes overshooting bounding box at 22vw / 18vw clamp
floors on Compact, (2) sidebar drawer last item clipped by Chrome
Android URL bar, (3) topbar gear icon clipping right viewport edge,
(4) hero-shape trio (pink square + blue triangle + yellow circle)
piling onto each other at 390 dp portrait — caught by new
Category D (item 4 added 2026-05-21 evening following user-
reported mobile screenshot).
Open ticket:
- All
engines/sdk/koder_kitFlutter consumers — handled byKoderSafeScaffoldfor chrome, but no overflow audit. - All Android native apps — manual screenshot review only.
Track closure in meta/docs/stack/registries/visual-regression-coverage.md
(new — created with this spec).
Tests of the test contract
| ID | Test |
|---|---|
| T1 | Every UI module has tests/regression/visual/ populated by /k-test --gen-only. |
| T2 | CI matrix runs categories A + B at all R2 viewports per module. |
| T3 | /k-commit blocks on a fresh element overflow regression. |
| T4 | Allow-list audit: every data-visual-overflow="allowed-by:..." references an existing spec § (else fail). |
| T5 | Visual-area parity (R1.C.2) fails on a deliberate 30% size delta between sibling decoratives (regression test for the regression test). |
| T6 | clamp() floor sanity (R1.C.3) fails on a width: clamp(200px, 18vw, 240px) at 360 dp (18vw = 65 < 200, floor wins, audit alerts). |
| T7 | Decorative margin (R1.C.4) fails on a hero card whose triangular shape has its keyframe peak at translate(-50%, -55%) pushing the top edge to −2 px relative to the container — audit reports the offending keyframe % + element + computed delta. |
| T8 | Sibling-collision (R1.D) fails on a 3-shape decorative cluster at 390 dp portrait whose bounding boxes overlap > 5% of the smallest shape's area — audit reports the offending pair (a, b), the overlap area in px², and the % of min(area_a, area_b). Regression test for the KDS-hero pink/blue/yellow pile-on bug. |
| T9 | Scroll-reachability (R1.E) fails on a form taller than the viewport whose submit button is below the fold with NO scroll container — audit reports the unreachable control + the axis. |
| T10 | Scroll-reachability (R1.E) horizontal: a table wider than the viewport with no horizontal scroll fails; with a horizontal scroll container that reaches both ends, passes. |
| T11 | Scroll-reachability text-scale: a form that fits at 1.0× but pushes its submit off-screen with no scroll at 2.0× accessibility text-scale fails E at the largest-scale pass. |
Relationship to existing specs
- Subset, not duplicate, of
docs-mobile-responsiveness.kmd §R4— that spec covers Lighthouse-scope structural checks; this spec adds per-element overflow / chrome / proportion. - Cross-references
safe-area.kmd— chrome overlap (Category B) uses the same insets API; this spec adds the test that verifies consumption. - Bound by
policies/regression-tests.kmd— fix-without-test rule applies: any visual fix must ship a Category-A/B/C test that would have caught it.
Open follow-ups
- Build
tools/koder-css-audit clamp-floor(referenced in R1.C.3). - Build
tools/koder-overflow-auditfor Category A web — runs as Playwright assertion library. - Extend
/k-testto detect surfaces per R4 and stub the suites.
Referências
specs/develop/docs-mobile-responsiveness.kmdspecs/app-layout/safe-area.kmdspecs/app-layout/window-size-classes.kmdspecs/web-apps/responsiveness.kmdpolicies/regression-tests.kmd