SWIFTPOINT//HUB
Support

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 .spcf has no macros and no sequences. PredecessorId is input nesting: a binding indented under another is active while the trigger above it is held. No if/else, no timed order, no priority, and OutputActionSequenceDelay is 0 in all 1026 stored mappings and is not exposed in the UI. The Builder's nesting tree, the Checker's Held-button nesting section and the profile page's Nested bindings section 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> with padding-left: 6 + depth * 12 px and a count badge. A layer with no mappings shows a 0 badge carrying the title No bindings yet.
  • #layer-filter filters by name (substring, case-insensitive). Escape in the box clears it and stops propagation, so the editor's own Escape (clear selection) does not fire as well. A filter that matches nothing says No 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-progress reads N / 16 bound and completes when all 16 controls in BUTTON_SECTIONS have at least one light press or deep zone.
  • #profile-meta shows the name, hub #N or not published, and N group(s) · N mapping(s) counted over the root and every group.
  • #diag is the compact diagnostic summary: N issue(s) in the warn state, Looks coherent when 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-open does 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, not MouseButtonLeft);
  • its holder, when BUTTON_BY_ID defines one — no entry does today, so this slot never renders;
  • a deep tag when the control has a force sensor;
  • a count badge with the number of bound stages;
  • an inline list of every bound stage: light rows and zone 1..n rows with their start–end% range, each with the label and the action summary, or not bound when 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 Press holds 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 after dropdown 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 after dropdown (with — none — standing for 65535) 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–10000 ms) configured per-step timing for a sequence. There is no sequence and no timing: the field is 0 in all 1026 stored mappings across all 48 profiles. The writer still emits 0 for 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 global paste listener armed for 8 seconds, and the button label becomes Press 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:

  1. ?id=N wins. The draft is not even read, because following a link to somebody's profile has to open theirs.
  2. 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.
  3. 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 .spcf file 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.html ships <meta name="robots" content="noindex,nofollow" />.
  • It does not record without an admin session. #btn-record is hidden in the markup; the server injects data-can-record="1" on <body> for admin sessions (routes/seo.js) and recorder.js reveals the button only then. See the Walkthrough recorder.
  • It does not write profiles by hand. Every file goes through serializeDoc in 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 becomes 65535, and a node with followers is refused outright unless the caller passes opts.reparentChildren. The old deleteMapping() / 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