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

155 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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.