Ship Data Apps with AI

🧱 Build it small·Lesson 9 of 26·~25 min·Beginner

🎨 Make it look right everywhere

Phones, dark mode, accessibility and every state your app can be in, turned into design tokens and checks instead of hope.

You'll leave with

  • Design tokens on :root and a dark mode that works on 'system dark' phones
  • A layout measured at 390px with 44px targets and passing contrast
  • Loading, empty, stale, offline and error states designed on purpose

Most people will open your app on a phone, some with it set to dark mode. First Bites, my first-foods tracker for babies, once showed a light-green box on a black screen to every phone set to dark, because one small setting was never written onto the page. You don't need ten devices to catch that. You need a few decisions written down once, as design tokens, and checks that see your page the way a phone does: 390 pixels wide, in both themes. I'm a data person, not a designer, so everything here is a rule or a measurement, not taste.

What are design tokens (CSS variables)?

Named values written once, like --accent: #007aff, and used everywhere as var(--accent): change one line and everything using it changes. #007aff is a hex color: red, green and blue, two characters each, from 00 to ff.

Why 390 pixels?

Pages are laid out in CSS pixels, not physical dots; a phone packs two or three dots into each. Several iPhones are 390 CSS pixels wide. Desktop checks use 1280.

⚡ The 60-second version

  1. Every color, size and motion becomes a token, with light and dark values.
  2. A tiny script always writes "light" or "dark" onto the page.
  3. Measure at 390 pixels in both themes: no sideways scrolling, 44-pixel buttons, readable contrast.
  4. Open on a working example, design every state, and let Claude review while you decide.
1

Turn "make it nice" into tokens

The brief for Baby Signs, my baby sign language app, was one line: super clean, Apple-level polish. Nobody can build "polish". What got built was a short list of decisions: the system font (already on the device, so nothing downloads), one accent (Apple's system blue, #007AFF, and #0A84FF in dark mode), one card style (rounded white cards on a light grey page), a 44-pixel minimum for anything you tap, one motion curve for sheets that slide up, and a light and a dark value for every color. Each became a CSS variable, and no other rule picks its own color. That's why it shipped with an iOS look instead of being eyeballed screen by screen.

What's :root?

CSS's name for the page's outermost element, <html>. Variables set there reach every element, so that's where tokens live.

In VS Code, ask Claude Code, the AI assistant working in your project folder:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouTurn my brief, "clean, calm, Apple-level polish", into tokens on :root in index.html: system font, one accent, one card style, a 44px tap minimum, one motion curve, a type scale, and light and dark values for every color (dark ones in a :root[data-theme="dark"] block). Then replace every hard-coded color, size and radius with a token.
ClaudeEvery color, size and radius now uses var(), and a search for hex colors finds them only inside the two token blocks. The page should look exactly as before.↳ Edit · index.html↳ Grep · #[0-9a-fA-F]{3,8} in index.html

What's happening underneath: the values move into one place; nothing changes on screen yet. Yours should look roughly like this (Baby Signs' font and blues, my other values; check yours in step 3):

:root {
  color-scheme: light;
  --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  --bg: #f2f2f7;          /* light grey page */
  --card: #ffffff;        /* the one card style... */
  --radius: 14px;         /* ...with rounded corners */
  --ink: #1c1c1e;         /* main text */
  --muted: #6c6c70;       /* secondary text */
  --accent: #007aff;      /* Apple's blue: icons, outlines */
  --accent-ink: #0062cc;  /* darker blue: links, filled buttons (step 3) */
  --on-accent: #ffffff;   /* text on a filled button */
  --tap: 44px;            /* the smallest thing a thumb must hit */
  --ease: cubic-bezier(.32, .72, 0, 1);  /* the one motion curve */
  --fs-title: 22px; --fs-body: 15px; --fs-meta: 13px; --fs-micro: 12px;
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #000000;
  --card: #1c1c1e;
  --ink: #ffffff;
  --muted: #98989f;
  --accent: #0a84ff;
  --accent-ink: #0a84ff;
  --on-accent: #000000;
}
body { margin: 0; font: var(--fs-body)/1.5 var(--font); background: var(--bg); color: var(--ink); }
.card { background: var(--card); border-radius: var(--radius); padding: 16px; }
.primary { background: var(--accent-ink); color: var(--on-accent); }
.sheet { transition: transform .5s var(--ease); }

color-scheme makes scrollbars and form controls match. Nothing sets data-theme yet, so the dark values wait for step 2.

Reuse pieces people already know

When Acento, my Argentine Spanish trainer, got an accent mode, it reused the syllable chips learners already knew: the same green, amber and red, now colored by acoustic distance (how far your sound is from the target). Only the label changed, from "words matched" to "accent match". Less code, one feel, fewer bugs.

Own your core content format

Baby Signs draws its own hands as SVG instead of embedding someone else's videos, so it works offline, adds no tracking or outside brand, and can't vanish when a third party changes something.

What's SVG?

An image format made of shapes and written as text, so it can sit inside your HTML ("inline SVG"). It stays sharp at any size.

The trap: an inline <svg> with only a viewBox (its internal coordinates) has no size of its own. Baby Signs' drawings blew past their cards and covered the text, and only screenshots caught it. Set width, height and preserveAspectRatio (how the drawing fits), and give its box a fixed size with overflow: hidden:

<div class="stage">
  <svg viewBox="0 0 200 200" width="200" height="200" preserveAspectRatio="xMidYMid meet"><!-- your drawing --></svg>
</div>
.stage { width: 160px; height: 160px; overflow: hidden; }
.stage svg { width: 100%; height: 100%; }
2

Dark mode that works for "system dark" phones

Dark mode is two jobs: dark values for every token (step 1), and deciding which set applies. I tripped on the second.

What are a media query and an attribute?

A media query is CSS that applies only while a condition is true, like @media (prefers-color-scheme: dark) when the device is set to dark. An attribute is extra information in an HTML tag, like data-theme="dark" in <html data-theme="dark">; :root[data-theme="dark"] matches only while it's there.

📍 First Bites' dark styles were [data-theme="dark"] rules, which worked when you picked Dark in the app. Phones set to dark system-wide were detected with a media query instead, so the attribute was never written and those rules skipped them: a light-green box on black. The fix was a three-way setting (System, Light, Dark) that always writes the answer onto <html>. Baby Signs did that from day one.

Moving every color into tokens also let me delete First Bites' pile of !important dark-mode patches (CSS's "win no matter what" flag).

The prompt ends with screenshots, which Claude takes with check.mjs, the script from Check the AI's work that drives a headless browser (a real Chrome or Edge with no window). No check.mjs yet? Claude will write it.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouMake dark mode work for phones set to dark. At the top of <head>, add a script that always sets data-theme to "light" or "dark" on <html>: from the theme saved in myapp.prefs in localStorage, or from the system setting when that's System or missing, and again when the system setting changes. Add a labeled System / Light / Dark menu that saves to the same place, keep the theme-color tag in step and printing light, then screenshot every screen at 390px with the system dark and the menu on System.
ClaudeWith the system emulated as dark and the menu on System, <html> has data-theme="dark" on every screen, and no screenshot in shots/ shows a light box. Your check: choose Light and reload; it stays light.↳ Edit · index.html↳ Bash · node check.mjs↳ Read · shots/*.png

What's happening underneath: before anything draws, the script writes light or dark on <html>, and the dark block swaps every token at once. The choice lives under myapp.prefs, this device's preferences from Build static-first, in localStorage (the browser's per-site storage). On a first visit nothing is saved, so the phone's setting wins, and the change listener follows the phone when it flips at sunset. try/catch keeps the page working when a private window blocks storage. myapp is your app's short name.

<meta name="theme-color" content="#f2f2f7">
<script>
  function readPrefs() {
    try { return JSON.parse(localStorage.getItem('myapp.prefs')) || {}; } catch (e) { return {}; }
  }
  function applyTheme() {
    const pref = readPrefs().theme || 'system';
    const dark = pref === 'dark' ||
      (pref === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
    document.documentElement.dataset.theme = dark ? 'dark' : 'light';
    const meta = document.querySelector('meta[name="theme-color"]');
    if (meta) meta.content = dark ? '#000000' : '#f2f2f7';
  }
  applyTheme();
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
</script>

The theme-color tag tints the phone's status bar, so its two colors match your two --bg values. The menu saves the choice and calls the same function:

<label>Theme
  <select id="theme">
    <option value="system">System</option>
    <option value="light">Light</option>
    <option value="dark">Dark</option>
  </select>
</label>
<script>
  const themePick = document.getElementById('theme');
  themePick.value = readPrefs().theme || 'system';
  themePick.addEventListener('change', () => {
    const prefs = readPrefs();
    prefs.theme = themePick.value;
    try { localStorage.setItem('myapp.prefs', JSON.stringify(prefs)); } catch (e) {}
    applyTheme();
  });
</script>

Print stays light on purpose, or a report printed from dark mode comes out dark. This goes below the dark block, so it wins:

@media print {
  :root[data-theme] { color-scheme: light; --bg: #ffffff; --card: #ffffff; --ink: #000000; --muted: #444444; }
}

Check it yourself in Chrome or Edge on a computer (Safari hides these tools until you switch them on; phones can't do this):

  1. Open your app at http://localhost:3000 (or the address npx serve printed) and press F12 (Mac: Cmd+Option+I) for DevTools, the browser's built-in developer tools. Click inside DevTools once, so the shortcuts below reach it.
  2. With your menu on System, press Ctrl+Shift+P (Mac: Cmd+Shift+P), type dark and pick Emulate CSS prefers-color-scheme: dark. The page should go dark with no light boxes.
  3. Open the Elements tab: the <html> line should include data-theme="dark".
  4. Press Ctrl+P (Mac: Cmd+P) for print preview. It should be light.

⚠️ Don't test by picking Dark in your own menu: that writes the attribute itself and hides exactly First Bites' bug. Test like a phone: menu on System, system set to dark.

🦥 Skipped: a copy of the dark values in @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }, which First Bites keeps for the instant before its script runs. Add it if you ever see a flash of light before dark.

3

Phones first: measure, don't assume

The List, my explorer for a New York property-tax roll, looked fine on my laptop. At 390 pixels wide, its map was 154 pixels wide behind its legend, and the table was cut off mid-column. One media query for screens up to 720 pixels fixed most of it: stack everything, turn the sidebar into a sheet (a panel that slides in) opened by a "Filters (n)" button, give the map 34% of the screen's height, hide the legend, and show three key table columns. This is its shape, with The List's class names (use yours); it goes last in your CSS, so it wins:

@media (max-width: 720px) {
  .layout { display: block; }           /* stack instead of side by side */
  .map { height: 34vh; }                /* vh = 1% of the screen's height */
  .legend, .hide-sm { display: none; }  /* desktop-only: add class="hide-sm" */
}

See it at 390, by hand

In Chrome or Edge with DevTools open (click inside it first), press Ctrl+Shift+M (Mac: Cmd+Shift+M) for the device toolbar above the page. Choose Responsive in its first menu and type 390 and 844 in its two boxes. Try to scroll sideways (Shift plus the mouse wheel): if anything moves, something is too wide.

💡 For a real phone: npx serve also prints a Network address that a phone on the same Wi-Fi can open (if Windows asks, let Node use private networks).

Measure it with check.mjs

Eyes miss things, so measure: sideways scrolling, tap targets (anything a finger presses) under 44 by 44 pixels, and text contrast under 4.5:1.

What's a contrast ratio, and what's WCAG AA?

Contrast ratio compares how light text is against its background, from 1:1 (invisible) to 21:1 (black on white). WCAG is the web's accessibility standard; its AA level asks for 4.5:1 for normal text, and 3:1 for large text, outlines and icons.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouExtend check.mjs: at 390×844 as a phone, in light and in system dark with my menu on System, fail if the page is wider than the screen, list every button, select and link-button under 44×44px, and print the contrast ratio of each text-and-background token pair, flagging any under 4.5:1.
ClaudeNo sideways scrolling in either theme. Under 44px: the banner's ✕ (24×24) and the "Undo" link (58×20, a plain inline link). One failing pair: --muted on --bg in light, 3.9:1. Shall I fix those three?↳ Edit · check.mjs↳ Bash · node check.mjs

What's happening underneath: the script sets a phone-sized screen and a dark system, then asks the page. Is it wider than the screen (scrollWidth bigger than clientWidth)? How big is each button really (getBoundingClientRect, the browser's own measurement)? It avoids window.innerWidth, which headless browsers sometimes misreport. Run node check.mjs yourself in a free terminal tab (Terminal → New Terminal), and confirm a pair in WebAIM's contrast checker.

The fixes that matter most

  • Contrast. First Bites' white text on a light coral button failed AA; the fix was a darker accent for filled buttons plus a token for their text, step 1's --accent-ink and --on-accent. White on Apple's #007AFF measures 4.02:1, so links and filled buttons use #0062cc (5.80:1); in dark mode their text flips to black, since white on #0A84FF is 3.65:1. Grey text chosen by eye often fails.
  • 44-pixel targets. First Bites' icons were smaller than that; the --tap token (code below) sets every button's minimum.
  • A small type scale (a short list of named font sizes). First Bites' body text was extra heavy; now it's weight 400 to 500, heavy only for numbers and titles. The List uses 12 to 13px for dense tables, 15px for reading, nothing smaller.
  • A visible focus outline. Press Tab repeatedly: each control should show a clear ring, the way keyboard users get around.
  • A label on every control. A dropdown without a visible label gets aria-label="Your state", the name a screen reader (software that reads pages aloud) announces. My price tool added that in one review pass, plus inputmode="numeric" (the number keypad for ZIP codes) and aria-live="polite", which makes the reader announce results that update in place.
  • A floating button that never covers the last row. First Bites' floating add button sat on the last item, so it couldn't be tapped; bottom padding equal to the nav bar plus the button fixed it.
button, select, .btn { min-height: var(--tap); min-width: var(--tap); }
.btn { display: inline-flex; align-items: center; justify-content: center; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
body { overflow-x: clip; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }

Three traps that look like bugs

  • min-height does nothing on a plain link. Links are inline (they flow like words in a sentence) and ignore height; an audit found my 44-pixel rule had no effect on link-style buttons. Give them class="btn": its display: inline-flex makes them obey.
  • width: 100vw adds a sideways scrollbar. vw is 1% of the window's width, scrollbar included, so the element ends up wider than the page; an audit caught a full-width nav bar doing this to every page. Use width: 100%, with overflow-x: clip on body as a safety net. Clip, not hidden: hidden can stop a sticky header (one that stays put as you scroll) from sticking.
  • Tabular or monospace figures on a big number. They give every character, even the comma, the same width so columns line up. The List's 28px headline count read "31 , 541", like a typo. Keep them for table columns: the td.num line.

Never greet visitors with an empty form

Visitors leave a blank tool without trying it, so the first screen should already show the product working.

  • Pre-load an example. A private variant of my price tool opens with one popular procedure already loaded, and "popular searches" buttons as the empty state. Rare settings fold into a <details> section (HTML's built-in fold-out: tap its summary line to open it), and everyday words like "brain MRI" find the billing code.
  • A front page for a bare visit. Type The List's address alone and you get a short front page: the headline count, one big search box, an "Explore the map" link and the two key findings. A shared link carries its view in URL parameters (the part of an address after ?, like ?q=mri) and skips straight into the full app. The trap: code that writes default parameters into the address first makes every visit look shared, so check for parameters before anything else.
  • Simple by default, "Full data" one click away. The List's top bar went from 8 controls with insider labels to 4, with the rest behind one "Full data" switch that remembers your choice. Nothing was removed, only folded. "Threshold bucket" became "Which rule applies": plain English first, expert detail second.
  • Widgets for someone who has never used one. A box on one of my pages began as an empty fake terminal only programmers would use. Now it starts filled with a working example beside a real Run button, and visitor text is never run as code (no eval()), only matched against a short allowlist of commands.

Ask Claude: "With no URL parameters, show a front page: the headline number, a search box and three popular-search buttons that run a search. Any parameter goes straight to the full app. Read the parameters before anything writes defaults, and write none to the URL while the front page shows." Then open the bare address and reload twice: the front page should still be there.

4

Design every state

Real apps spend a lot of time somewhere other than "everything loaded", and each state (what the screen shows in one situation) deserves a design. Habooby, my Phoenix dust and air-quality map, replaced an opaque loading screen with these:

StateWhat the visitor sees
LoadingThe map at once, a thin progress line, and "Still loading…" after 6 seconds
Stale"as of 2:40 PM" once the data is over 45 minutes old
Failed feedWhat failed, in a sentence, plus a Retry button
Outside coverageA note outside the county its rain gauges cover
Good news"No dust advisories right now"
EmptyAn example, never a blank form
Offline"You're offline. Showing what you last loaded."

The first five rows are Habooby's; most apps need the last two as well.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouDesign every state: page frame first with a thin progress line, "Still loading…" after 6 seconds, an "as of" time when data is over 45 minutes old, a sentence plus Retry when a fetch fails, an offline note and a friendly empty state. Add a ?debugState= switch that forces each, and screenshot each at 390px.
ClaudeSix states, forced with ?debugState=loading, slow, stale, error, offline or empty; the screenshots are in shots/states/. Open them and check each says what's happening and what to do next.↳ Edit · index.html↳ Bash · node check.mjs

What's happening underneath: each state is a branch in the code that draws your page. The switch, new URLSearchParams(location.search).get('debugState') from Check the AI's work, shows rare states on demand instead of waiting for an outage.

Ask for location after saying why

Habooby used to ask for your location on load. Most people refuse a cold prompt, and some leave. Now it explains the benefit, asks only when you tap "Use my location", and quietly reuses an earlier yes. Removing the automatic prompt also hid a stats card, and a user asked "where did the left panel go?" It's back, opening on its own only when permission is already granted, so it never triggers a prompt.

Lead with one hero number

My price tool, the Medical Price & Bill Coach, first buried its useful number among statistics. Every result card now leads with one action, "Ask to pay $X: about 2x what Medicare pays", plus a Copy button, plain labels ("Medicare benchmark" became "What Medicare pays") and the negotiation scripts shown by default.

Teaching and feedback that's kind

  • A loop doesn't teach; numbered frames do. My verdict on Baby Signs' first animated signs: "just okay… I don't think they'll work to teach." Each sign now shows a start and an end frame, captioned 1 and 2 to match its two written steps, with the animation tucked behind "Watch it move".
  • Three things today, not the whole catalog. Baby Signs introduces at most three new signs at a time. Once the code's cap was 2 while the welcome text promised three, and the live smoke test (a run-through of the real site after a deploy) caught it. Now one constant, NEW_AT_ONCE = 3, drives the logic and the sentence.
  • Partial credit, forgiving drills, quiet warnings. Acento colors each syllable green, amber (one letter off: half credit) or red, passes at a friendly 85%, and lets the learner make the final tap; its typing drills forgive missing accents. A user found First Bites' warnings "a bit overwhelming", so a Quiet mode folds the secondary ones into one-line notes, while the first-run disclaimer, the safety page and the choking and infant-CPR guide stay unconditional.
  • When v1 looks too simple, build a model. Baby Signs v1 drew each finger as one straight line, so an "O" hand was a ring, and I rejected it. v2 models a hand (four jointed fingers, a two-part thumb, a palm, a wrist), so all 15 handshapes come from the same code, and its fingers fold in parallel, like real ones.
5

Small polish that reads as care

A favicon you can tell apart. Nearly every one of my apps once shipped the same recolored icon, and two had none, so their tabs showed the browser's generic globe. At 16 pixels, the same shape in a new color reads as the same icon. Now each app gets a rounded square in its own accent with one bold white symbol, written into the page as an SVG data URI, so there's no image file.

What are a favicon and a data URI?

A favicon is the tiny icon in the browser tab. A data URI is a whole file written into an address starting with data:, so there's nothing extra to download.

<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230062cc'/%3E%3Ctext x='32' y='44' font-family='system-ui,sans-serif' font-size='36' font-weight='700' text-anchor='middle' fill='%23fff'%3EM%3C/text%3E%3C/svg%3E">

Replace the M with your app's letter and 0062cc with your accent, keeping the %23 in front: a raw # starts a URL fragment (the end part of an address) and breaks the icon. Then look at it at real size in the browser tab: one of mine, a kettlebell, read as a padlock at 16 pixels.

Animation that rests. The radar sweep on traqqit.com, my apps' home page, pauses offscreen and in hidden tabs, and visitors whose system asks for reduced motion (a setting for people sensitive to movement) get a still frame. Heavy pages deserve a Low graphics option too: a 3D browser game of mine looked like it was skipping on weaker laptops, and your own machine flatters you.

Ask Claude: "Make every animation pause offscreen and in hidden tabs, and show a still frame when the system asks for reduced motion." To check, open DevTools' Command Menu as in step 2, type reduced, pick Emulate CSS prefers-reduced-motion: reduce and reload: the animation should stop on a still frame.

6

Let AI review; you decide

Claude is a tireless reviewer and a poor decider. Three habits keep it useful:

  • A brief and a do-not-touch list. Habooby's polish pass covered mobile ergonomics, first-run and data states, and copy. Its data pipelines, color ramps and basemap were off-limits in writing, and it shipped without breaking the data side.
  • Several designs, fresh judges, then an attack. traqqit.com's design came from 4 genuinely different directions and 3 judges in fresh sessions (new Claude conversations that haven't seen your discussion), then an attack on the winner for mobile, accessibility, performance, honest copy and polish. Show the judges phone screenshots too, and copy the old page first (cp index.html index.old.html) so rollback is the same command with the names swapped.
  • A "deliberately not done" list. A Claude review of The List brought the front page and fewer controls, but it also called the green "success" color pointless. I kept it, because "not on the roll" genuinely is good news, and wrote down why, so the next session doesn't suggest it again.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouReview the design from the screenshots in shots/. Scope: mobile layout, empty and error states, wording. Don't touch the data files, engine.js or the map colors, and skip anything under "Deliberately not done" in NOTES.md. Numbered list: problem, fix, effort.
ClaudeNine suggestions, cheapest first, all in scope and none from your list. The biggest are on phones: #1, the Retry button looks too small to tap; #2, the "as of" time hides under the floating button.↳ Read · NOTES.md↳ Read · shots/*.png

What's happening underneath: Claude judges only what you show it and can't tell taste from rules. Sort its list into do and don't, a reason for each, and add the don'ts to NOTES.md, the notes file Claude reads each session (from Work with Claude Code):

## Deliberately not done
- YYYY-MM-DD Kept the green "all clear" color. The review called it pointless; for my users it's good news, and green says so.

If you send one prompt from this lesson, make it this: "Make every color a token on :root with System, Light and Dark that always sets data-theme, then screenshot every screen at 390px in both themes." Underneath, one attribute on <html> swaps every token at once.

Your turn

  • Ask Claude to search your CSS for hex colors. Done when every match sits inside the :root blocks.
  • With your menu on System, emulate dark in DevTools. Done when <html> shows data-theme="dark", no screen has a light box, and print preview is light.
  • Run node check.mjs. Done when, at 390×844 in both themes, it finds no sideways scrolling, no control under 44px and no token pair under 4.5:1.
  • Press Tab through every screen. Done when each control shows a visible ring and every input and menu has a label.
  • Force each state with ?debugState=. Done when the loading, empty, stale, offline and error screenshots each say what's happening and what to do next.
  • Run one scoped design review. Done when NOTES.md has a rejected suggestion and its reason under "Deliberately not done".

Next, Maps, charts and dashboards: your data on a map or a chart without a framework, and without getting rate-limited. Your tokens and 390-pixel checks come along.