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-fullstacktemplates, DON'T paste this block - the tokens already exist. Those templates shipgipity-theme.css(loaded before yourstyles.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-mainfor body color). Pasting a second:roothere 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):
: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:
#tap-overlay { position: fixed; inset: 0; display: flex; ... } /* always wins */
<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:
[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.
.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); }
const escapeHtml = s => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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.
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.
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.
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
minto now, and refresh onfocussince "now" drifts after load. - Bounded numbers:
min/max(plusstepfor whole/fixed increments). - Required fields: add
requiredso the browser blocks an empty submit.
<label for="event-when">Event date & time</label>
<input id="event-when" type="datetime-local" required>
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:
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:
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:
.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:
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.