9.0 KiB
9.0 KiB
| name | description |
|---|---|
| single-file-webapp | Patterns for browser apps delivered as ONE self-contained HTML file (ES modules from a CDN import map, UI wiring, state, save/load JSON, file import/export, undo, test hooks, compact mobile-friendly layout) plus a static checker. Use for any "build a tool/editor/game in a single HTML file" task, before writing the first line. |
Single-file web app
Structure
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>β¦</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<style>/* layout: CSS grid with named areas (menu / left / main / right) */</style>
<script type="importmap">{ "imports": { "lib": "https://cdn.jsdelivr.net/npm/lib@X.Y.Z/..." } }</script>
</head><body>
<!-- markup with ids for everything JS touches -->
<script type="module">
import β¦ from 'lib';
const state = { objects: [], selection: new Set(), nextId: 1 };
// --- helpers, model, render, UI wiring, test hook --- (in this order)
</script></body></html>
Pin CDN versions (@0.160.0, never @latest). Keep everything in the file; no
build step, no npm for the app itself.
The five rules that prevent the usual bugs
- No inline handlers with a module script.
onclick="save()"cannot see functions declared inside<script type="module">("save is not defined"). Wire every control withdocument.getElementById('save').addEventListener('click', save). Assignwindow.x = β¦only for the test hook, and before anything uses it. - One state object, one render. All data lives in
state;render()redraws from it. Mutations go through small functions (addObject,remove,group) that end withrender(). Never keep the truth in the DOM. - Serialize data, not live objects (full recipe, checker and tests: the
save-load-jsonskill - load it for any save/load/export feature).toJSON(state)returns plain objects (ids, type, params, transform, children for groups - recursively);fromJSON(data)rebuilds. Round-trip test:fromJSON(toJSON(s))equalss. Groups/nesting are the classic thing to forget - write them first. - Files: export with
download(name, text)(Blob +<a download>), import with<input type="file">+FileReader. Exporters are plain functions that RETURN the data (toSTL(),toJSON()) and the button handler is exactlydownload('scene.json', JSON.stringify(toJSON(), null, 2))- never a second, hand-written serializer in the handler. Importers likewise: the file handler only reads the file and callsimportSTL(buffer)/importSVG(text), which create objects through the normal constructor path. Test import/export via the buttons (h.upload/h.downloadin the browser-test skill). - Test hook at the end of the module - the browser-test skill drives and
asserts through it, so include at least:
Tests set up state throughwindow.__app = { state, // live state object add: (type, params) => addObject(type, params), // returns the new object's id select: ids => { state.selection = new Set(ids); render(); updatePanel(); }, project: id => { /* world position of object -> {x, y} page pixels */ }, toJSON, fromJSON, toSTL, // pure data functions };add/select, use real clicks only to test clicking itself, and never rely on guessed pixel coordinates.
Reference snippets for all of the above (download, file input, undo via JSON snapshots, keyboard shortcuts, selection with shift, grid layout): patterns.md.
Layout: compact, mobile first
Design for a phone (~360-400 px wide) first, then let it grow on wide screens. The screen is small and the thumb is big: every pixel must earn its place. Snippets: "Mobile layout" in patterns.md.
Menus and buttons
- Symbols instead of words. Toolbar buttons show an icon/emoji or a short
symbol (πΎ save, π open, βΆ undo, β· redo, οΌ add, π delete, β settings, β° menu,
β€’ fit/fullscreen); put the full name in
titleandaria-label. Use plain words only for the one or two primary actions, or when no clear symbol exists. - Few top-level buttons. More than ~6 actions: group them behind one
β°/β―button that opens a small dropdown. Never let the toolbar wrap onto a second row; make it one row that scrolls horizontally (overflow-x:auto). - Touch targets at least 40x40 px (
min-width/min-height:40px), with 4-8 px gaps. Icon buttons are square, no wide padding. - Don't rely on hover (no tooltips-only info, no hover menus). Use
click/pointerevents.
Input fields
- Size fields to their content, never full width by default: a number
with up to 4 digits gets
width:5ch, a hex colour8ch, a name12ch. Free text that really needs the width may flex. Settype="number"withinputmode="decimal"(ornumeric) so phones show the number pad, andstep/min/max. - Group related fields on one row (X Y Z, W H D, R G B) with a tiny
label or symbol in front (
x,y,β,β,β). Usedisplay:flex;gap:4px;flex-wrap:wrapand a small row gap. No big margins between fields, no label above each field. - Font size of inputs at least 16 px, otherwise iOS zooms in on focus; make
the fields compact via small padding (
2px 4px), not small text. - Prefer sliders/steppers (
β+) for values changed often, number fields for exact entry; checkboxes/toggles asβsymbol + short label.
Panels, popups, dialogs
- Side panels become drawers or bottom sheets on narrow screens
(
@media (max-width: 700px)): hidden by default, opened with a symbol button, and they cover at most ~40 % of the height (bottom sheet) or ~70 % of the width (drawer) - the main area (canvas, board, preview) stays visible and usable. - Popups must not hide what the user is working on. Prefer a small dropdown
anchored to its button, or a bottom sheet, over a centred modal. Limit
max-width:min(92vw, 360px)andmax-height:50vh(usedvh), alwaysoverflow:autoso content scrolls inside the popup, never the whole page. No full-screen modals except for real file/text editing. - Popups close on outside tap,
Escapeand via a visibleβ; only one open at a time. Never usealert()/confirm()/prompt()- they block and look bad on phones; use a small inline message or toast (position:fixed;bottom:8px, auto-hide after 2-3 s) and don't let it cover the main controls. - Show results/status in a one-line status bar, not in popups.
Page structure
- Use
100dvh(fallback100vh) andoverflow:hiddenonbodyfor app-like tools; scrolling happens inside panels, not on the page. The main area takes all remaining space (1fr/flex:1; min-height:0) and canvases are resized onresizeandorientationchange. - Respect notches:
padding: env(safe-area-inset-top) ...on fixed bars. - Base font 14-16 px, line-height 1.2-1.3, tight spacing (4-8 px); use
rem/ch/%/min()/clamp()rather than fixed pixel widths. No horizontal page scroll at 360 px width. - Touch input on canvas/board: use pointer events (
pointerdown/move/up),touch-action:noneon the canvas (otherwise the page scrolls instead of dragging), support two-finger pinch/pan for zoom where it matters, and keep a keyboard shortcut for each action as well (desktop). - Colours: sufficient contrast, don't encode meaning by colour alone; support
prefers-color-schemeonly if cheap.
Games and simulations
Rules 3 and 4 (files) usually don't apply; rules 1, 2 and 5 do, plus:
- Separate the logic from the clock.
step()advances the world by exactly one tick and never reads timers orDate; the loop is onlysetInterval(() => { step(); render(); }, TICK_MS)(or arequestAnimationFrameloop with an accumulator). That makes the game deterministic and testable. - Input: key handlers only record the wanted action in a variable that
step()reads. Nothing moves inside a key handler. - Game over is state (
state.over = 'won' | 'lost' | null), not control flow:step()returns early when set,render()shows it, restart rebuilds the state from scratch. - Test hook:
window.__app = { get state(), step, setInput, reset }- tests call__app.step()in a loop instead of waiting for ticks.
Verify
python <skill-dir>/check_html.py app.html- syntax of every script block, import map JSON, inline handlers vs. module scope, missing element ids, mobile basics (viewport meta,alert/confirm/prompt, tiny input font sizes, fixed pixel widths on inputs, popups withoutmax-height/overflow).- Run it in the headless browser with the
browser-testskill (errors, UI actions, screenshots) - ALSO at phone size (viewport 390x844, and landscape 844x390): take a screenshot, check that the toolbar is a single row, nothing is cut off, there is no horizontal scrollbar (document.documentElement.scrollWidth <= innerWidth), and that an opened popup/panel leaves the main area visible.