Page Tuner (`?tuner=1`)
Specification for the built-in AMADEQ page editor.
Written: 2026-08-09, after two days of development that hit a dead end.
Status: specification awaiting approval. The editor code has been rolled back to commit 402caa7.
Backup of the earlier work: D:\Web\_amadeq-backup\tuner-2026-08-09\ — code, edit log, HTML dumps, and a HANDOFF snapshot, 5.8 MB, outside the repository.
0. Why this exists
Landing-page layout has to be adjusted on the live page, not on a mockup: in the real fonts, in the real boxes, with the globe and the animations running. The changes are then carried into *.module.scss by hand — the editor never writes styles itself.
The behavioural reference is Figma. Not "similar to", but specifically: a click takes the deepest content, holding drags it, a double click edits text, the selection is shown by a frame, and containers are taken from the layer tree.
What the editor does not do
- It does not edit
*.scssautomatically — it only produces a report and a log. - It does not appear without
?tuner=1, and the save bridge exists only in development (apply: 'serve'in the plugin). - It makes no claim to mobile viewports (the landing page has not been built for them yet).
The editor is not excluded from the production bundle. The import is static, so its code ships to production — see §8, defect 1.
1. What was already built, and what survived
Two days produced working mechanisms and four classes of breakage. The mechanisms are worth recreating; the breakages must not be repeated. That is the main value of this document.
| Mechanism | State | Decision going forward |
|---|---|---|
Geometric selection (stackAt) instead of hit-testing | ✅ works | Keep unchanged. |
| Layers / Settings windows with drag and resize | ✅ works | Keep. |
| Magnetic dragging with guides | ✅ works | Recreate. |
| Ctrl+Z (50 steps) | ✅ works | Recreate. |
Text editing via contenteditable | ✅ works | Recreate. |
| Comments on elements | ✅ works | Recreate. |
| Cumulative log in the repository | ✅ works | Recreate. |
| Element identity across sessions | ⛔ broke three times | See §3 — this is the foundation. |
| Selection rules (modes, locks, "sticky" hover) | ⛔ broke every time | See §2 — one simple rule. |
The four breakages that cost two days
- The element key ignored the CSS-module hash. The class
titleis declared in five components,sectionin six, androotin nine. A key meaning "the first.titlein the document" pointed at different elements depending on which sections had managed to mount. Saved edits landed on the wrong elements, and the page drifted on every reload. - A positional path used as identity. The same element was
section:nth-child(7)and then(8)seconds apart — the landing page mounts its sections progressively. - "Smart" selection rules. A boundary was added twice — first "pressing inside the selection drags it", then a "lock" mode — and both times it produced the same failure: as soon as a large element became selected, it covered the screen and it became impossible to click through to anything else.
- Media prioritised over size. Once images were allowed to always win the selection, the globe's 1134×1134
<canvas>covered every button on top of it.
As a side effect, div.page — the site's scroll root — was given width: fit-content by the Size control, and the layout fell apart on every load, because the edit was faithfully restored.
2. Laws that must not be broken
Each one is derived from a specific breakage. Violating any of them means returning to the dead end.
- What you clicked is what is selected. No modes, locks, or exceptions. Dragging does not suffer: after a selection, the next press takes the same element.
- Highlight and selection are computed by ONE function. If hover shows one thing and the click takes another, the tool is lying.
- The smallest element under the cursor wins. Media, text, buttons — all compete on size. No unconditional priorities.
- Identity = class name + module hash + tag + index, with a fallback chain of
name+hash → name+tag → name. The hash narrows but is not required, since it changes with any style edit. For elements without a class, anchor to the nearest ancestor that has one, plus a short relative path. - Verify before applying. If the tag or class does not match, skip the edit and name it — never apply blindly.
- Shells are untouchable. An element covering ≥ 98 % of the viewport cannot be selected by click and cannot be resized. It is reachable only from the layer tree.
- State lives in refs, not in effect dependencies. A live gesture must not depend on an effect being re-registered.
- Never read back what you just wrote.
left/topcome back as used values and need a fresh layout pass. - Reset must work per element. "Reset everything" cannot be the only way out.
- Every edit is reversible. Ctrl+Z, at least 50 steps, and multi-property actions undone in one step.
3. Architecture
Four layers, each verifiable on its own.
1. IDENTITY elementKey / resolveKey / keyMatches
↓ (the single point where an element becomes a key and back)
2. SELECTION stackAt → preferred → pickTarget
↓ (one function for both hover and click)
3. APPLICATION setPropOn / setRawOn / setText → inline styles + undo
↓
4. PERSISTENCE localStorage (session) + docs/tuner/<slug>.json (log)
The log is one file per page: docs/tuner/<slug>.json (machine-readable, the editor restores state from it) and docs/tuner/<slug>.md (a mirror for the developer). slugOf: / → home, /pricing → pricing, /a/b → a--b.
The older docs/tuner-changes.{json,md} in the docs/ root belong to the removed FirstScreenTuner editor; the new one does not write to them. Their format is entries + vars; the new one is elements + props + status.
The log is cumulative. Entries merge on the element's stableId plus the property name — specifically on stableId, because the module hash changes with any style edit and comparing raw ids produced duplicates of the same element. from is written only the first time — it is the original value from the stylesheet; when to changes, the previous value moves into history. An edit that has been carried into SCSS is marked "status": "applied" (or via POST /__tuner/apply), and the tuner stops replaying it.
Restoration comes from localStorage; if that is empty, from the log via GET /__tuner/state. Retry steps are 0 / 250 / 800 / 2000 / 4000 ms, to cope with sections mounting progressively. The "hydration complete" flag is set only after the last pass.
4. Scope and acceptance criteria
Every item has a measurable criterion. "Looks good" is not accepted.
Stage 1 · Selection and highlight
| Item | Acceptance criterion |
|---|---|
| Hover shows a frame, a backdrop, and a name with the size | Across 5 different elements — 5 different names. |
| A click selects exactly what was highlighted | A run over 25 elements: 25/25 match. |
| Sync with the layer tree | The selected row is active and scrolled into view. |
| Shells are not click-selectable | Clicking the background never yields div.page or body. |
| The size is the same everywhere | Tree, tooltip, and sliders report one number. |
Stage 2 · Movement
| Item | Criterion |
|---|---|
| Press and drag moves any element | A run over 25: exactly one moves, 25/25. |
| A click does not nudge (5 px threshold) | A 2 px move leaves the inline style unchanged. |
position: static becomes relative automatically | Measured: static → relative, offset exact. |
| Magnet: edges and centres of the parent and siblings | Asked for +20 → got snapping; the difference equals the distance to the guide. |
| Guides come from the whole page, not just DOM siblings | A heading snaps to a logo from another branch. |
| Alt disables snapping, Shift locks one axis, Esc reverts | Each mode measured separately. |
A stretched box (left + right) does not change size | left 50→170, right 200→80, width unchanged. |
Stage 3 · Edits
| Item | Criterion |
|---|---|
| Numbers: padding, margin, size, radius, font size | The inline value equals what was set. |
| Size: Fixed / Hug / Fill | The button state is derived from the existing inline style. |
Text: double click → contenteditable | Enter commits, Esc reverts, and it appears as its own row in the report. |
| Images: selection plus natural size and aspect ratio | An SVG logo is selectable and its original size is shown. |
| Anchors within the parent (3×3) | Every cell affects both axes. |
| Alignment on a single axis | "Top only" does not shift horizontally. |
| Centring on the page | Left and right offsets are equal within ±1 px. |
| Distance from another element | Asked for 16 → measured exactly 16. |
| Ctrl+Z | 3 edits → 3 undos → the inline style is empty. |
| Resetting one element | The rest of the session is untouched. |
Stage 4 · Persistence
| Item | Criterion |
|---|---|
| "Save all" with a counter | Shows the number of edits and elements. |
| It is visible whether everything is saved | The state changes only after the server responds. |
| Survives a reload | Three reloads — coordinates match to the pixel. |
| Restores in a clean browser | With an empty localStorage, it pulls from the log. |
| Other elements are untouched | Other .title elements carry no inline styles. |
| Comments | Arrive in the .md as their own section above the tables. |
Stage 5 · End-to-end check
- A run over ≥ 25 visible elements: selection plus drag, with a report for each.
- Three reloads with coordinate verification.
tsc -b,eslint src,vite build,tokens:check— all exit 0.- A visual check by the owner — see §5 for why it cannot be done by the agent.
5. Verification limits
The browser panel in the authoring agent's environment does not composite frames. Measured:
- no screenshots, so coordinate clicks are unavailable;
requestAnimationFramenever fires, so any wait-for-frame hangs;setTimeoutis throttled to roughly 1/s, so a script with 10 pauses exceeds the limit;getBoundingClientRectandgetComputedStylereturn stale values for already-existing nodes — verified:padding-top: 55px !importantstill reads as a computed4px, while a new node in the same parent updates correctly.
Consequence: everything is verified synchronously, and the source of truth is the style attribute and the log contents, not the frames. There is no physical mouse. Stage 5 therefore always ends with a manual check by the owner.
6. Working rules
- One session per file. A parallel session already wrote the "lock" mode into this document, and the two sessions overwrote each other's edits.
- Commit nothing without an explicit instruction.
- Do not test against
docs/tuner/*.json— that is the owner's working log. Use a separateslugfor checks (for example?tuner=1on a different path). - After every stage, take measurements — not "it should work".
6a. Rebuild progress
| Stage | State | Evidence |
|---|---|---|
| 1 · Identity |