155 lines
9.0 KiB
Markdown
155 lines
9.0 KiB
Markdown
---
|
||
name: single-file-webapp
|
||
description: >
|
||
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
|
||
```html
|
||
<!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:
|
||
```js
|
||
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](./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](./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.
|