Profile Builder — the `.spcf` editor at `/editor`
/editor is the site's inbrowser .spcf editor: it opens a profile, builds button bindings, deepclick pressure zones, tilt triggers, nested bindings and device se
/editor is the site's in-browser .spcf editor: it opens a profile, builds button bindings, deep-click pressure zones, tilt triggers, nested bindings and device settings, and writes the file back out. This page documents that tool — its layout, every panel, its persistence and its save paths. The file format it reads and writes is documented separately in the SpCF format.
Read this first. A
.spcfhas no macros and no sequences.PredecessorIdis input nesting: a binding indented under another is active while the trigger above it is held. No if/else, no timed order, no priority, andOutputActionSequenceDelayis0in all 1026 stored mappings and is not exposed in the UI. The Builder's nesting tree, the Checker'sHeld-button nestingsection and the profile page'sNested bindingssection are all the same model rendered three times. See Input nesting and loops.
What it is
A single page, public/editor.html, driven entirely by public/js/editor.js with the shared parser, nesting core and writer loaded as classic scripts (/js/spcfParser.js — which carries lib/nesting.js concatenated ahead of itself — then /js/spcfWriter.js, then js/export-helpers.js, js/step-diff.js, js/editor.js, js/recorder.js, plus notifications.js and chrome.js deferred). There is no build step and no inline script — the CSP forbids one — so every control's behaviour lives in the one file.
Its <body> carries data-app-shell, which switches the page from document to application layout in css/base.css:
body[data-app-shell] { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
The header takes its natural height, the workspace takes the rest, and each pane that holds more than fits scrolls internally. chrome.js skips the site footer on this flag, so there is no page scroll and no footer.
The promise the tool makes is written into the first-run panel, not just implied:
Everything you build here stays on this device. Nothing is uploaded unless you publish it.
Admin-only affordances (the record button, the Admin link, the staff menu entries) are layered on by auth.js and recorder.js; the markup is identical for everybody.
The layout
+------------------+------------------------------+--------------+
| tool strip | History · Profile · Output | status ? rec |
| left rail | centre | binding pane |
| Layers | Mouse | Bindings | | header |
| filter/progress | Settings | JSON | | press stages|
| group rows | Diagnostics | trigger |
| Profile | | activation |
| Diagnostics | | deactivation|
| | | label |
+------------------+------------------------------+--------------+
| status bar | layer path · doc · issues | saved |
| Region | Element | Notes |
|---|---|---|
| Tool strip | .tbar |
groups History (#btn-undo, #btn-redo), Profile (#btn-new, #btn-open, hidden #file, #load-id, #btn-load), Output (#btn-download, #btn-save); end side carries #ed-dirty, #ed-status, #btn-record (hidden), #btn-help |
| Left rail | #rail-panel |
Layers list, profile meta, diagnostics summary; #rail-resize handle |
| Collapsed rail | #rail-collapsed |
narrow strip with add / remove / expand |
| Centre | .ed-center-header + .ed-center-split |
#group-name, the five .edctab buttons, then #view-diagram, #view-mappings, #view-settings, #view-json, #view-diag |
| Binding pane | #editor-pane |
#editor-resize handle, #be-title / #be-hint, #binding-editor |
| Status bar | #ed-statusbar |
#ed-status-layer, #ed-status-doc, #ed-status-diag (a button), #ed-status-dirty |
| Overlays | #overlay, #shortcut-help, #ed-start, #publish-backdrop |
toasts, shortcut help, first-run panel, publish / sign-in modals |
Both columns are resizable, and the sizes persist:
| Pane | Default | Range | Keyboard | Stored under |
|---|---|---|---|---|
| Layers rail | 288 px | 180–520 px | ArrowLeft/ArrowRight ±12 px, Shift ±48 px, Home/Enter/Space back to the default |
sph:editor:rail-w |
| Binding pane | 900 px | 300–1400 px | same keys, mirrored (ArrowLeft widens, because the width is measured from the right edge) |
sph:editor:pane-w |
| Rail collapsed | no | — | #btn-rail-collapse / #btn-rail-expand |
sph:editor:rail-collapsed |
The centre tabs are Mouse (the diagram view, the default), Bindings, Settings, JSON and Diagnostics, and the status bar under the workspace carries four fields, all derived from state the editor already keeps:
| Field | Shows |
|---|---|
#ed-status-layer |
the selected layer's breadcrumb path through the tree, e.g. Base > Sub |
#ed-status-doc |
the document name (profile title, else root group name, else Untitled) plus · #<hub id> when published |
#ed-status-diag |
the issue count, clickable, and the way into Diagnostics from anywhere |
#ed-status-dirty |
Saved or Unsaved, mirroring the tool strip's #ed-dirty marker |
With no document loaded the bar reads —, No profile, Saved, 0 issues.
Every read and write of those keys is wrapped in try/catch: storage can throw in a private window or with cookies blocked, and a layout preference is not worth breaking the editor over.
A stable layout is deliberate. The comments in editor.js are explicit that the binding pane stopped being a view that opened on selection, because "a layout that changes shape between steps makes the recording harder to follow", and that the empty pane is not wasted space — it holds the form that creates a binding, which is exactly where a recorded step about adding one points.
Getting a profile in
Four ways, and one panel that offers them:
| Route | Mechanism |
|---|---|
| Drop a file anywhere on the page | dragover is prevented and drop reads dataTransfer.files[0] |
Open… |
#btn-open clicks the hidden #file input (accept=".spcf,application/octet-stream,text/plain"); the title defaults to the filename minus its extension |
| Load from the hub | #load-id + #btn-load → GET api/profiles/<id>/download; a non-positive id gives Enter a profile number., a 404 gives Profile not found. |
| Deep link | ?id=N on boot fills #load-id and loads it |
FileReader.readAsText() is used throughout, so a file that fails to read reports Could not read the file. An opened file becomes the draft immediately, before it is edited: a refresh after opening a file should not send you back to a blank Builder either. The first tool you touched after opening is still the last layer selected — the first group is selected, nothing else is.
The first-run panel
#ed-start is a panel over the workspace (role="dialog", aria-modal="false"), not a gate: the editor is usable behind it and Not now is a real answer. Escape closes it, through a capture-phase listener that is removed with the panel so the editor's own Escape handling is untouched.
It is called on every boot that does not restore a draft, and it is skipped entirely for ?record= or ?id=, where "start a profile" would be a lie about what the page is. (The markup comment in editor.html claims it is shown on every open; the code in wire() only reaches initStart() when there is no draft — the code wins.)
Its four cards:
| Card | Behaviour |
|---|---|
| Open a .spcf file | closes the panel and clicks #file |
| Load the example profile | EXAMPLE_PROFILE_ID = 8 — the published CS2 profile (game: Counter-Strike 2), the same id the Checker's Example button fetches from api/profiles/8/download |
| Watch a profile get built | revealed only when GET api/guides returns at least one guide; href is ?record=<guide.url>; subtitle becomes <title> — N steps, played in this editor. (or , played in this editor. when the guide reports no step count). Empty link omitted — see the Walkthrough recorder |
| Start blank | closes the panel; the blank profile is already loaded |
| Not now | closes the panel |
Validation on open
parseDocText(text) is the single reader for anything the user opens, and it applies the same container rules as the server parser with different wording:
| Check | Failure message |
|---|---|
| BOM | stripped silently |
| No newline | Not a .spcf file — no header line found. |
| Header must match `/^json([,\s] | $)/` |
| NUL (U+0000) in the body | This looks like a binary file. |
JSON.parse(body) |
the raw JSON error message |
| Root is an object, not an array | Bad profile document. |
RootProfile.Groups missing or not an array |
No RootProfile.Groups found. |
Groups empty |
Profile contains no groups. |
Whatever it throws becomes a toast, and the previously loaded document is left alone.
Layers (the left rail)
state.groups is a flattened depth-first walk of the group tree, each entry carrying its depth, so nesting is shown by indentation and everything stays selectable in one list.
- Each row is a real
<button>withpadding-left: 6 + depth * 12px and a count badge. A layer with no mappings shows a0badge carrying the titleNo bindings yet. #layer-filterfilters by name (substring, case-insensitive).Escapein the box clears it and stops propagation, so the editor's ownEscape(clear selection) does not fire as well. A filter that matches nothing saysNo layer matches "<query>".- The head carries
#btn-del-group(-),#btn-add-subgroup(+sub),#btn-add-group(+) and#btn-rail-collapse; add and remove are disabled until a layer is selected. #rail-progressreadsN / 16 boundand completes when all 16 controls inBUTTON_SECTIONShave at least one light press or deep zone.#profile-metashows the name,hub #Nornot published, andN group(s) · N mapping(s)counted over the root and every group.#diagis the compact diagnostic summary:N issue(s)in the warn state,Looks coherentwhen there is nothing to report, and below it a readout of sub-group and mapping counts. Every issue row is clickable and switches to the Diagnostics tab.#btn-diag-opendoes the same from the panel head.- Collapsing the rail swaps the list for
#rail-collapsed, which keeps add, remove and expand reachable so the structural actions never disappear, and mirrors the disabled state of the remove button.
The group name itself is edited in #group-name in the centre header (60 characters), and an emptied name is stored as (untitled).
The mouse tab
The default view. BUTTON_SECTIONS lays the 16 real controls out as six labelled grid sections:
| Section | Controls | Grid span |
|---|---|---|
| Mousewheel | Wheel Pull, Wheel Push, Wheel Press | 2 |
| Main | Left Button, Right Button, Middle Button | 1 |
| Thumb | Top Thumb, Bottom Thumb | 1 |
| Fingertip | Left Fingertip, Right Fingertip | 1 |
| Triggers | Left Trigger Pull, Left Trigger Push, Right Trigger Pull, Right Trigger Push | 2 |
| Edges | Rear Edge, Front Edge | 1 |
Above the grid, the stats strip counts N bindings, N buttons, N deep zones, and — only when non-zero — N tilt/pivot, N surface and N wheel; N scripts (the non-press-stack nesting trees in this group) is always shown.
Each control is a real <button class="ed-mbtn"> with aria-pressed, carrying:
- its label (
Left Button, notMouseButtonLeft); - its holder, when
BUTTON_BY_IDdefines one — no entry does today, so this slot never renders; - a
deeptag when the control has a force sensor; - a count badge with the number of bound stages;
- an inline list of every bound stage:
lightrows andzone 1..nrows with theirstart–end%range, each with the label and the action summary, ornot boundwhen the control has none.
Each card's tooltip says what it is and what it can do: the label, — supports deep-click zones for the five force-capable controls, then — <n> binding(s), — nothing bound to it yet, or how many bindings it only has while another trigger is held, and always closes with — click to open its bindings in the Bindings tab. A grid line above the tiles says the same. Clicking a tile keeps it selected (so the rail's press card and + Light press / + Deep zone keep working) and then moves to the Bindings tab, scrolls that control's card into view and marks it. A control that is only ever a child lands on the card that actually holds its bindings — clicking Bottom Thumb in profile 69 opens Left Button's card — and says so in a toast.
Clicking a card selects the control and its first binding, so the right pane opens on something rather than on an empty form.
A control's first binding gets the trigger that control implies: everything becomes a buttonToggle, except wheel pull and wheel push, which become a scroll (back and forward) because they are motion, not presses.
The bindings tab
Filter bar (#map-filter-bar, shown only in this view): #map-filter-q matches, case-insensitively, the trigger text, every activation and deactivation action description, and the label; #map-filter-type filters by trigger type:
| Option | Matches |
|---|---|
| Any type | everything |
| Button presses | buttonToggle |
| Deep zones | forceRegionToggle |
| Tilt / Pivot | imuAngleRegionToggle, imuAngleRange |
| Wheel | scroll |
| Surface | surfaceCombined |
| Settings | systemEvent |
| Other | anything else |
The nesting tree. This block used to be called Sequences and macros and drew numbered steps with arrows. It is a nesting tree now, because that is what the format contains.
The Builder's view of the structure is nestingFor(g) (public/js/editor.js), which wraps nesting.buildTreeFromValues() over the group's own ActionMappings.$values and returns { tree, count, roots, children, parentOf, depthOf, maxDepth, order, issues, pressStacksByButton, mappings } — every key a raw index, the same index space the parser uses. chainsOf() on top of it returns one entry per root:
| Field | Meaning |
|---|---|
root |
the raw index the tree hangs off |
seq |
every mapping under that root, depth-first, once each |
stack |
true when the whole run is one button's press stack — its light press plus its own deep zones — which belongs in that button's press card rather than in a tree |
button |
the button of a press stack, else null |
maxDepth |
deepest index under this root, in edges |
chainsOf() used to follow only the first follower at each hop and had no notion of branching at all. On profile 69 that dropped 5 of its 9 mappings, silently, because a dropped mapping is not an error — the profile simply looked smaller than it was. It now walks the whole subtree, and test/nesting-tree.test.js pins "traversal visits all 9" because that is the assertion that fails against the old version.
MAX_NEST_DEPTH is 20 edges, and the mutation helpers refuse an insert or a move that would exceed it — the depth and cycle guards run before anything is written, so a refusal leaves the array untouched. The corpus's deepest profile is 5 edges.
What the block does:
- Everything renders under the Root key. A button whose bindings are only ever children gets an empty card; the whole tree hangs off the root that holds it. For profile 69,
Left Button Pressholds the tree and the cards for Right, Bottom Thumb, Right Trigger Push, Tilt, Rear Edge and Left Trigger Push are empty. - A button appearing in two branches appears in both, as a node in each — its own card stays empty. Two mappings with the same trigger on different branches are two nodes: the model is keyed by raw index, never by trigger.
- A button's own deep-click zones stay in its press card, on the force axis with its real ranges. A deep zone nested under another button is a gated trigger and appears in the tree under its parent.
- The
Run afterdropdown is gone, and with it the per-mapping delay field, because there is no per-mapping timing to set. Nesting is created from the Add-menu in the button card in the Bindings tab, not from a dropdown on an unrelated binding. - The
▶preview went with it: there is no sequence to play. It highlighted only — it never sent real key presses.
All bindings. The list is grouped, and the grouping order is fixed by rank: controls in grid order, then Tilt / Pivot, Wheel, Surface, Other, and finally Settings. Each group head shows its name and count. A row is the mapping index, a settings or deep badge where it applies, the trigger text, the label, up to four activation actions, and a × to delete. Empty states: No bindings yet. Add one above, or click a button in the Mouse tab. and No bindings match the filter.
The head carries #map-add-kind and #btn-add-mapping. The kind menu is two option groups:
| Group | Options |
|---|---|
| Mouse buttons | Button press; Deep-click zone (offered only when the selected control has a force sensor) |
| Device-wide | Tilt gesture; Pivot gesture (yaw); Tilt range; Pivot range (analog yaw); Mousewheel; Surface drag; System event |
Pivot gesture and Pivot range are UI aliases: they create imuAngleRegionToggle / imuAngleRange with Axis: "yaw", and the toast recommends adding a "Reset pivot" output so the axis starts centred. Adding a button trigger with no control selected gives Select a button first (Mouse tab, or click a button there and re-add).; a deep zone on a control without a sensor gives This button has no deep-click sensor.
The settings tab
The settings of the currently selected layer, opened with a note that states where they live: Settings are stored in the group's profileChanged system event and apply when the profile activates.
| Block | Controls |
|---|---|
| DPI Levels | rows of X and Y number inputs, min 0, max 32000, step 50, separated by a multiplication sign; + Add level appends a copy of the last row. There is no per-row remove button in this pane (the composer's level widget has one) |
| Device | #set-hz (125 / 250 / 500 / 1000 Hz), #set-lift (0.7 / 1 / 2 mm), #set-device (none / joystick / gamepad) |
| LED | #set-led (off / fixed colour / hue rotation) and #set-ledb (0–100, step 5) |
| OLED Display | #set-oled with all nine modes; the message field (max 32) appears only for customMessage and the 384×192 canvas block only for image, with Paste image, Clear and a drawing hint |
| Behaviour | #set-auto and #set-tilt, each enabled / disabled |
#set-apply rewrites the activation list of the group's systemEvent mapping as dpiConfigure, reportInterval, liftOffSelect, gameDeviceSelect, ledSet, oledSet, autoReleaseInputs, tiltAutoZeroing in that fixed order, then appends any items it did not recognise — a key press on profile load survives, but ends up after the eight. If the group has no settings mapping, one is created. fixGroupCounts() runs and the toast reads Settings applied.
Two things this pane does not do: LED duration is not editable here (Duration10ms: 500 is always written), and an out-of-list lift-off value is preserved by prepending an option rendered as <value> mm (non-standard) rather than being snapped to 0.7/1/2.
The binding editor
Always visible on purpose (syncEditorPane() clears hidden), and its header always says what is being edited: Nothing selected with the hint Pick a button on the Mouse view, or a binding in the list.; a control name with Supports a deep-click zone or No deep-click sensor on this button; or the mapping's label / trigger text with Binding #N. The layer name follows after a middot.
Press stages card. Rendered only for the five deep-capable controls (left, right, middle, both fingertips). It shows a 0–50–100 % band track split into light and deep segments by the zones' real ranges, a count, one row per stage (Light press, or Deep · n with its start–end% and its action summary), and the two add buttons: + Deep zone and + Light press. + Deep zone auto-starts the band — 35–100 % for the first zone on a control, min(lastZoneEnd, 95)–100 % afterwards — with the exit band equal to the entry band.
Trigger. #trig-kind lists the trigger kinds (forceRegionToggle is omitted when the control has no sensor; systemEvent is appended when the mapping already is one), and #trig-fields renders that kind's fields. For a deep zone the fields are followed by #trig-range-plot: a band with a dimmed exit range, a filled entry range, ticks at 0 / 25 / 50 / 75 / 100, and two draggable handles. The drag uses pointer capture so it survives leaving the plot, constrains start to the current end and vice versa, writes back into the number inputs live, and pushes exactly one undo snapshot for the whole drag.
On activation and On deactivation. Two sections, each with a live count, the action rows, and its own composer. The deactivation block is what makes "while held" work — press down on activation, release on deactivation — and the hint above it states the rule: Fired when trigger is released / leaves zone. Within each list the order is the execution order, which is why a loop can jump to an index in it.
Nesting, label & layer. #map-label (40 characters) and #map-move (Move to…), which appears only when the profile has more than one layer, listing them with a dotted depth prefix. Moving severs the mapping's own nesting link and re-indexes the links in the group it leaves. The section's head carries a read-only summary line that reads the gate back out of nestingFor(g), so it cannot describe a link the editor does not have.
There is no Run after dropdown and no delay field. Both have been removed, and neither was describing something the file has:
- The
Run afterdropdown (with— none —standing for65535) let you pick the parent of a mapping, but framed it as "this fires after that one" — which is the opposite of what the link means. It is a gate: the mapping is live while the chosen trigger is held. The name was not a label problem; the model behind it was. Nesting is now created from the Add-menu in the button card, so the parent you pick is the button whose card you clicked. - The delay field (
OutputActionSequenceDelay,0–10000ms) configured per-step timing for a sequence. There is no sequence and no timing: the field is0in all 1026 stored mappings across all 48 profiles. The writer still emits0for wire fidelity, and nothing edits it.
The section head used to read Sequence and label, and the Sequences & macros block's empty state told the reader to set "Run after" in a binding to link steps into a sequence. Both are gone with the feature; see Known gaps for what replaced them and what is still open.
Action rows carry the position, the description, and five tools: move up (disabled on the first), move down (disabled on the last), duplicate, edit and remove.
The trigger fields, and the ranges the widgets enforce:
| Trigger | Fields |
|---|---|
| Button press | none |
| Deep-click zone | Direction (increasing / decreasing / both); Entry start %, Entry end %, Exit start %, Exit end % (0–100, step 1); range plot |
| Tilt gesture | Axis (Tilt roll / Tilt forward-back pitch / Pivot yaw); Direction (both / increasing / decreasing); Entry start, Entry end, Exit start, Exit end in degrees (0–360, step 1) |
| Tilt range | Axis; Range from and Range to, signed −180…+180 relative to level, step 0.5, with a hint that the band may cross level |
| Mousewheel | Direction (Forward / Back); Sensitivity (CountDivisior, 1–200) |
| Surface drag | Direction (Any / Forward / Back); Sensitivity (CountDivisor, 1–200); Repeat while moving |
| System event | Event (profileChanged) |
And the composer fields, per kind:
| Composer label | Kind | Fields and ranges |
|---|---|---|
| Key press | keyboardKey |
Key (the 90-entry HID list); Action: tap / hold down / release |
| Media key | keyboardMedia |
Media (8 consumer keys); Action |
| Type text | keyboardKeyString |
Text to type, 80 max |
| Mouse button | mouseButton |
Button (Left, Right, Middle, Back, Forward, Extra); Action |
| Scroll wheel | mouseScroll |
Direction (down / up); Notches −20…20 |
| Mouse move | mousePointer |
Axis (xy / x / y); X and Y −50…50 |
| Haptic pulse | hapticFeedback |
Strength 0–100 % (stored value shown times 1.2); Duration 10–500 ms |
| Delay | wait |
Milliseconds 0–10000 |
| DPI selection | dpiSelect |
How (select / index / next / previous / increase / decrease); Value 0–32000 |
| Loop to action | gotoOutputAction |
Jump to action # (0–100, hint: 0 = first in this list); Repeat count 0–100 (hint: 0 = forever) |
| Stop loop | stopLooping |
Skip waits |
| LED | ledSet |
Mode; Brightness 0–100 step 5; Duration 10–20000 ms |
| OLED display | oledSet |
Mode (all nine); Message (32 max, only for customMessage); Image canvas (only for image) |
| OLED frame | oledFlash |
Image canvas; Duration 10–2000 ms; Block: wait (blocks) / continue |
| OLED animation | oledAnimation |
Animation paste; Frame duration 10–60000 ms; Loop while held |
| Select profile | profileSelect |
Direction (down / up / next); Level (currentLevel) |
| Jump to profile | profileJump |
Profile name (60 max); Profile # 0–100; Sub # 0–100 |
| Auto-release inputs | autoReleaseInputs |
State (enabled / disabled) |
| Tilt auto-zero | tiltAutoZeroing |
State (enabled / disabled) |
| Zero tilt/pivot | imuAnglesZero |
Axis |
| Game device mode | gameDeviceSelect |
Mode |
| Polling rate | reportInterval |
Hz |
| Lift-off height | liftOffSelect |
mm |
| Joystick button | joystickButton |
Button # 1–16; Action |
| Joystick stick | joystickStick |
Axis (X left/right, Y forward/back, Rot yaw, Z thrust); Left start/end %, Right start/end % (0–100); Mirrored |
| Alternate outputs | alternateOutputActions |
Count 1–20 |
| Do nothing | doNothing |
— |
A select whose stored value is not in the list gains a <value> (custom) option rather than being snapped, and the range plot is only rendered for the deep zone.
Composer. + Add action opens it; #cd-kind is the 27-entry kind list in KIND_ORDER and #cd-fields renders that kind's inputs — including the level list, the OLED canvas and the animation paste block. Cancel closes it; the primary button reads Add action or Update depending on whether an existing node is being edited. Editing passes the original node as the factory's base, so unknown driver fields survive the change.
The JSON tab and the diagnostics tab
The JSON view is a read-only <pre id="raw-view"> holding JSON.stringify(state.doc, null, 1). It is cached against state.rawKey (mappingSel + '/' + groupSel) so it does not re-serialise the whole document on every keystroke, and the cache is dropped on undo/redo.
The Diagnostics tab lists every issue with contextual buttons. collectDiagIssues() runs on every render, so the rail summary, the status-bar count and this tab can never disagree.
| Issue text | Conditions | Buttons |
|---|---|---|
Group "<name>" has no bindings |
a nested group (depth > 0) with no mappings | Jump to Group · Remove Group |
#<n> has no trigger type |
InputActionType.Id missing |
Jump to Mapping · Remove Binding |
#<n> (<trigger>) has no actions |
both action lists empty | Add Action · Jump to Mapping · Remove Binding |
Binding #<n> is meant to be held under #<p>, which is not a binding in this layer — it fires on its own instead |
PredecessorId outside the group's range |
Make Top-Level · Jump to Mapping |
<Button>: deep zones overlap at <n>% |
two zones on one control share part of the axis | Jump to Mapping |
Jump selects the group and mapping, scrolls the row into view and flashes it for 1700 ms (ed-flash). Add Action opens the composer ready to add. Make Top-Level resets PredecessorId to 65535. When nothing is wrong: No issues found — the profile looks coherent.
The fourth row is a real defect and a real diagnostic — a PredecessorId outside the group cannot be honoured — and both of its strings were wrong about the model; they shipped reworded with the removal of the dropdown. It used to read #<n> has a broken "Run after" link (# <p>), naming a link as "Run after" when it is a gate, and its toast read "Run after" link cleared; it now reads as in the table and its toast is Binding #3 is now top-level — active whenever its own trigger fires. The condition, the button and the fix were always right; only the description was not. The row now names the binding, says what a gate is, and points at what the button on the same row does. lib/nesting.js validate() is the authority on which links are broken and codes them (self-reference, dangling-parent, cycle, max-depth) — this check is the Builder's own narrower version, on the raw PredecessorId before normalisation.
The OLED canvas inside the Builder
Every OLED image field is a 384×192 canvas showing a 64×32 bitmap at 6 px per pixel, drawn as #c6f542 on #0d0d10.
- Click toggles a pixel; Shift-click or right-click erases it; dragging paints.
- Buffers are keyed by canvas id, so a drawing survives pane re-renders and two canvases in the same session are independent.
- Paste image tries the async Clipboard API for an
image/png; if that is unavailable or denied, it falls back to a globalpastelistener armed for 8 seconds, and the button label becomesPress Ctrl+V…until it succeeds or expires. The global handler ignores pastes while focus is in an input, textarea or contenteditable field. - Clear resets the buffer to blank.
- A pasted PNG is scaled into 64×32 and thresholded on luma,
0.299R + 0.587G + 0.114B > 128, because the target is 1-bit.
Pasting an animation from the OLED Editor
The OLED Editor's Copy animation button writes plain text to the clipboard:
SWIFTPOINT-OLED-ANIM;v1;{"fps":6,"frames":["<base64>","<base64>", ...]}
Each frame is a base64 64×32 bitmap in exactly the .spcf Image encoding, so nothing has to be converted later. The Builder recognises the payload with ^SWIFTPOINT-OLED-ANIM;v1;(\{[\s\S]*\})[;\s]*$, rejects it unless frames is a non-empty array of non-empty strings, and falls back to 6 fps when fps is missing.
Pasting has the same two paths as an image: the clipboard API first, then an armed global paste (button label Press Ctrl+V… for 8 seconds, No animation in clipboard if the text is not an animation). A preview strip of frame thumbnails appears with a readout of the form 4 frames @ 167 ms · ~6 FPS · 0.7s total; the Frame duration field overrides the fps-derived timing (minimum 10 ms) and updates the readout live.
Committing the animation expands it into real output actions:
| Step | What is written |
|---|---|
| Per-frame duration | frameMs if it is at least 10, otherwise round(1000 / fps) |
| Stored duration | max(1, round(ms / 10)) into Duration10ms |
| One action per frame | oledFlash with Mode: "image", the frame's base64, that duration, and Options: "wait" |
| Insertion point | at the edited index when editing, otherwise appended |
| Loop while held | gotoOutputAction { Index: insertAt, LoopCount: 0 } after the last frame |
| Stopping the loop | when the target list is the activation list and the deactivation list has no stopLooping yet, one is added with SkipWaitActions: true |
The stopLooping only appears for the activation list, because that is the only case where "while held" has a release to stop on, and it is never duplicated. The animation itself is not a node in the file, so reopening the composer starts from an empty animation (loop: true is the only remembered default).
Undo, redo and the local draft
Undo/redo is snapshot-based. snapshot() captures the header, a JSON.parse(JSON.stringify(state.doc)) deep clone, the profile id and a shallow copy of the profile meta. pushHistory() must be called before a mutation, truncates any redo branch, and the stack is capped at HISTORY_LIMIT + 1 (50 undoable states). applySnapshot() restores everything, re-derives the group list, drops an out-of-range selection, and closes any open composer, whose base node belongs to the document that was just replaced. Loading a document calls resetHistory().
One guard keeps drags from flooding the history: rangeDrag.active is set on the first pointer movement of a range-plot drag and cleared on pointer up, so a drag of any length pushes one snapshot.
The draft (sph:editor:draft, version 1) exists because publishing and reloading are the two things that reliably take an in-memory profile with them. It is written 600 ms after every change that marks the document dirty, and immediately whenever the page is about to navigate. Its payload is { v, at, header, doc, profileId, profileMeta: { title, game, description }, pendingPublish }. At boot it is rejected unless v === 1 and doc.RootProfile exists — a draft from a different format version is not readable by this build.
Boot order is deliberate:
?id=Nwins. The draft is not even read, because following a link to somebody's profile has to open theirs.- Otherwise the draft is restored, with the status line
Draft restored from this device — New profile discards it.and a toast. If the draft was waiting on a sign-in, publishing resumes. - Otherwise a blank profile is created and the first-run panel is shown.
Note that step 1 runs loadNew() before fetching, which clears the draft — opening a deep link discards an unsaved local draft. New profile in the tool strip does the same on purpose.
beforeunload warns while the document is dirty, and #ed-dirty in the tool strip carries the same state.
Saving, publishing and the 401 path
buildSpcfText() is the single way a file is produced: fixGroupCounts(), serializeDoc(state.doc, state.header), then parseSpcf() on the result as a self-check. downloadProfile() and onSave() both go through it, and test/profile-format.test.js asserts that neither can drift away from it.
Download is disabled until a document exists. The filename is the profile title slugified ([^a-z0-9_-]+ becomes _, trimmed, falling back to profile) plus .spcf. A parser failure toasts the message and produces no file.
Save reads Save / publish profile for a document with no hub id and Save changes for one with; with no document at all the button is disabled and reads Open or create a profile first.
| Case | Request |
|---|---|
Existing profile (PUT api/profiles/:id) |
multipart with the file field named edited.spcf |
New profile (POST api/profiles) |
multipart title, game, description and the file named <slug>.spcf |
The publish modal (#publish-backdrop) collects #pub-title (80 characters, required — an empty title toasts A title is required.), #pub-game (80 characters, backed by a <datalist id="pub-games"> filled from GET api/profiles/games) and #pub-desc (400 characters). On success the id from the response becomes profileId, the document is marked clean, and the status line reads Published as profile #<n>.
The 401 path. Publishing needs an account and the account is Discord, which is a whole page away at the end of a redirect — and an hour of binding choices would otherwise be lost. So askToSignIn(meta) copies the title, game and description into state.profileMeta, sets pendingPublish, and writes the draft synchronously (not on the 600 ms timer) before the navigation. It closes the publish modal and opens:
Publishing puts the file on the hub, so it needs an account. Signing in opens Discord and brings you straight back here — your profile and the details above are kept on this device until you do.
with Keep editing (clears pendingPublish) and Sign in with Discord (SPHAuth.loginUrl(), or api/auth/discord/login?returnTo=<current path + query>).
On the way back, resumePublish() waits for the session and only reopens the publish dialog once it really says loggedIn; otherwise the status line reads Signed out — sign in to finish publishing this profile. and the flag stays set for the next attempt. On success the dialog reopens and the toast reads Signed in — your profile is still here, finish publishing.
Automation hooks for the recorder
The Builder exposes window.EDHooks so the Walkthrough recorder can drive and observe it without reaching into editor state:
| Hook | Purpose |
|---|---|
getDoc(), getBuildText(), getMeta() |
the document, the built .spcf text and the title/game/description for export |
adopt(text, meta) |
load a document into the workspace |
setView(view), getView() |
switch to a view, validated against the actual tab buttons so a renamed view cannot break replay |
selectMapping(index, match) |
select a binding by index or by label text, for a step whose title names the binding it produced |
getMappingCount(), showPane(mode) |
how many bindings the layer has; close/add clear the selection, open keeps it |
getReplayState(), setReplayState(next) |
the recorded { view, group, button, mapping }, applied in one render |
clearSelection(), getSelection() |
clear, or read the selection in the terms the recorder needs |
onDocReset, onBeforeEdit, onEditCommit |
lifecycle callbacks the recorder uses to track changes and render boundaries |
Keyboard shortcuts
| Shortcut | Action |
|---|---|
Ctrl/Cmd + S |
save changes / publish |
Ctrl/Cmd + Z |
undo |
Ctrl/Cmd + Shift + Z, or Ctrl + Y |
redo |
Ctrl/Cmd + Shift + D |
duplicate the selected mapping |
Delete / Backspace |
remove the selected mapping (suppressed inside input, textarea and select) |
Ctrl + / |
toggle the shortcut-help overlay |
Escape |
close the help overlay, otherwise clear the selection (the pane stays and shows the add form) |
Escape in the layer filter |
clear the filter only |
Escape with the first-run panel open |
close the panel |
Ctrl/Cmd + K |
command palette — owned by chrome.js, not editor.js |
Ctrl + V |
paste an OLED image or animation into an armed or focused canvas |
ArrowLeft/ArrowRight, Home, Enter, Space |
resize the focused panel separator (Shift for the larger step) |
The overlay's own footer line reads:
Tip: drag a
.spcffile onto the page to open it, or load a hub profile via/editor?id=<number>.
Validation and diagnostics on the way out
Every save re-validates: the serialised text is fed back through the shared parser before it is downloaded or sent, so a Builder bug cannot publish a file the driver would reject, and the failure is reported in the user's own terms. On top of that, collectDiagIssues() re-runs on every render, so the rail summary, the status-bar count and the Diagnostics tab always describe the current document — including the coherence rules that matter to the device: a group with nothing in it, a binding that would do nothing, a dangling nesting link, and two deep zones fighting over the same part of the force axis.
What the Builder never does
- It does not upload anything until you publish. Opening, editing, downloading and the local draft are all client-side; the only outbound requests are the deliberate ones (a hub profile you asked to load, a publish or save you pressed, and the auth round trip).
- It is not indexed.
public/editor.htmlships<meta name="robots" content="noindex,nofollow" />. - It does not record without an admin session.
#btn-recordishiddenin the markup; the server injectsdata-can-record="1"on<body>for admin sessions (routes/seo.js) andrecorder.jsreveals the button only then. See the Walkthrough recorder. - It does not write profiles by hand. Every file goes through
serializeDocin the writer, and the JSON tab is read-only. - It does not renumber nesting links by hand. Inserting, deleting and moving a binding goes through
nesting.insertChild()/removeNode()/moveNode(), which return a remap that every caller applies: a reference above a moved index shifts with it, a reference to a dropped parent becomes65535, and a node with followers is refused outright unless the caller passesopts.reparentChildren. The olddeleteMapping()/moveMappingTo()pair did that bookkeeping by hand and was the only place in the codebase that knew the invariant. - It does not let you create a sequence, because the file cannot express one. There is no per-mapping delay field and no "runs after" toggle; the only structure is a tree of gated triggers, bounded at 20 edges.
Related: SpCF format · OLED Editor · Checker · Gallery and profiles · Walkthrough recorder · Markdown blocks · Known gaps