Skip to main content

Page Tuner (`?tuner=1`)

Specification for the built-in AMADEQ page editor.

note

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 *.scss automatically — 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).
warning

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.

MechanismStateDecision going forward
Geometric selection (stackAt) instead of hit-testing✅ worksKeep unchanged.
Layers / Settings windows with drag and resize✅ worksKeep.
Magnetic dragging with guides✅ worksRecreate.
Ctrl+Z (50 steps)✅ worksRecreate.
Text editing via contenteditable✅ worksRecreate.
Comments on elements✅ worksRecreate.
Cumulative log in the repository✅ worksRecreate.
Element identity across sessionsbroke three timesSee §3 — this is the foundation.
Selection rules (modes, locks, "sticky" hover)broke every timeSee §2 — one simple rule.

The four breakages that cost two days

  1. The element key ignored the CSS-module hash. The class title is declared in five components, section in six, and root in nine. A key meaning "the first .title in 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.
  2. 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.
  3. "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.
  4. 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.

  1. 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.
  2. Highlight and selection are computed by ONE function. If hover shows one thing and the click takes another, the tool is lying.
  3. The smallest element under the cursor wins. Media, text, buttons — all compete on size. No unconditional priorities.
  4. 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.
  5. Verify before applying. If the tag or class does not match, skip the edit and name it — never apply blindly.
  6. 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.
  7. State lives in refs, not in effect dependencies. A live gesture must not depend on an effect being re-registered.
  8. Never read back what you just wrote. left/top come back as used values and need a fresh layout pass.
  9. Reset must work per element. "Reset everything" cannot be the only way out.
  10. 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, /pricingpricing, /a/ba--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

ItemAcceptance criterion
Hover shows a frame, a backdrop, and a name with the sizeAcross 5 different elements — 5 different names.
A click selects exactly what was highlightedA run over 25 elements: 25/25 match.
Sync with the layer treeThe selected row is active and scrolled into view.
Shells are not click-selectableClicking the background never yields div.page or body.
The size is the same everywhereTree, tooltip, and sliders report one number.

Stage 2 · Movement

ItemCriterion
Press and drag moves any elementA 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 automaticallyMeasured: static → relative, offset exact.
Magnet: edges and centres of the parent and siblingsAsked for +20 → got snapping; the difference equals the distance to the guide.
Guides come from the whole page, not just DOM siblingsA heading snaps to a logo from another branch.
Alt disables snapping, Shift locks one axis, Esc revertsEach mode measured separately.
A stretched box (left + right) does not change sizeleft 50→170, right 200→80, width unchanged.

Stage 3 · Edits

ItemCriterion
Numbers: padding, margin, size, radius, font sizeThe inline value equals what was set.
Size: Fixed / Hug / FillThe button state is derived from the existing inline style.
Text: double click → contenteditableEnter commits, Esc reverts, and it appears as its own row in the report.
Images: selection plus natural size and aspect ratioAn 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 pageLeft and right offsets are equal within ±1 px.
Distance from another elementAsked for 16 → measured exactly 16.
Ctrl+Z3 edits → 3 undos → the inline style is empty.
Resetting one elementThe rest of the session is untouched.

Stage 4 · Persistence

ItemCriterion
"Save all" with a counterShows the number of edits and elements.
It is visible whether everything is savedThe state changes only after the server responds.
Survives a reloadThree reloads — coordinates match to the pixel.
Restores in a clean browserWith an empty localStorage, it pulls from the log.
Other elements are untouchedOther .title elements carry no inline styles.
CommentsArrive 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;
  • requestAnimationFrame never fires, so any wait-for-frame hangs;
  • setTimeout is throttled to roughly 1/s, so a script with 10 pauses exceeds the limit;
  • getBoundingClientRect and getComputedStyle return stale values for already-existing nodes — verified: padding-top: 55px !important still reads as a computed 4px, 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

  1. One session per file. A parallel session already wrote the "lock" mode into this document, and the two sessions overwrote each other's edits.
  2. Commit nothing without an explicit instruction.
  3. Do not test against docs/tuner/*.json — that is the owner's working log. Use a separate slug for checks (for example ?tuner=1 on a different path).
  4. After every stage, take measurements — not "it should work".

6a. Rebuild progress

StageStateEvidence
1 · IdentityThe keys class:navBtn#0 / #1 distinguish two identical buttons; a class-less <p> received the anchor class:navBtn#0 p:nth-child(1).
2 · Log with statuses18 elements in docs/tuner/home.json, each edit with from→to and a status; POST /__tuner/apply moved closeButton to "carried into styles". That test log was removed after acceptance — as of 2026-08-10 docs/tuner/ is empty.
3 · Selection and highlightOne pickTarget function for both hover and click.
4 · Movement with magnetA run over 24 elements: 24 of 24 move exactly one element, nothing extra.
5 · EditsIndividual measurements: number 21 → 21px; "Hug" → fit-content with the mode highlighted; "top only" shifted X 0 / Y −76; page centring 880/878 (±1 px); distance from a neighbour set to 16 → measured 16; Ctrl+Z reverts; "Reset this element" leaves an empty inline style.
6 · Replacing the old editorFirstScreenTuner and the old plugin were deleted and mounting was switched over; after a reload the coordinates of three elements matched to the pixel.

Three bugs found precisely by individual measurement — a bulk run does not reveal them:

  1. Picking a reference element did not work. Listener order on window follows registration order, and the drag effect is declared earlier, so it intercepted the press before "pick reference" did, and a drag started instead. Moving the flag into a ref removes the dependency on ordering entirely. This also caused two further faults: Ctrl+Z and "reset element" operated on the wrong element, because the selection had already jumped.
  2. Premature rounding in centring. The target was rounded before the delta was computed, so errors accumulated: 880 on the left against 878 on the right. The target is now passed as a fraction and only the final value is rounded.
  3. The same problem in "distance from another element" — 16 was requested and 15 came out.

New structure, replacing a single 3,400-line file. The numbers were measured with wc -l on 2026-08-10, not estimated:

PageTuner/identity.ts element key, resolution, verification 190
PageTuner/geometry.ts measurement, selection, guides, axis 368
PageTuner/ledger.ts log types and the bridge client 134
PageTuner/ui.tsx overlay, tree, sliders 317
PageTuner/PageTuner.tsx state and interaction 1469
PageTuner/PageTuner.module.scss panel styles 862
scripts/vite-plugin-tuner.ts per-page log, statuses 296
total 3636

Each layer is verifiable on its own — which is exactly what the old version lacked. But the separation is only partial: PageTuner.tsx at 1,469 lines is twice the planned ~700 and is itself a candidate for further splitting, since state, gestures, and the panel still live in one file.

7. What was done at rollback time

  • The entire earlier development was backed up to D:\Web\_amadeq-backup\tuner-2026-08-09\.
  • The editor code was rolled back to 402caa7 (5,804 → 2,638 lines).
  • docs/tuner/ (616 KB of HTML dumps) and docs/tuner-changes.json.bak were removed.
  • Integrity was checked: tsceslintbuildtokens:check ✅.
  • Nothing was committed. Branch Vlad = 402caa7, unchanged.

Outside the editor, the owner's deliberate decisions were left in place and not rolled back: tokens:sync (120 tokens), the Alexandria button typeface, the Lemon heading colour, and the documentation of those decisions.

8. Open defects

Found 2026-08-10; fixes deferred by the owner.

1. The editor's code ships in the production bundle

§0 long claimed the opposite, which was wrong. Measured on dist/ after vite build:

  • dist/assets/index-*.js contains the strings __tuner/save and Скинути цей елемент;
  • dist/assets/index-*.css contains z-index: 2147483000three times.

Cause: import PageTuner from '../PageTuner/PageTuner' in components/LandingPage/LandingPage.tsx is static. The ?tuner=1 condition gates only the render, not the bundle. apply: 'serve' protects only the plugin bridge, not the component. The result is roughly 2,500 lines of TS and 862 lines of SCSS in the production build, and on amadeq.com/?tuner=1 the overlay would come up — without saving, since /__tuner/* returns 404 there.

How to close it: React.lazy(() => import('../PageTuner/PageTuner')) under the same condition, or an import.meta.env.DEV guard. Verify by grepping dist/ after a build, not by assuming "the plugin is dev-only anyway".

2. .gitignore does not cover the new log

Lines 60–61 ignore docs/tuner-changes.json and docs/tuner-changes.md — files belonging to the removed editor. docs/tuner/ is not ignored, so the owner's edit log will end up in a commit. Whether to ignore it as temporary or track it as part of the hand-off channel is the owner's call — §1 lists the in-repository log as a working mechanism, so this is not unambiguously a bug.

3. The old log is not a source for porting edits

docs/tuner-changes.md still holds all 61 edits from the old session, including entries on div.button and button.closeButton that broke the banner and that pass 7 removed, as well as section.section → left: 600px and h1.title → left: 8px. Only three .centerCta values were approved by the owner (see HANDOFF.md). This file must not be ported wholesale.