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.
When this spec applies
Primary triggers
- Author or regenerate visual regression TDDs
All 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
Specification body
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.
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).
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).
Template engines:
- Web → Playwright + the snippet in
specs/web-apps/responsive-smoke.test.js, extended with category A/B/C assertions. - Flutter →
flutter_test+golden_toolkitfor snapshots +MediaQueryoverrides for viewport states. - Android native → Compose UI Test (
createComposeRule()) withsetContent+onRoot().assertExists()overflow checks.
R5 — Run cadence
| Trigger | Categories | Action on failure |
|---|---|---|
Pre-commit (/k-commit § 5c) | A + B + D --fast (3 phone rows only) | Block commit |
Pre-release (/k-ship per module) | A + B + C + D all rows | Block release |
| Nightly CI | A + B + C + D all rows + chrome states | Open ticket auto |
/k-housekeep | C + D (clamp-floor + sibling-collision 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. |
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.
References
specs/develop/docs-mobile-responsiveness.kmdspecs/app-layout/safe-area.kmdspecs/app-layout/window-size-classes.kmdspecs/web-apps/responsiveness.kmdpolicies/regression-tests.kmd