Web App Responsiveness

web-apps specs/web-apps/responsiveness.kmd

Responsividade e conformidade mobile para web apps Koder (admin, dashboards, consoles SaaS): breakpoints, touch targets, iOS Safari, hover, tabelas, formulários. Distinto de landing pages (specs/ landing-pages/), que tem regras próprias.

When this spec applies

All triggers

Specification body

Spec — Web App Responsiveness

Applicability

All Koder web applications with user-facing UI pages — admin panels, dashboards, SaaS consoles, and any route served beyond a static landing page. Examples:

  • id.koder.dev/admin/*, id.koder.dev/ui/*
  • flow.koder.dev/*
  • edictus.koder.dev/*, kompass.koder.dev/*
  • Any route under a product domain that is not site/index.html

Out of scope: static landing pages (site/index.html) — those are covered by specs/landing-pages/products.kmd.


1. Breakpoints

Three breakpoints, mobile-first:

Name Range CSS custom property
mobile 0 – 767 px (base, no min-width query)
tablet 768 – 1199 px @media (min-width: 768px)
desktop ≥ 1200 px @media (min-width: 1200px)

All layout styles must be written mobile-first: base styles target the narrowest viewport; add overrides at 768px and 1200px. Do not use max-width queries for primary layout breakpoints — they produce desktop-first CSS and break future additions. max-width is only acceptable to undo a desktop rule that should not apply on mobile.

Sidebar reference implementation (mobile-first):

/* Base (mobile): sidebar is a hidden drawer */
.app-shell {
  display: grid;
  grid-template-columns: 1fr;        /* single column — no sidebar column */
}

.sidebar {
  position: fixed;
  inset: 0;
  transform: translateX(-100%);       /* off-screen */
  z-index: 200;
  transition: transform 0.25s ease;
}

.sidebar.is-open {
  transform: translateX(0);
}

.mobile-topbar { display: flex; }    /* hamburger bar visible on mobile */

/* Desktop (≥ 1200 px): sidebar becomes a persistent column */
@media (min-width: 1200px) {
  .app-shell {
    grid-template-columns: 240px 1fr;  /* sidebar + content */
  }

  .sidebar {
    position: sticky;
    top: 0;
    height: 100svh;
    transform: none;                   /* always visible */
  }

  .mobile-topbar { display: none; }   /* no hamburger needed */
}

Common wrong pattern (avoid):

/* ❌ Wrong — desktop-first, sidebar hidden only below 900 px */
.app-shell { grid-template-columns: 240px 1fr; }
@media (max-width: 900px) { .sidebar { display: none; } }

/* ❌ Wrong — arbitrary breakpoint not aligned to spec */
@media (max-width: 900px) { ... }

2. Touch Targets

  • All interactive elements (buttons, links, checkboxes, form controls, icon buttons) must have a minimum clickable area of 44 × 44 px (Apple HIG minimum).
  • If the visual element is smaller, add padding or use ::before/::after to extend the hit area.
  • Interactive elements must never overlap or be spaced less than 8 px apart on mobile.

3. Navigation

3.1 Sidebar / Nav rail

  • Desktop (≥ 1200 px): persistent sidebar visible (sticky column, 240px wide by default).
  • Tablet + Mobile (< 1200 px): sidebar collapses to a full-height slide-over drawer (preferred) or an icon-only rail (acceptable). Default: drawer triggered by a top-bar hamburger button. Use the drawer for new implementations — icon-only rail only if there is a strong product reason.
  • The sidebar drawer must be position: fixed; inset: 0; z-index: 200 and slide in from the left with transform: translateX(-100%) → translateX(0).
  • A semi-transparent backdrop (position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 190) must appear behind the open drawer and close it on tap.

3.2 Top bar

  • Must be present on all three breakpoints.
  • On mobile: product logo + menu button. No horizontal nav items visible; they move into the drawer.
  • Minimum height: 52px.

3.3 Menu button (hamburger)

  • Required in the top bar when sidebar is not visible.
  • Must be at least 44 × 44 px.
  • Must toggle a drawer with aria-expanded on the triggering button.

4. Typography

Context Minimum font-size
Body / paragraphs 14px
Form inputs 16px (prevents iOS auto-zoom on focus)
Labels / captions 12px
Headings (h1–h3) Scale relative; h1 may shrink on mobile but never below 20px
  • Line height: ≥ 1.4 for body copy.
  • Never use font-size < 12px.

5. Forms

  • Inputs, selects, and textareas must be width: 100% on mobile.
  • Labels must stack above their inputs on mobile (not inline-left).
  • font-size: 16px on all <input>, <select>, <textarea> — prevents iOS Safari from zooming in on focus.
  • Submit buttons must span full width on mobile.

6. Tables

Data tables must not break the layout. On mobile (< 768 px):

  • Option A (preferred): Wrap the table in overflow-x: auto — table scrolls horizontally inside the wrapper.
  • Option B: Reflow to card/list layout via CSS.
  • Never allow table-layout: fixed with large fixed widths that cause overflow: hidden to clip data silently.
/* Option A */
.table-wrapper {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

7. Modals and Dialogs

On mobile:

  • Full-width (width: 100%; max-width: 100%) or bottom-sheet pattern.
  • max-height: 90svh with overflow-y: auto on the scrollable content area.
  • Never extend below the fold without a scroll container.
  • position: fixed works on iOS for modals — no workaround needed for modals (only for sticky bars — see §8).

8. iOS Safari — Required Rules

8.1 Viewport height

Never use height: 100vh or min-height: 100vh on full-screen containers. iOS Safari includes the browser chrome in 100vh, causing content to be partially hidden.

Use 100svh (small viewport height — excludes browser chrome):

/* Wrong */
.app-shell { min-height: 100vh; }

/* Correct */
.app-shell {
  min-height: 100svh;
  /* Fallback for browsers without svh support */
  min-height: -webkit-fill-available;
}

8.2 Safe area insets (notch / home indicator)

Containers that extend to screen edges must account for the notch and home indicator:

body {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

.top-bar {
  padding-top: calc(12px + env(safe-area-inset-top));
}

.bottom-bar, .tab-bar {
  padding-bottom: calc(8px + env(safe-area-inset-bottom));
}

Required in the <head>: <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">. viewport-fit=cover is what enables env(safe-area-inset-*) to work.

8.3 position: fixed caveats

position: fixed elements (top bars, sidebars, bottom nav) may not behave correctly on iOS when the virtual keyboard is open. Do not rely on position: fixed for elements that must stay visible while an input is focused. Consider repositioning to position: sticky within a scrollable parent for such cases.

8.4 Meta viewport — forbidden values

Never add user-scalable=no or maximum-scale=1 to the meta viewport tag. These break accessibility (pinch-to-zoom) and violate WCAG 1.4.4.

<!-- Forbidden -->
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">

<!-- Correct -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

9. Hover — Touchscreen Safety

All :hover rules applied to cards, rows, buttons, and interactive containers must be wrapped in @media (hover: hover). This prevents "stuck hover" on touchscreens (the hover state activates on tap and never clears).

/* Wrong */
.card:hover {
  background: var(--surface-raised);
  transform: translateY(-2px);
}

/* Correct */
@media (hover: hover) {
  .card:hover {
    background: var(--surface-raised);
    transform: translateY(-2px);
  }
}

Exception: color-only changes on text links (underline, color) do not require the wrapper.


10. Images and Media

  • max-width: 100% on all <img>, <video>, <iframe>.
  • Never set width or height as inline style with a fixed pixel value that exceeds the viewport.
  • Use object-fit: cover or object-fit: contain for images in fixed-size containers.
  • Decorative images: alt="".

11. Scrolling

  • Set overflow-x: hidden only on <body> or the root app shell — never on content containers where it would clip visible content.
  • Scrollable lists and panes must use -webkit-overflow-scrolling: touch (momentum scrolling on iOS).
  • Avoid nested scroll containers where possible — they produce poor UX on mobile.

12. Build & Deploy

12.1 Asset cleanup

Build tools (Vite, Webpack, etc.) append a content hash to output filenames (index-CBGY9LJz.css). When a rebuild produces a new hash, the old files stay on disk unless explicitly removed. Browsers that cached the old index.html (which references old hashes) will continue loading old CSS/JS even after a new deploy.

Rules:

  • Always clean the build output directory before or after each build: rm -rf dist/* or set build.emptyOutDir: true in vite.config.js.
  • If the server's static directory is updated by cp, clear it first: rm -rf /dest/* && cp -r dist/. /dest/.
  • Never accumulate multiple generations of hashed files in the same directory on the server.

12.2 Cache-Control headers

Resource Required header
index.html (entry point) Cache-Control: no-cache
Hashed assets (*.css, *.js with hash in filename) Cache-Control: max-age=31536000, immutable
Non-hashed static files (icon.svg, favicon.ico) Cache-Control: max-age=86400

index.html must never be cached long-term. It is the only resource that tells browsers which hashed assets to load. If it is stale, browsers serve old CSS/JS even after a redeploy.

If the web server cannot be configured per-file (e.g., koder-id serving static files directly), ensure the application sets the appropriate headers when serving index.html vs. assets.


13. Pre-deploy Checklist

Before publishing a new page or route:

  1. Headless breakpoint test — run a Playwright/Puppeteer script that opens the page at 390 px, 768 px, 1024 px, and 1440 px. At < 1200 px: sidebar must be position: fixed; transform: translateX(-100%) and .mobile-topbar must have display: flex. At ≥ 1200 px: sidebar must be position: sticky and topbar must be display: none.
  2. No max-width as primary breakpoint — grep stylesheet for max-width queries. Any @media (max-width: NNNpx) block that controls the sidebar, grid layout, or main navigation is a bug. The only accepted primary breakpoints are min-width: 768px and min-width: 1200px.
  3. Input font-size ≥ 16px — grep <input, <select, <textarea; verify CSS sets font-size to 16px on all three.
  4. No bare 100vh on full-screen containers — grep for height: 100vh and min-height: 100vh; each occurrence must be accompanied by a 100svh override on the next line.
  5. viewport-fit=cover in meta viewport tag — required if any env(safe-area-inset-*) is used.
  6. No user-scalable=no — grep the meta viewport tag; must not be present.
  7. Hover rules wrapped — grep the stylesheet for :hover outside of @media (hover: hover) {; flag any hit on cards, rows, table rows, or non-link interactive elements.
  8. Touch targets ≥ 44px — verify interactive elements (icon buttons in particular) have min-width: 44px; min-height: 44px or equivalent padding.
  9. Tables scroll horizontally — verify all <table> elements are wrapped in a div with overflow-x: auto.
  10. Modals scroll internally — check modals have max-height: 90svh; overflow-y: auto on the content area.
  11. Build artifacts clean — verify the deploy directory contains only files from the current build (no old hashed files from previous builds).
  12. Cache headers — verify index.html is served with Cache-Control: no-cache and hashed assets with max-age=31536000, immutable.

14. Audit Integration

/k-housekeep does not audit web-app routes by default (too dynamic for static grep). To trigger an audit of a specific app:

/k-housekeep platform/id

The audit will:

  1. Grep all *.css and *.html / *.svelte / *.vue / *.tsx files under platform/id/ for the checks in §13.
  2. Report violations. Does not auto-fix web app UI — changes are application code, not markup templates.
  3. List violations in the housekeep report under a new section "Web app audit — platform/id".

Auto-fix is limited to:

  • Replacing bare 100vh100svh in CSS
  • Adding viewport-fit=cover to the meta viewport tag if missing

Everything else is reported for manual fix.

Headless breakpoint test (auto-runnable when Playwright is installed):

# Run from the module root — replace id.koder.dev/admin with the actual URL
npx playwright test --browser=chromium meta/docs/stack/specs/web-apps/responsive-smoke.test.js

A reusable smoke test template lives at meta/docs/stack/specs/web-apps/responsive-smoke.test.js.