SWIFTPOINT//HUB
Support

OLED Editor — the 64×32 pixel editor

This file documents the 64x32 1bit pixel editor at /oled: the canvas and its overlays, all three zones of the tool strip, the layer and object panels, the frame

This file documents the 64x32 1-bit pixel editor at /oled: the canvas and its overlays, all three zones of the tool strip, the layer and object panels, the frame timeline and keyframes, the modals, and the two ways work leaves the page — the export menu and the "To profile…" injection. Every shortcut, id, colour and limit below was read out of public/oled.html, public/oled/js/oled-editor.js and public/oled/js/spcf-inject.js.

What it is

/oled is a static page. Four files do all the work:

public/oled.html
public/oled/js/oled-editor.js
public/oled/js/spcf-inject.js
public/oled/css/oled-editor.css

routes/seo.js serves public/oled.html for GET /oled, injects a canonical URL, description, OpenGraph block and one WebApplication JSON-LD block with applicationCategory: 'DesignApplication', adds a <base href> element, and redirects GET /oled.html to /oled with a 301. Nothing about the page is server-rendered beyond that head injection.

Structural notes, all verifiable in public/oled.html:

  • The <body> carries data-app-shell, the same attribute the Profile Builder uses, so the shared header chrome (js/chrome.js) treats it as a tool page rather than a document page.
  • There is no footer. It is the only tool on the site without one; the editor is the whole viewport.
  • The tool strip is not a separate toolbar. It is one .tbar.tbar--rows block inside the shared <header class="hdr">, written into the <!-- header:start --> / <!-- header:end --> sync markers by scripts/sync-header.js. test/header.test.js asserts that public/oled/css/oled-editor.css contains no .toolbar { rule, so it cannot drift back into being a second bar.
  • .tbar--rows exists for this page. The strip carries more controls than fit any viewport, so instead of one scrolling ribbon it stacks its zones vertically. There are three zones in the markup — .tbar-start, .tbar-center and .tbar-end — and the three "tool strip" sections below are those zones, in DOM order.

There is no persistence anywhere in this tool. A search of public/oled.html and everything under public/oled/ finds no localStorage, no sessionStorage and no indexedDB reference. The editor's entire state lives in closure variables in oled-editor.js (frames, currentFrame, pixels, activeLayerIdx, undoStack, redoStack, keyframes, the tool and zoom flags). A reload — or navigating away and coming back — discards the drawing completely. There is no autosave, no draft key and no server round trip.

That leaves exactly two exits from the page:

Exit Where What leaves
The Export menu ↧ Export in .tbar-end PNG, sprite sheet, ZIP, clipboard image, clipboard text
The "To profile…" CTA #btn-profile, .tbar-btn--primary.tbar-cta A rewritten .spcf file

See Known gaps for what the absence of persistence costs.

The canvas

The drawing surface is #canvas-area, which contains #canvas-frame. Inside the frame:

#canvas-area
└── #canvas-frame
    ├── canvas#oled-canvas        64 x 32   the pixels
    ├── canvas#oled-overlay       64 x 32   selection, shape preview, ghosts
    ├── .guide-overlay#guide-overlay
    │   ├── div#guide-line-v
    │   └── div#guide-line-h
    ├── div#grid-overlay
    └── .frame-bezel-glow

Both canvases are declared width="64" height="32" and are never resized — they are real 64x32 pixel buffers scaled up by CSS. That is why every drawing operation can work in whole display pixels.

Zoom. A ResizeObserver on #canvas-area calls applyZoom() on every size change. setZoom(z) clamps to 1–8 and the zoom buttons and keys step by 0.5. At level 1 the inline width and height on #canvas-frame are cleared and the zoomed class is removed; above 1 the frame is sized from the available area (height - 24, capped by width - 24, aspect preserved) multiplied by the zoom level. updateZoom() then writes the real rendered scale into #status-zoom as a percentage.

Pointer mapping. canvasToPixel(e) measures #canvas-frame, computes s = rect.width / 64, and floors the offset into a column and row clamped to 0–63 and 0–31. The pointer is captured on pointerdown, so a drag that leaves the frame keeps drawing.

Colours. All four are RGBA arrays at the top of oled-editor.js:

Colour Value Used for
ON rgb(124, 255, 227) A lit pixel
OFF rgb(5, 8, 10) A dark pixel
ONION_PREV rgb(255, 150, 38) The onion ghost from the previous frame
ONION_NEXT rgb(70, 205, 255) Declared but never drawn

ONION_NEXT is a real constant in the file, and a search of the directory finds exactly three references to the two onion constants: the two declarations and one use of ONION_PREV inside render(). The next-frame cyan ghost is not implemented. The Onion checkbox therefore ghosts only the previous frame, wrapped around at the top of the timeline.

render() composites one ImageData: current frame pixels in ON, else the onion ghost in ONION_PREV, else OFF. Layers are flattened by renderFrameBuffer(fi), which starts from the frame's background pixel array and paints the objects of every visible layer into it.

The overlay canvas is separate so nothing is committed before the pointer lifts. It carries:

Overlay Colour Source
Object selection outline rgba(255, 200, 0, 0.9), 0.5 wide, dashed renderObjSelection()
Object corner and mid-edge handles rgba(255, 200, 0, 0.95) renderObjSelection()
Marquee rectangle rgba(198, 245, 66, 0.95), dashed selectPointerMove/Up
Shape drag preview rgba(124, 255, 227, 160) renderShapePreview()
Text / symbol ghost rgba(124, 255, 227, 180) renderTextPreview(), renderSymbolPreview()
Moving region ghost rgba(124, 255, 227, 220) selectPointerMove()

#grid-overlay is a CSS background whose background-size is set to one display pixel in each direction, recomputed whenever the frame resizes, and hidden with display: none when the Grid checkbox is off. The two guide lines are empty divs whose .visible class is toggled.

The tool strip, row 1 (.tbar-start) — tools, symbols, text

Every plain tool button is a .tbar-btn[data-tool], and the script binds them by iterating document.querySelectorAll('.tbar-btn[data-tool]'). The keyboard map in the keydown handler covers exactly the same set.

Tool data-tool Shortcut Notes
Pixel pixel P Default tool; live-mirrored while dragging
Erase eraser X Writes 0; live-mirrored
Select & Move select S Title in the markup is "Select & Move (S)"
Line line L Bresenham, committed as a line object
Rect rect R Outline, committed as a rect object
Fill R filledRect Shift+R
Tri triangle G
Fill T filledTriangle Shift+G
Circle circle C Midpoint ellipse
Fill C filledCircle Shift+C
Diamond diamond M Markup title: "Diamond / Rhombus (M)"
Fill D filledDiamond Shift+M
Text text T Reveals #text-section
Fill fill F Flood fill, 4-way stack fill
Symbol symbol — Not a data-tool button; see below

A shape is only committed if the drag covers at least two pixels in both axes (if (w < 2 && h < 2) return). A committed shape becomes an object on the active layer with the tool's own type string, x/y/w/h from the drag's bounding box, rotation: 0, color: drawColor and an empty data.

The Symbol dropdown

#btn-symbol opens #symbol-dropdown (with a #symbol-backdrop click-catcher). Clicking an entry in #symbol-grid sets activeSymbol, reads the scale from #symbol-size and calls selectTool('symbol') — which is why symbol has no button of its own in the strip.

The palette is the SYMBOLS table: 73 entries, every one w: 5 wide with rows of exactly five 0/1 characters (a test/ui-shape.test.js assertion). Some glyphs are six rows tall; the source comment explains that a six-row face reads better than a five-row one.

SYMBOL_HIDDEN withholds 21 of them from the grid, so 52 are offered:

xmark, play, pause, stop, ff, rew, vol,
chev-u, chev-d, chev-l, chev-r,
trash, download, upload, menu, check,
star5, target, battery2, search, flag

The reasoning is in the source comment, and it constrains any future tidy-up: a saved animation names its symbol by key, not by pixels. A .spcf written by an older version of this editor may insert a smiley, a battery2 or a flag, and that key has to still resolve to the same bitmap or an old profile comes back as a blank box. The hidden keys are therefore kept in SYMBOLS permanently and merely not offered — they duplicate a control the editor already has (play, pause, stop, ff, rew, the chevrons, the trash, the download, the menu), and a palette where half the entries are "a play button" is a palette nobody scrolls through to find the heart. test/ui-shape.test.js asserts that a named list of keys is still present.

Each palette button renders its own tiny canvas at 1:1 in #c6f542. The dropdown also carries the size select #symbol-size: 1x / 2x (default) / 3x / 4x. symbolScale is applied as a whole-pixel block scale when the symbol is committed as a symbol object.

The text options

#text-section is hidden in the markup and is only revealed by selectTool('text'); selecting any other tool hides it again.

Control Id Values Default
Text field #text-input maxlength="12" empty
Size #opt-text-size 1x … 5x 2x
Gap #opt-text-gap 0, 1, 2 1

Text is uppercased before it is rasterised (text.toUpperCase() in getTextPixels), and the object-properties Text field uppercases on input too. The glyphs come from the built-in FONT table: 5x7 bitmaps for 0–9, A–Z, space and . , ! ? : - + / ( ) _ * # @ $. Lowercase is not a separate design — a loop at the bottom of the table copies each uppercase bitmap to its lowercase code point, so a renders as A. A character with no glyph falls back to ?. The gap is a whole-pixel gap between glyph cells, and the total width is chars * (5 * scale + gap) - (gap > 0 ? gap : 0).

Clicking with the Text tool creates a text object whose w/h are the rasterised bitmap's dimensions; renderObject re-derives the scale as max(1, round(h / 7)) when it repaints, so the object's height is the authority, not the stored scale.

The tool strip, row 2 (.tbar-center) — transform, mirror, guides, examples

Control Id Effect
Flip horizontal #btn-flip-h Mirrors the current frame's background pixels left-right and sets o.x = W - o.x - o.w on every object of the active layer
Flip vertical #btn-flip-v The same vertically, adjusting o.y
Invert all pixels #btn-invert Inverts every background pixel and flips every active-layer object's color

All three push an undo snapshot, clear the selection and refresh the thumbnail. Note the scope: the background is the current frame's whole pixel array, but the objects are only the active layer's. Objects on other layers do not move.

Mirror

Four icon buttons carrying data-sym, with aria-pressed on exactly one:

Id data-sym Shortcut Mirrors
#sym-none none — Nothing (default)
#sym-v v V Across the vertical axis (W - 1 - x)
#sym-h h H Across the horizontal axis (H - 1 - y)
#sym-both both B Both, plus the 180-degree rotation

Symmetry is live, not a post-process: mirrorPixels() writes every one of the four reflected positions while a pixel drag is in progress, and mirrorShape() does the same for a shape preview, so the commit already contains the mirrored copies. The keyboard toggles are on/off switches between none and the chosen axis, not a cycle through all four.

Guides

Id data-guide Shortcut Line
#guide-v v D #guide-line-v, vertical centre
#guide-h h Shift+D #guide-line-h, horizontal centre

Toggling adds or removes the .visible class on the corresponding div.

Examples

#btn-examples opens #example-dropdown, which is fixed to the viewport and positioned from the button's box (placeExampleDropdown()), because the strip is an overflow container that would clip an absolutely positioned child. The same mechanism and the same reasoning are used for the Export menu.

Sixteen entries, all present in the markup:

Example data-example Frames FPS set Prompts?
Countdown 10→0 countdown 11 4 no
3D Rotating Cube cube 24 12 no
Loading Spinner spinner 8 8 no
Bouncing Ball bounce 16 12 no
Heartbeat heart 4 4 no
Scrolling Text marquee 64 + text width 10 yes
Sound Wave wave 20 12 no
Space Invader invader 16 4 no
Equalizer bars 16 12 no
Pong pong 24 10 no
Rain rain 20 12 no
Pac-Man pacman 20 8 no
Loading loading 6 4 no
Clock clock 12 2 no
Typewriter typewriter text length + 4 4 yes
Progress Bar progress 11 6 no

Two of the sixteen prompt, both through window.prompt:

Scrolling Text:  prompt("Enter scrolling text:", "SWIFTPOINT Z3")
                 -> uppercased, sliced to 40 characters, three trailing spaces appended
                 -> frame count is 64 + the rasterised width of that string at scale 2

Typewriter:      prompt("Enter text to type:", "SWIFTPOINT")
                 -> uppercased, sliced to 30 characters
                 -> frame count is text.length + 4 (the characters, then a settle)
                 -> picks the largest scale from 4 down to 1 that fits within 62 x 30

Both prompts are wrapped in try/catch, so a context that refuses prompt falls back to the default string rather than throwing.

Every example is generated into Uint8Array buffers by scratch helpers (sSet, sLine, sFillRect, sRect, sFillCircle, sArc, sText) — the bitmap-only cousins of the interactive tools. Committing runs commitFrames(list, fps), which clears the keyframe set, resets currentFrame and activeLayerIdx to 0, writes the example's FPS into #frame-fps, and calls switchFrame(0), which also empties the undo and redo stacks. The status bar flashes Example loaded: N frames.

The tool strip, row 3 (.tbar-end) — ink, history, export

Control Id Behaviour
ON / OFF ink split #ink-toggle, two .ink-half Sets drawColor to 1 or 0; Space toggles
Grid #opt-grid Checkbox, checked in the markup
Undo #btn-undo Ctrl+Z; disabled when the stack is empty
Redo #btn-redo Ctrl+Y; disabled when the stack is empty
Clear all pixels #btn-clear Empties the current frame's background and empties every layer's object list
Export menu #btn-export-menu Six entries, below
To profile… #btn-profile Opens the .spcf modal

MAX_UNDO = 100. pushUndo() snapshots the current frame's pixels, its layers (objects shallow-copied with their data spread) and the active layer index, shifts the oldest entry off when the stack exceeds 100, and clears the redo stack. Both stacks are emptied by switchFrame() — undo does not cross a frame boundary.

The Export menu is positioned against the viewport like the Examples menu and flips above the button when there is no room below. Opening it closes the Examples menu, and test/ui-shape.test.js asserts that openExportDropdown() calls closeExampleDropdown() — two dropdowns open over one toolbar is worse than either. The menu items kept their original ids, so every handler still binds to them; the menu's own click listener only closes the dropdown and deliberately does not re-dispatch the click, which would fire the handler twice.

Export entry Id Output
Import an image #btn-import Opens the Import Image modal via the hidden #import-file
Save this frame as PNG #btn-download swiftpoint-oled.png; flashes PNG saved
Export a sprite sheet #btn-export-sheet swiftpoint-oled-frames.png, 64 x frames.length wide
Export a ZIP with every frame #btn-export-zip swiftpoint-oled-frames.zip
Copy this frame #btn-copy-img PNG blob via navigator.clipboard.write + ClipboardItem
Copy the whole animation #btn-copy-anim Text payload via navigator.clipboard.writeText

The sprite sheet entry's label becomes Sheet (N) when there is more than one frame and reverts to Sheet at one frame; updateFrameStrip() is what rewrites it, so adding or deleting a frame updates it too.

The ZIP is written by hand. There is no compression library: createZip() emits store-method (compression method 0) entries with a local file header (0x04034b50), a central directory record (0x02014b50) per file, and an end-of-central-directory record (0x06054b50). crc32() is the standard reflected CRC-32 with polynomial 0xEDB88320. Entries are named frame-001.png, frame-002.png, … (String(i + 1).padStart(3, '0')), each decoded from the canvas's data URL back into bytes with atob.

The text protocol written by "Copy the whole animation" is a single string:

SWIFTPOINT-OLED-ANIM;v1;{"fps":6,"frames":["<base64 256 bytes>","..."]}

Each frame is packed by frameToSpcfB64() into exactly 256 bytes in the .spcf bit layout (see Why 64x32 and why 256 bytes) and base64-encoded. The FPS is read from #frame-fps, floored at 1. On success the status bar flashes Animation copied — paste it in the Profile Builder (Ctrl+V); on clipboard refusal it flashes Copy failed.

The OLED tool's own social card renders a real specimen rather than describing one: lib/og-card.js builds a 64x32 bitmap in oledFrame() — the text CS2, a rule, 800 DPI, a battery outline and a deep-click track — and toolCard() draws it at scale 6 on /og-image/tool-oled.png. The glyph shapes there come from og-card.js's own GLYPHS table, a separate copy of the same 5x7 letterforms the editor uses, not a shared import.

Layers (#layer-panel)

The right sidebar lists the current frame's layers in #lp-list. Header controls:

Button Id Behaviour
+ #btn-layer-add Appends Layer N and makes it active
copy #btn-layer-dup Inserts a copy below the active one, named <name> copy
bin #btn-layer-del Removes the active layer; refused at one layer
up #btn-layer-up Swap up; no-op at index 0
down #btn-layer-down Swap down; no-op at the last index

Each row is a .layer-item carrying:

  • a rendered thumbnail — a 64x32 canvas where the layer's objects are rasterised one at a time into a scratch buffer and painted in ON on a black field. The layer's background pixels are not part of the thumbnail.
  • the layer name and an object count rendered as N objects;
  • a visibility button (eye / dash) toggling layer.visible;
  • a lock button (closed / open padlock) toggling layer.locked.

Clicking the row makes the layer active. Double-clicking the name opens a prompt to rename it. A layer is { id, name, visible, locked, objects }, and visible and locked default to true and false. A locked layer makes hitTest() return null, so the Select tool cannot pick its objects.

Object properties (#obj-props)

The block is hidden until something is selected, and #prop-text-row is hidden unless the selection is a text object.

Field Id Range
Type #prop-type Read-only text, the object's type string
X #prop-x min=0, max=63
Y #prop-y min=0, max=31
W #prop-w min=1, max=64
H #prop-h min=1, max=32
Color #prop-color ON (1) or OFF (0)
Text #prop-text maxlength="12", text objects only, uppercased on input
Delete Object #btn-obj-delete Calls deleteSelection()

The numeric and colour fields fire on change; the Text field fires on input. Every application pushes an undo snapshot first. The declared min/max attributes are advisory — the handler assigns Number(input.value) without clamping, so a value outside the panel's range is stored as typed.

Frames and the timeline

.frame-strip holds a TIMELINE label, the frame buttons, the thumbnails in #frame-list, and the playback controls.

Control Id Behaviour
Add frame #btn-frame-add Inserts a blank frame after the current one
Duplicate frame #btn-frame-dup Inserts a copy after the current one
Delete frame #btn-frame-del Disabled at one frame; the handler also returns early
Move left / right #btn-frame-left / #btn-frame-right Disabled at the ends
Thumbnails .frame-thumb buttons in #frame-list Numbered, canvas thumbnail, Frame N tooltip
Onion #opt-onion Unchecked by default
FPS #frame-fps 2, 4, 6, 8, 12, 24
Play #btn-frame-play Disabled at one frame; label becomes ⏸ Stop while playing

Playback is a plain setInterval at 1000 / fps that calls drawFrameToCanvas() — it paints straight onto the main canvas and does not change currentFrame, so it does not disturb the undo stacks. Changing the FPS select while playing stops and restarts the loop. switchFrame() returns early while playing.

Deleting a frame removes its index from the keyframe set and shifts every higher index down by one; moving a frame swaps both the frames and their keyframe marks. addFrame and deleteFrame both route through switchFrame(), which is what clears the undo and redo stacks.

Keyframes (#keyframe-panel)

The panel is toggled by adding or removing the visible class — K is the shortcut. Controls:

Control Id Behaviour
+ Key #btn-kf-add Marks the current frame; flashes Keyframe at frame N
Delete #btn-kf-delete Unmarks it; flashes Keyframe removed
Interpolate #btn-kf-gen Fills the frames between consecutive keys
Timeline #kf-timeline One .kf-thumb per frame, keyframes styled is-key
Easing #kf-easing linear, easeIn, easeOut (default), easeInOut
Preview #btn-kf-preview Cycles the keyframes at the current FPS; relabels ⏹ Stop

Keyframes are a Set of frame indices, not a separate curve model. interpolateFrames() needs at least two (otherwise it flashes Need 2+ keyframes) and, for each pair, rebuilds every intermediate frame from the earlier one: object x/y/w/h are lerped with the easing function and rounded, objects without a counterpart in the target frame are carried over untouched, and a background pixel that differs between the two keyframes snaps to whichever side of t > 0.5 it lands on — there is no real pixel blending. On completion it flashes Interpolated N segments. previewKF() is a separate loop from the frame Play button, with its own timer.

The status bar

.status-bar is the last element in the document and holds no state of its own beyond the two live fields:

Element Id Contents
Coordinates #status-coords x, y under the pointer
Size — The literal 64×32
Zoom out #btn-zoom-out -, shortcut -
Zoom #status-zoom The real rendered scale as a percentage
Zoom in #btn-zoom-in +, shortcut = or +
Zoom reset #btn-zoom-reset reset glyph, shortcut 0
Message #export-msg Flash slot, cleared after 3000 ms
Shortcuts #btn-shortcuts ? Keys, opens the shortcuts modal
Home — Hub ↗ to ./

flashMsg() only clears the slot if the text is still the one it wrote, so a second message inside the window is not cut short by the first one's timer.

Modals

Import Image

#import-panel with #import-backdrop. It carries a live #import-preview-canvas at 64x32, a Threshold range #import-threshold from 0 to 255 defaulting to 128 with its value echoed into #import-threshold-val, an Invert checkbox, and Cancel / Apply.

Loading an image — via the Export menu, the hidden #import-file (accept="image/*"), or a paste — runs grayscaleFromImage(): the image is scaled to fit inside 64x32 preserving aspect ratio, centred on a black 64x32 canvas, and converted to luma with 0.299R + 0.587G + 0.114B. The modal then thresholds that buffer on every slider move, inverting first if the box is ticked.

Apply pushes an undo snapshot, appends a new layer named Imported, makes it active, and puts one image object on it holding the thresholded bitmap plus its source dimensions (data: { bitmap, bw, bh }), selects it and flashes Imported as object (bw x bh). renderObject scales that bitmap to the object's current w/h, so resizing the object resamples it.

A window-level paste listener is registered for the whole page: any clipboard item whose type starts with image/ opens this modal. That is the only path that accepts an image copied from elsewhere on the desktop — there is no drag-and-drop target for images.

Keyboard Shortcuts

#shortcuts-panel with #shortcuts-backdrop, opened by #btn-shortcuts or by pressing ?. The grid is static markup in public/oled.html and it is fully enumerated there — twenty-one <div> rows, each a <kbd> plus a label. The complete list, verbatim from the markup:

Row Key Label
1 P Pixel
2 X Eraser
3 S Select / Move
4 L Line
5 R Rectangle
6 Shift+R Filled Rect
7 G Triangle
8 Shift+G Filled Tri
9 C Circle
10 Shift+C Filled Circle
11 M Diamond
12 Shift+M Filled Diamond
13 T Text
14 F Flood Fill
15 Space Toggle ink
16 Arrows Nudge
17 Del Delete selection
18 Ctrl+Z/Y Undo / Redo
19 K Keyframe panel
20 Esc Cancel
21 ? This panel

There is no gap to fill in and no reason to invent rows. What the panel omits is listed under Keyboard shortcuts below.

"To profile…" — injecting an animation into a .spcf

#btn-profile opens #spcf-panel, whose handler lives in public/oled/js/spcf-inject.js. That file carries its own copy of the .spcf parser (parseSpcf, walkGroups, collectButtonMappings, groupByPath) rather than importing lib/spcfParser.js, so the OLED page loads exactly two scripts of its own and nothing else.

Step 1 — load a .spcf

#spcf-drop accepts a click on #spcf-choose, a file from #spcf-file (accept=".spcf,.spc,text/plain,application/octet-stream"), or a drag-and-drop that toggles a .dragover class. parseSpcf() strips a BOM, requires a header line matching /^json([,\s]|$)/, rejects a body containing a NUL byte as binary, JSON.parses the rest, and requires an object RootProfile with a non-empty Groups array. The caps are the same as the shared parser's: MAX_GROUPS = 100, MAX_MAPPINGS = 1000, MAX_NESTING = 20, each throwing a descriptive message.

walkGroups() then recurses through Groups, and for every group collectButtonMappings() returns each mapping whose InputActionType.Id is buttonToggle or forceRegionToggle. Each row shows the friendly button name (from the file's own BUTTON_LABELS table), an OLED badge when the activation list already contains an oledSet or oledFlash item, and a human trigger description built by describeItem() — Key A, Media Play/Pause, Mouse click [upOnRelease], Haptic, Wait 250ms, Set DPI, DPI 400, 1000 Hz, Lift-off, Game device, LED, OLED off, OLED image, loop→12, stop loop, and anything unrecognised rendered as its TypeId split on capitals. ActivationOutputName is prefixed when the mapping has one. The list shows N button / N buttons per group, indents nested groups by depth * 14 px, and shows (no button bindings in this group) for a group with none.

The header line #spcf-meta reads <name>.spcf · N groups · N button bindings. A profile with no button bindings at all renders No button bindings found in this profile — nothing to attach the animation to.

Step 2 — pick the mapping

Clicking a row stores its group path and its raw ActionMappings index, marks the row .active, enables #spcf-download, and writes #spcf-selected: Inserting on "Left Button" · currently: Key A · OLED frame. The replace checkbox is force-disabled when the chosen mapping has no existing OLED item, because there would be nothing to replace.

Step 3 — options

Option Id Values Default
Playback name="spcf-mode" Loop while held / Play once — hold last frame Loop
Replace existing OLED items on this button #spcf-replace checked / unchecked checked

#spcf-frames reports the current animation as Animation: N frames @ F fps · each frame ~D ×10ms · total ~T s, where D = max(1, round(100 / F)).

Step 4 — Download .spcf

makeAnimationItems() builds the chain. Per frame:

{
  "$type": "ZMouseConfig.OutputActionItems.OledSetOutputActionItem, ZMouseConfig",
  "DataType": "trigger",
  "Duration10ms": 25,
  "IsValid": true,
  "ItemType": "outputAction",
  "OledConfigurationParameters": { "Image": "<base64, 256 bytes>", "Mode": "image" },
  "Options": "wait",
  "TypeId": "oledFlash"
}

Duration10ms is max(1, round(100 / fps)), and Options: 'wait' means the frame blocks the rest of the macro while it is on screen. In loop mode a trailing item jumps back to the first animation item:

{
  "$type": "ZMouseConfig.OutputActionItems.GotoOutputActionOutputActionItem, ZMouseConfig",
  "DataType": "trigger",
  "Index": 0,
  "IsValid": true,
  "ItemType": "outputAction",
  "LoopCount": 0,
  "TypeId": "gotoOutputAction"
}

and a stopLooping with SkipWaitActions: true is pushed onto the deactivation list — but only if that list does not already contain one. Index is the activation-list index the frames were inserted at, so the jump lands on the first frame even when the mapping already had other outputs. In once mode the chain instead ends with an oledSet holding the last frame, which stays on the panel.

If #spcf-replace is ticked, the activation list is first filtered to drop every oledSet and oledFlash, so the new chain does not stack on top of an old one.

The document is re-serialised as header + '\n' + JSON.stringify(doc, null, 4), wrapped in a blob and downloaded as <original-name-without-extension>-animation.spcf. The header is the destination file's own, preserved verbatim. Errors are surfaced in #spcf-error — including Select a button binding first., Editor frames are not available yet., The selected mapping no longer exists in the profile. and Could not serialize the profile: ….

Step 5 — the Profile Builder handoff

The other direction uses the clipboard text protocol. In the Profile Builder, paste (Ctrl+V) the string from "Copy the whole animation"; parseAnimClip() in public/js/editor.js matches it against /^SWIFTPOINT-OLED-ANIM;v1;(\{[\s\S]*\})[;\s]*$/, requires a non-empty frames array of non-empty strings, and expands it into the same oledFlash chain. The per-frame duration is the editor's own frameMs field when it is at least 10 ms, otherwise round(1000 / fps); the pasted animation gets a preview strip showing its frame count, per-frame milliseconds, derived FPS and total duration.

See Profile Builder and The .spcf format for the other end of both flows.

Why 64x32 and why 256 bytes

The Z3's panel is 64x32 pixels, one bit each — 2048 bits, or 256 bytes. The layout, used identically by the editor's frameToSpcfB64(), spcf-inject.js's frameToSpcfImage() and the Checker's unpacking loop, is:

  • row-major: row 0 first, then row 1, and so on;
  • 8 horizontal pixels per byte, so 8 bytes per row and 32 rows;
  • MSB = the leftmost pixel of each group of eight: bit 7 is x=0, bit 0 is x=7.

The read side mirrors it: byte = bytes[y * 8 + (x >> 3)], on = (byte >> (7 - (x & 7))) & 1.

Three consequences worth knowing:

  1. A pasted PNG is thresholded on luma. The Import modal converts to 0.299R + 0.587G + 0.114B and lights a pixel when the value is strictly greater than the threshold, which defaults to 128.
  2. The image is scaled to fit and centred, not cropped: the fit factor is min(64 / width, 32 / height), the result is centred on a black field, and only the scaled rectangle is thresholded into the new object.
  3. A payload that does not decode to exactly 256 bytes is refused. decodeOledImage() in lib/spcfParser.js returns null unless the base64 decodes to (64 * 32) / 8 bytes, and the UI simply does not draw an item it could not decode — so a malformed or truncated image can never reach the canvas as garbage.

Keyboard shortcuts

Every handler in the editor's single document keydown listener. The listener returns immediately when the event target is an INPUT or a TEXTAREA, so typing in #text-input or #prop-text never triggers a tool.

Key Action Notes
P Pixel tool
X Eraser tool
S Select & Move tool
L Line tool
R Rectangle tool
Shift+R Filled rectangle tool
G Triangle tool
Shift+G Filled triangle tool
C Circle tool
Shift+C Filled circle tool
M Diamond tool
Shift+M Filled diamond tool
T Text tool Reveals #text-section
F Flood fill tool
V Toggle mirror vertical On/off, not a cycle
H Toggle mirror horizontal Shift+H falls through and does nothing
B Toggle mirror both axes
D Toggle the vertical centre guide
Shift+D Toggle the horizontal centre guide
Space Toggle the ink between ON and OFF Ignored when Ctrl is held
Ctrl+Z (or Cmd+Z) Undo
Ctrl+Y, or Ctrl+Shift+Z Redo The modal only documents Ctrl+Z/Y
Delete / Backspace Delete the selection Select tool, with an object or a marquee
Arrow keys Nudge by one pixel Select tool, with an object or a marquee
K Show or hide the keyframe panel Also refreshes its timeline when showing
Esc Close shortcuts, close the import panel, clear the selection Also closes the export menu, and the .spcf modal via its own listener
? Open the shortcuts modal
= or + Zoom in by 0.5
- Zoom out by 0.5
0 Reset zoom to 1
Paste of an image Open the Import Image modal A window paste listener, not this handler
Ctrl+K Open the site-wide command bar Belongs to public/js/chrome.js, not to the editor

So the twenty-one-row modal grid is complete but not exhaustive: it omits the mirror keys V / H / B, the guide keys D / Shift+D, the three zoom keys, the Ctrl+Shift+Z redo variant and the image paste. Everything the modal lists is real; there is nothing in it that the code does not do.

What it does not do

  • No persistence of any kind. No localStorage, no sessionStorage, no IndexedDB, no server draft. A reload loses the frames, the layers, the keyframes and the undo history.
  • No undo across a frame switch. switchFrame() empties both stacks, so Ctrl+Z after changing frame is a no-op rather than a jump back to the previous frame.
  • No multi-object selection. One selectedObj, or one selRect of raw pixels. There is no shift-click, no marquee of objects, no group transform.
  • No import of an animation. The page reads images — the hidden image/* input and a clipboard paste — and nothing else. The SWIFTPOINT-OLED-ANIM;v1; text protocol is written by this tool and consumed by the Profile Builder; pasting that text into /oled does nothing, because the global paste listener only looks at clipboard items whose type starts with image/.
  • No whole-document transform. Flip, invert and clear reach the current frame's background pixels, and flip and invert reach the active layer's objects only.
  • No real pixel interpolation. "Interpolate" snaps differing background pixels to one side of the halfway point instead of blending them.
  • It is not indexed. public/oled.html ships <meta name="robots" content="noindex,follow" />, and the /oled route injects no robots tag of its own, so noindex is set, with follow. The route does inject WebApplication JSON-LD with applicationCategory: 'DesignApplication', an OpenGraph block, and og:image pointing at /og-image/tool-oled.png.