# Web UI Patterns

Concrete, copy-paste UI recipes for HTML/CSS/JS web apps. `web-app-basics` covers the principles (build incrementally, go one step past the bare ask, don't depend on a runtime CDN); this skill gives you the snippets. Paste them into `src/css/styles.css` / `src/js/main.js`, swap the token names for your own if you already have a system, and adjust to taste. The user can override anything - these are sensible defaults, not rules.

> Scope: web only (HTML/CSS/JS). Native mobile would use different patterns.

## The default Gipity look

Most AI-generated apps default to the same generic purple-on-white gradient. Don't. **When the user hasn't specified a palette, start from the Gipity theme below** - a warm cream canvas, near-black text, white cards, and a single orange accent. It's distinctive, calm, and reads as a real product rather than a template. The user can override any token.

> **On the `web-simple` / `web-fullstack` templates, DON'T paste this block - the tokens already exist.** Those templates ship `gipity-theme.css` (loaded before your `styles.css`), which defines the full set: colors `--primary --primary-hover --surface --surface-alt --border --text-main --text-bright --text-muted --code`, spacing `--space-xs|sm|md|lg|xl`, type sizes `--text-xs|sm|base|lg|xl`, and `--radius --shadow`. Reference those names directly (e.g. `font-size: var(--text-lg)`). The names differ slightly from the standalone block below (theme spacing is `--space-md`, not `--space-3`; theme uses `--text-main` for body color). Pasting a second `:root` here just shadows the brand - skip it.

### Themes: dark / light / blue (Water.css templates)

`gipity-theme.css` ships **three themes** keyed on the `data-theme` attribute of `<html>`: **dark** (default), **light**, and **blue**. Because every color is a CSS variable, switching themes just swaps the palette - your app's own `var(--…)`-based styles come along for free. Don't hard-code colors, or they won't follow the theme.

- **Set a fixed theme:** hardcode it in the HTML - `<html lang="en" data-theme="light">`.
- **Switch at runtime (persisted, no flash):** call the SDK - `Gipity.setTheme('light')` (or `'dark'`/`'blue'`); `Gipity.getTheme()` reads the current one. The template head already carries a tiny pre-paint script that applies the saved choice before styles load, so reloads don't flash. This is how you'd wire a theme toggle button.
- **Custom brand accent (any theme):** `Gipity.theme({ primary: '#7c3aed', primaryHover: '#8b5cf6', radius: '12px' })` overrides just those tokens inline - use it to match a user's brand color without leaving the themed surfaces behind.

The SDK helpers come from the `gipity.js` tag every template already loads; no import or setup needed. (Non-Water apps using the standalone `:root` block below get theming the same way if they also define `[data-theme="light"]`/`[data-theme="blue"]` overrides.)

For an app on a non-Water.css base (no `gipity-theme.css`), paste this `:root` block and baseline at the top of `src/css/styles.css`, then reference the tokens everywhere (never hard-code a color, size, or radius):

```css
:root {
  /* Gipity palette - the brand defaults */
  --gip-cream:  #f0efee;  /* rgb(240,239,238) - page canvas */
  --gip-black:  #141415;  /* rgb(20,20,21)    - text */
  --gip-white:  #fafafa;  /* rgb(250,250,250) - cards / surfaces */
  --gip-orange: #fea60b;  /* rgb(254, 166, 11)  - the one accent */

  /* Semantic tokens - reference THESE in your CSS, not the raw palette */
  --bg:               var(--gip-cream);
  --surface:          var(--gip-white);
  --text:             var(--gip-black);
  --muted:            rgba(20, 20, 21, 0.56);   /* secondary text, timestamps */
  --border:           rgba(20, 20, 21, 0.12);   /* hairlines, card edges */
  --primary:          var(--gip-orange);
  --primary-contrast: var(--gip-white);          /* text on an orange fill */

  /* Type - a clean system stack (no runtime font CDN; vendor a font if you want more identity) */
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* Five sizes only - pick from these, don't invent new ones */
  --text-xs: 12px; --text-sm: 14px; --text-base: 16px; --text-lg: 20px; --text-xl: 28px;

  /* Spacing + shape */
  --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px;
  --radius: 10px;
}

/* Keep the hidden attribute working even where CSS sets display (see
   "Showing and hiding elements" below) */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--text-base);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--primary); }

/* The primary action - solid orange. Keep it to one per screen. */
.btn-primary {
  background: var(--primary);
  color: var(--primary-contrast);
  border: none;
  border-radius: var(--radius);
  padding: var(--space-3) var(--space-4);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.btn-primary:hover { filter: brightness(0.95); }
```

Dark variant (if the user asks for dark): keep the same palette, just swap `--bg: var(--gip-black)`, `--surface: #1d1d1f`, `--text: var(--gip-white)`, and bump `--muted`/`--border` to `rgba(250,250,250, …)`. Orange stays the accent either way.

## Showing and hiding elements (the `hidden` trap)

The HTML `hidden` attribute (`el.hidden = true` in JS) only works while no author CSS sets `display` on that element. Author styles beat the browser's built-in `[hidden] { display: none }`, so this silently breaks:

```css
#tap-overlay { position: fixed; inset: 0; display: flex; ... }  /* always wins */
```
```html
<div id="tap-overlay" hidden>...</div>  <!-- hidden does nothing: overlay shows forever -->
```

Every `el.hidden = true/false` toggle on that element is a no-op. The failure mode is brutal for fullscreen overlays and loading placeholders: a "tap to continue" scrim that covers the whole app and can never be dismissed, or a "still loading…" message that stays after the content arrives. **The page still renders and the console is clean, so `page inspect` won't catch it - screenshot the toggled state to verify.**

Apps built on the Water.css templates (`web-simple`, `web-fullstack`) are protected: `gipity-theme.css` ships `[hidden] { display: none !important; }`, so `display` rules and `hidden` toggles coexist safely. In any app without that guard, add it as the first rule of `styles.css`, or scope each display rule so `hidden` keeps working:

```css
[hidden] { display: none !important; }            /* the one-line guard, OR: */
#tap-overlay:not([hidden]) { display: flex; }     /* per-rule scoping */
```

## Lists and feeds of user entries

Submit-and-list apps (notes, comments, reviews, a guestbook) live or die on the list UX. A bare `<ul>` of `toLocaleString()` timestamps with no submit feedback reads as unfinished. Defaults to reach for:

- **Render entries as separated cards, not a bare list.** Each entry gets its own surface so it reads as a distinct item.

```css
.entry { padding: var(--space-3) var(--space-4); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: var(--space-3); }
.entry-meta { color: var(--muted); font-size: var(--text-xs); margin-top: var(--space-2); }
.empty-state { color: var(--muted); text-align: center; padding: var(--space-5); }
```

```js
const escapeHtml = s => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

function renderEntries(list, entries) {
  if (entries.length === 0) {
    list.innerHTML = `<p class="empty-state">No entries yet - be the first to post.</p>`;
    return;
  }
  list.innerHTML = entries.map(e => `
    <article class="entry" data-testid="entry">
      <div class="entry-body">${escapeHtml(e.text)}</div>
      <div class="entry-meta">${relativeTime(e.createdAt)}</div>
    </article>`).join('');
}
```

- **Relative time, not a raw timestamp.** "just now" / "2 min ago" reads better than `2026-05-31 14:03:22`; fall back to a date for anything older than a day.

```js
function relativeTime(ts) {
  const s = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
  if (s < 60) return 'just now';
  if (s < 3600) return `${Math.floor(s / 60)} min ago`;
  if (s < 86400) return `${Math.floor(s / 3600)} hr ago`;
  return new Date(ts).toLocaleDateString();
}
```

- **Live character counter whenever an input has a `maxlength`.** Show the count so the limit isn't a surprise on submit.

```js
const input = document.querySelector('[data-testid="entry-input"]');
const counter = document.querySelector('[data-testid="char-count"]');
const max = Number(input.getAttribute('maxlength'));
input.addEventListener('input', () => { counter.textContent = `${input.value.length} / ${max}`; });
```

- **Confirm a successful submit.** After a post lands, clear the input and show a brief, self-dismissing confirmation instead of leaving the user guessing whether it worked.

```js
function toast(msg) {
  const t = document.querySelector('[data-testid="toast"]');
  t.textContent = msg;
  t.classList.add('show');
  setTimeout(() => t.classList.remove('show'), 2000);
}
```

User text goes straight into the DOM, so always escape it (`escapeHtml` above) or use `textContent` - never drop raw input into `innerHTML`.

## Forms

**Label every control.** Each `<input>`/`<select>`/`<textarea>` needs a visible `<label for="id">` (or wrap the control) - not just a `placeholder`. Placeholders vanish on focus, read poorly to screen readers/autofill, and non-text inputs like `datetime-local` can't show one at all. Label says *what to enter* ("Event date & time"); placeholder is a *format example* ("e.g. Team standup").

**Mirror JS validation with native constraints** so the browser blocks bad values before submit instead of erroring after. Keep the JS check as source of truth; the constraint is the first line of defense.

- **Future-only dates**: set `min` to now, and refresh on `focus` since "now" drifts after load.
- **Bounded numbers**: `min`/`max` (plus `step` for whole/fixed increments).
- **Required fields**: add `required` so the browser blocks an empty submit.

```html
<label for="event-when">Event date &amp; time</label>
<input id="event-when" type="datetime-local" required>
```
```js
const when = document.getElementById('event-when');
const setMin = () => { when.min = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16); };
setMin();
when.addEventListener('focus', setMin);   // refresh so "now" doesn't go stale
```

## Formatting money and numbers

Never build a total/price/amount with `'$' + n.toFixed(2)`. It hardcodes the dollar sign, drops grouping separators (`1234.5` → `$1234.50`, not `$1,234.50`), forces a dot decimal mark even where the locale uses a comma, and always puts the symbol on the left even where it belongs after the number. Use `Intl.NumberFormat`:

```js
const money = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' });
money.format(12.5);      // "$12.50"
money.format(1234.5);    // "$1,234.50"
```

`undefined` locale = follow the user's browser. `currency` is required for `style: 'currency'`. For plain counts use `new Intl.NumberFormat()` (no options) so large numbers still get grouping.

Let non-USD users pick a currency - reuse their choice for every formatter:

```js
let currency = 'USD';
document.querySelector('[data-testid="currency"]').addEventListener('change', e => {
  currency = e.target.value;   // 'EUR', 'JPY', 'GBP', …
  render();                     // re-render amounts with new Intl.NumberFormat(undefined, { style: 'currency', currency })
});
```

Right-align amounts and add `font-variant-numeric: tabular-nums` so digits share a fixed width and decimal points line up in a results column:

```css
.amount { text-align: right; font-variant-numeric: tabular-nums; }
```

## Copy to clipboard

A copy button that just calls `navigator.clipboard.writeText` silently does nothing on insecure contexts (no HTTPS, so `navigator.clipboard` is undefined) or when the call rejects - the user taps it and nothing copies, with no feedback. A button that appears broken is worse than no button. Always pair the modern API with visible feedback and a select-text fallback:

```js
async function copyToClipboard(text, valueEl, statusEl) {
  try {
    await navigator.clipboard.writeText(text);
    statusEl.textContent = 'Copied!';
  } catch {
    // Insecure context (no HTTPS) or permission denied: select the value so the user can copy it by hand
    const sel = window.getSelection();
    const range = document.createRange();
    range.selectNodeContents(valueEl);
    sel.removeAllRanges();
    sel.addRange(range);
    statusEl.textContent = 'Tap and hold to copy';
  }
}
```

`valueEl` is the element displaying the value (so the fallback can pre-select it), `statusEl` is a small toast or inline span for the message. The user always gets feedback - `Copied!` on success, a clear `Tap and hold to copy` prompt with the value already selected on failure - so the action never looks broken.

## Adding more patterns

This skill is the home for concrete, reusable web UI recipes - if a build keeps hitting the same polish gap (modals, tabs, loading/skeleton states, form validation, pagination), add the snippet here rather than bloating `web-app-basics`. Keep each entry a short principle plus the smallest copy-paste snippet that demonstrates it.

