jcode/skills/single-file-webapp/SKILL.md

9.0 KiB
Raw Blame History

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

  1. No inline handlers with a module script. onclick="save()" cannot see functions declared inside <script type="module"> ("save is not defined"). Wire every control with document.getElementById('save').addEventListener('click', save). Assign window.x = … only for the test hook, and before anything uses it.
  2. One state object, one render. All data lives in state; render() redraws from it. Mutations go through small functions (addObject, remove, group) that end with render(). Never keep the truth in the DOM.
  3. Serialize data, not live objects (full recipe, checker and tests: the save-load-json skill - 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)) equals s. Groups/nesting are the classic thing to forget - write them first.
  4. 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 exactly download('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 calls importSTL(buffer) / importSVG(text), which create objects through the normal constructor path. Test import/export via the buttons (h.upload / h.download in the browser-test skill).
  5. Test hook at the end of the module - the browser-test skill drives and asserts through it, so include at least:
    window.__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
    };
    
    Tests set up state through 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 title and aria-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/pointer events.

Input fields

  • Size fields to their content, never full width by default: a number with up to 4 digits gets width:5ch, a hex colour 8ch, a name 12ch. Free text that really needs the width may flex. Set type="number" with inputmode="decimal" (or numeric) so phones show the number pad, and step/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, βŒ€, ↔, ↕). Use display:flex;gap:4px;flex-wrap:wrap and 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) and max-height:50vh (use dvh), always overflow:auto so content scrolls inside the popup, never the whole page. No full-screen modals except for real file/text editing.
  • Popups close on outside tap, Escape and via a visible βœ•; only one open at a time. Never use alert()/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 (fallback 100vh) and overflow:hidden on body for 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 on resize and orientationchange.
  • 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:none on 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-scheme only 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 or Date; the loop is only setInterval(() => { step(); render(); }, TICK_MS) (or a requestAnimationFrame loop 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

  1. 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 without max-height/overflow).
  2. Run it in the headless browser with the browser-test skill (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.