{
  "name": "web-ui-patterns",
  "title": "Web UI Patterns",
  "description": "Concrete copy-paste web UI recipes - the default Gipity look (colors, fonts, type scale), safe show/hide toggling (the hidden-attribute vs CSS display trap), entry lists/feeds (cards, relative time, char counter, submit confirmation), and robust copy-to-clipboard. Load when building any web UI beyond a bare form. Web/HTML-CSS-JS only; not for native mobile.",
  "guid": "sk_plat_wuip",
  "category": "App development",
  "requiredTools": [
    "file_write"
  ],
  "content": "# Web UI Patterns\n\nConcrete, 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.\n\n> Scope: web only (HTML/CSS/JS). Native mobile would use different patterns.\n\n## The default Gipity look\n\nMost 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.\n\n> **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.\n\n### Themes: dark / light / blue (Water.css templates)\n\n`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.\n\n- **Set a fixed theme:** hardcode it in the HTML - `<html lang=\"en\" data-theme=\"light\">`.\n- **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.\n- **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.\n\nThe 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.)\n\nFor 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):\n\n```css\n:root {\n  /* Gipity palette - the brand defaults */\n  --gip-cream:  #f0efee;  /* rgb(240,239,238) - page canvas */\n  --gip-black:  #141415;  /* rgb(20,20,21)    - text */\n  --gip-white:  #fafafa;  /* rgb(250,250,250) - cards / surfaces */\n  --gip-orange: #fea60b;  /* rgb(254, 166, 11)  - the one accent */\n\n  /* Semantic tokens - reference THESE in your CSS, not the raw palette */\n  --bg:               var(--gip-cream);\n  --surface:          var(--gip-white);\n  --text:             var(--gip-black);\n  --muted:            rgba(20, 20, 21, 0.56);   /* secondary text, timestamps */\n  --border:           rgba(20, 20, 21, 0.12);   /* hairlines, card edges */\n  --primary:          var(--gip-orange);\n  --primary-contrast: var(--gip-white);          /* text on an orange fill */\n\n  /* Type - a clean system stack (no runtime font CDN; vendor a font if you want more identity) */\n  --font-sans: system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\n  /* Five sizes only - pick from these, don't invent new ones */\n  --text-xs: 12px; --text-sm: 14px; --text-base: 16px; --text-lg: 20px; --text-xl: 28px;\n\n  /* Spacing + shape */\n  --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px;\n  --radius: 10px;\n}\n\n/* Keep the hidden attribute working even where CSS sets display (see\n   \"Showing and hiding elements\" below) */\n[hidden] { display: none !important; }\n\nbody {\n  margin: 0;\n  background: var(--bg);\n  color: var(--text);\n  font-family: var(--font-sans);\n  font-size: var(--text-base);\n  line-height: 1.5;\n  -webkit-font-smoothing: antialiased;\n}\n\na { color: var(--primary); }\n\n/* The primary action - solid orange. Keep it to one per screen. */\n.btn-primary {\n  background: var(--primary);\n  color: var(--primary-contrast);\n  border: none;\n  border-radius: var(--radius);\n  padding: var(--space-3) var(--space-4);\n  font: inherit;\n  font-weight: 600;\n  cursor: pointer;\n}\n.btn-primary:hover { filter: brightness(0.95); }\n```\n\nDark 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.\n\n## Showing and hiding elements (the `hidden` trap)\n\nThe 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:\n\n```css\n#tap-overlay { position: fixed; inset: 0; display: flex; ... }  /* always wins */\n```\n```html\n<div id=\"tap-overlay\" hidden>...</div>  <!-- hidden does nothing: overlay shows forever -->\n```\n\nEvery `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.**\n\nApps 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:\n\n```css\n[hidden] { display: none !important; }            /* the one-line guard, OR: */\n#tap-overlay:not([hidden]) { display: flex; }     /* per-rule scoping */\n```\n\n## Lists and feeds of user entries\n\nSubmit-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:\n\n- **Render entries as separated cards, not a bare list.** Each entry gets its own surface so it reads as a distinct item.\n\n```css\n.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); }\n.entry-meta { color: var(--muted); font-size: var(--text-xs); margin-top: var(--space-2); }\n.empty-state { color: var(--muted); text-align: center; padding: var(--space-5); }\n```\n\n```js\nconst escapeHtml = s => s.replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\n\nfunction renderEntries(list, entries) {\n  if (entries.length === 0) {\n    list.innerHTML = `<p class=\"empty-state\">No entries yet - be the first to post.</p>`;\n    return;\n  }\n  list.innerHTML = entries.map(e => `\n    <article class=\"entry\" data-testid=\"entry\">\n      <div class=\"entry-body\">${escapeHtml(e.text)}</div>\n      <div class=\"entry-meta\">${relativeTime(e.createdAt)}</div>\n    </article>`).join('');\n}\n```\n\n- **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.\n\n```js\nfunction relativeTime(ts) {\n  const s = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);\n  if (s < 60) return 'just now';\n  if (s < 3600) return `${Math.floor(s / 60)} min ago`;\n  if (s < 86400) return `${Math.floor(s / 3600)} hr ago`;\n  return new Date(ts).toLocaleDateString();\n}\n```\n\n- **Live character counter whenever an input has a `maxlength`.** Show the count so the limit isn't a surprise on submit.\n\n```js\nconst input = document.querySelector('[data-testid=\"entry-input\"]');\nconst counter = document.querySelector('[data-testid=\"char-count\"]');\nconst max = Number(input.getAttribute('maxlength'));\ninput.addEventListener('input', () => { counter.textContent = `${input.value.length} / ${max}`; });\n```\n\n- **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.\n\n```js\nfunction toast(msg) {\n  const t = document.querySelector('[data-testid=\"toast\"]');\n  t.textContent = msg;\n  t.classList.add('show');\n  setTimeout(() => t.classList.remove('show'), 2000);\n}\n```\n\nUser text goes straight into the DOM, so always escape it (`escapeHtml` above) or use `textContent` - never drop raw input into `innerHTML`.\n\n## Forms\n\n**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\").\n\n**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.\n\n- **Future-only dates**: set `min` to now, and refresh on `focus` since \"now\" drifts after load.\n- **Bounded numbers**: `min`/`max` (plus `step` for whole/fixed increments).\n- **Required fields**: add `required` so the browser blocks an empty submit.\n\n```html\n<label for=\"event-when\">Event date &amp; time</label>\n<input id=\"event-when\" type=\"datetime-local\" required>\n```\n```js\nconst when = document.getElementById('event-when');\nconst setMin = () => { when.min = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16); };\nsetMin();\nwhen.addEventListener('focus', setMin);   // refresh so \"now\" doesn't go stale\n```\n\n## Formatting money and numbers\n\nNever 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`:\n\n```js\nconst money = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' });\nmoney.format(12.5);      // \"$12.50\"\nmoney.format(1234.5);    // \"$1,234.50\"\n```\n\n`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.\n\nLet non-USD users pick a currency - reuse their choice for every formatter:\n\n```js\nlet currency = 'USD';\ndocument.querySelector('[data-testid=\"currency\"]').addEventListener('change', e => {\n  currency = e.target.value;   // 'EUR', 'JPY', 'GBP', …\n  render();                     // re-render amounts with new Intl.NumberFormat(undefined, { style: 'currency', currency })\n});\n```\n\nRight-align amounts and add `font-variant-numeric: tabular-nums` so digits share a fixed width and decimal points line up in a results column:\n\n```css\n.amount { text-align: right; font-variant-numeric: tabular-nums; }\n```\n\n## Copy to clipboard\n\nA 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:\n\n```js\nasync function copyToClipboard(text, valueEl, statusEl) {\n  try {\n    await navigator.clipboard.writeText(text);\n    statusEl.textContent = 'Copied!';\n  } catch {\n    // Insecure context (no HTTPS) or permission denied: select the value so the user can copy it by hand\n    const sel = window.getSelection();\n    const range = document.createRange();\n    range.selectNodeContents(valueEl);\n    sel.removeAllRanges();\n    sel.addRange(range);\n    statusEl.textContent = 'Tap and hold to copy';\n  }\n}\n```\n\n`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.\n\n## Adding more patterns\n\nThis 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.\n"
}
