SWIFTPOINT//HUB
Support

Known gaps — what does not work yet

An honest catalogue of what does not work, what does not say what it means, and what is merely limited. Every entry names a file so it can be confirmed or dismi

An honest catalogue of what does not work, what does not say what it means, and what is merely limited. Every entry names a file so it can be confirmed or dismissed in a minute, and gives the smallest change that would resolve it.

How to read this file

These are real, reproducible findings in the code as it stands, not a wish list. Each entry gives where, the symptom, why it matters, and the smallest fix. Severity is High (a user-visible promise is not kept, or data is written wrong), Medium (a feature is reachable but incomplete, or a screen disagrees with itself) or Low (dead code, a hard-coded constant, a cosmetic mismatch).

Findings were made by reading the source. A few were confirmed against the shipped SQLite database; those carry the counts as of 2026-09-30 and will move.

The nesting rework of 2026-10-01 moved several findings out of this file rather than deleting them: they are collected under Fixed by the nesting rework and Still open after the nesting rework near the end, in the same format, because the evidence is what makes a finding checkable and that evidence does not stop being true.

Behaviour that is not what the UI says

1. The markdown editor's Ctrl+B / Ctrl+I / Ctrl+K are dead — Medium

Where: public/js/markdown-editor.js:510.

var b = BUTTONS.filter(function (x) { return x.cmd === cmd; })[0];

BUTTONS is never declared. The module declares FORMAT_BUTTONS (line 51), BLOCK_BUTTONS (line 82) and ENTRY_BUTTONS (line 101) — so the keydown handler throws a ReferenceError, and it throws after e.preventDefault() has already run on line 509.

Why it matters: nothing happens and the browser's own default is swallowed, plus one uncaught console error. Shortcuts advertised in the toolbar tooltips silently do nothing. Smallest fix: change BUTTONS to FORMAT_BUTTONS; the toolbar buttons themselves work and their tooltips are correct.

2. The search box on /updates does nothing — Medium

Where: public/js/updates.js:28 and :79. searchEl (#up-search) is looked up once and then used exactly once — to write state.q into it from ?q=. There is no input or change listener on it anywhere in public/. The input listeners in updates.js:451 are on the update editor's title, version and summary fields.

Why it matters: a reader types into a search box and the list does not narrow. q only reaches the API through the URL or by clicking a tag chip. Smallest fix: one listener that writes state.q, calls writeUrl() and reloads.

3. The forum index has no pager — Medium

Where: public/js/forum.js:210 hard-codes limit: '60'; there is no pager markup in public/forum.html.

GET /api/forum/threads (routes/forum.js:202) accepts offset and threadList returns total (lib/forum.js:470).

Why it matters: beyond 60 threads the rest are unreachable from the index — only search or a direct link finds them. Smallest fix: send offset and append a "load more", using the total the API already returns.

4. There is no way to edit a thread title — Medium

Where: routes/forum.js has PATCH /categories/:id (:177) and POST /posts/:id/edit (:320) but no thread-update route, while the new-thread form's foot text (public/js/forum.js:295) says: "Yours to edit while you are at it, and to delete by a moderator."

Why it matters: the UI promises an edit the API does not offer. Only posts can be edited, and only their own. Smallest fix: a PATCH /threads/:id behind requireUser with an author check, plus a title field in the existing post-edit dialog.

5. The OLED editor has no persistence at all — High

Where: public/oled/js/oled-editor.js. There is no localStorage, no sessionStorage and no IndexedDB anywhere in the file.

Why it matters: a reload discards the drawing and the whole animation. The Profile Builder, by contrast, keeps an sph:editor:draft localStorage draft (public/js/editor.js:125) and says so on restore — so "your work is saved" is true in one tool here and false in the other. Smallest fix: the Builder's draft key and restore prompt.

6. The forum "Open tickets" sidebar shows more than it promises — Medium

Where: public/forum.html:182-183 — heading "Open tickets", sub-line "Everything not yet closed, oldest first." The list is filled at public/js/forum.js:230 from data.latest, which is threadList({ sort: 'activity', limit: 8 }) over every category (routes/forum.js:155).

Why it matters: the list is most-recent-first and includes closed tickets and ordinary discussion threads; the heading and the sub-line describe something else. Smallest fix: filter to open tickets sorted oldest-first, or reword both lines.

7. The ticket status tooltips are just the labels — Low

Where: lib/forum.js:66-69 defines a hint for all four states. public/js/thread.js:93 builds each button's title from s.hint || s.label, but the STATUSES list it maps over (public/js/thread.js:33) is a local literal with no hint property.

Why it matters: the server's hints never reach the UI, so the || s.label fallback always wins and the tooltip repeats the label. Smallest fix: have the status bar render from the server's list, or add the hints to the local literal — the latter is worse, because it puts the same fact in two files.

8. The Builder's first-run panel does not appear on every open — Low

Where: public/editor.html:406 says the panel "is shown every time the Builder is opened, not only the first time." In public/js/editor.js, initStart() (defined at :4956) is called only on the else branch at :4915 — the branch with no draft.

Why it matters: it shows on every boot that does not restore a local draft, so a reader who returns to a draft never sees the four ways in. test/editor-first-run.test.js encodes the stronger claim, so the test changes with whichever way this goes. Smallest fix: call initStart() unconditionally, or reword the comment and the test.

9. The admin panel's report queue mislabels forum-post reports — High

Where: routes/admin.js:124-127. The query joins users, profiles (for target_type = 'profile') and comments (for target_type = 'comment') — and neither forum_posts nor forum_threads. It emits no threadId or postId.

public/js/admin.js:142 renders every non-comment report as "Profile report"; :135 prints the target as profile "unknown" when profileTitle is null; :136 builds the View link as ${BASE}/profiles/${encodeURIComponent(r.profileId)}, which is /profiles/null for a post report.

Why it matters: the moderation half of the contract already exists — routes/reports.js:136 joins forum_posts and :158-159 emit threadId and postId. The admin query does not use it, so the panel is the one place a moderator cannot act on a reported post. Smallest fix: copy those joins and fields into routes/admin.js, and branch the label and the link on targetType in public/js/admin.js.

10. Post reports can be filed by members but only cleared by admins — Medium

Where: routes/reports.js:61 is requireUser, and a targetType: 'post' report notifies moderators so they can act (:96). But the triage endpoints — GET / at :120 and POST /:id/status at :169 — are both requireAdmin.

Why it matters: a moderator is told about a bad post and then cannot close the report. The two halves of the contract disagree about who moderation is. Smallest fix: requireModerator on the two triage routes — and change the test that asserts the admin gate alongside it.

11. The publish toast on /records is dead code — Low

Where: public/js/records.js:315 tests out.snippet. The server returns embed (lib/records.js:211), not snippet.

Why it matters: the branch never runs, so the reader gets "Published" instead of "Published — embed this in a guide". snippet is a client-side concept here (public/js/records.js:179); the server never used that name. Smallest fix: out.embed.

Data and format defects

12. The writer puts a key code into ButtonState for keyboard actions — High

Where: lib/spcfWriter.js:246 and :250:

case 'keyboardKey':
  out.KeyCode = Object.assign({}, B.KeyCode, { HidPageValue: keyHid(f.key) });
  setState(out, B, f, 'key', 'tap');
  break;
case 'keyboardMedia':
  ...
  setState(out, B, f, 'key', 'tap');

setState (lib/spcfWriter.js:367) reads f.key when field === 'key', otherwise f.buttonState. But the Builder's composer for both kinds produces { key: Number(v.key), state: v.state || 'tap' } — the field is named state, not buttonState (public/js/editor.js:1585 and :1593).

Why it matters: the HID key code lands in ButtonState. For a key whose HidPageValue is 9 you get "ButtonState": 9 where a state such as "tap" was meant. Builder-written keyboard actions do not round-trip through the parser. mouseButton (:257) and joystickButton (:272) pass 'button' and are correct.

The shipped database holds 1142 keyboardKey nodes across 46 stored profiles, every one with a string state ("ButtonState": "downOn"), so the risk is entirely on the Builder's output. Smallest fix: pass 'button' for both keyboard kinds and teach setState to fall back to f.state, or rename the composer's field to buttonState.

13. Writer and parser disagree on button ids — Medium

Where: the parser's BUTTON_LABELS (lib/spcfParser.js:174) is keyed on legacy ids — primaryLeft, mousewheelPull, triggerLeftPull, … The Builder's BUTTON_SECTIONS (public/js/editor.js:1945) emits short ids — left, right, middle, mousewheelPull, triggerLeftPull, …

buttonLabel() (lib/spcfParser.js:211) falls back to button.KeyName, then to the raw key.

Why it matters: for the ids with no legacy counterpart, a Builder-written file shows the raw key name where the Builder shows a friendly one. Smallest fix: add the short ids to BUTTON_LABELS alongside the legacy keys, so both spellings resolve. This one is now fixed — see F4 under Fixed by the nesting rework; kept here because the entry is the one a reader searching for MouseButtonLeft will land on.

14. Two more small writer/parser shape mismatches — Medium

Where and what:

Node Writer emits Shipped files carry
mouseScroll DataType: 'trigger' (lib/spcfWriter.js:361-365 defaults to trigger) 35 nodes with 'oneCount' and 14 with 'trigger' across 24 of the 46 stored profiles
DPI levels { XDpiRaw, YDpiRaw, isValid } 20 stored profiles use { IsValid, XDpi, YDpi }; 12 use the new shape

Why it matters: neither breaks the round trip — the parser tolerates both and test/spcf-format.test.js passes — but a Builder-written file differs in shape from every profile the site hosts, which is the kind of difference that shows up as "the mouse behaves slightly differently". Smallest fix: return oneCount for mouseScroll, and emit the legacy DPI spelling.

15. migrate() does not fully repair legacy game tags — Low

Where: lib/games.js:247:

const kept = tags.filter((t) => key(t) !== key(row.game));

It strips only the tag whose key equals the spelling stored in row.game. A row whose game is already canonical but whose tags still carry a legacy alias keeps that alias, because key(row.game) is the canonical key and never matches.

Why it matters: no current row violates it, and test/game-taxonomy.test.js:173 only asserts that the canonical game is among the tags — not that no tag is a known legacy alias. Smallest fix: filter against every alias whose canonical form is canonicalGame(row.game), and assert it in the test.

Where: lib/docs/index.js:649:

t = t.replace(/\[([^\]]+)\]\(([^)#]+?)\.md(#[^)]*)?\)/g, (m, label, target, anchor) => {
  return `[${label}](https://mecke.dev/swiftpoint/docs/${String(target).replace(/\//g, '/')}${anchor || ''})`;
});

The pattern matches any markdown link whose target ends in .md.

Why it matters: an external https://example.com/a.md becomes https://mecke.dev/swiftpoint/docs/https://example.com/a, and a relative ../profile-builder/deep-click.md becomes a docs URL that is not a docs URL. The rewrite is also the only place in that module that does not use SITE_URL — it hard-codes https://mecke.dev/swiftpoint/docs/, so another deployment sends Discord links to a site that is not this one. Smallest fix: require a relative target, and use SITE_URL.

Where: lib/docs/index.js:1049:

const prepared = closeDocsBlocks(preprocess(String(src || ''), () => null) || String(src || ''));

The wikilink resolver is hard-wired to () => null.

Why it matters: a wikilink shows as plain text in Preview even though it works once saved, while relative links, images and ::: steps all resolve — so the preview looks trustworthy in every other respect. Smallest fix: pass the real resolver, scoped to the guide being previewed.

Accessibility and markup

18. Copy buttons exist only on /docs — Medium

Where: public/js/docs.js:154 adds a "Copy" button to every .d-prose pre. That script is loaded only by the docs shell (routes/docs.js:192); public/forum.html, public/thread.html and public/updates.html do not load it.

Why it matters: a code block in a forum post or an update has no copy affordance, and a reader on a phone has to select the text by hand. Smallest fix: move the function into js/forum-common.js, which all three pages already load, and call it after the post renders.

19. The notes map's canvas requires JavaScript but the list does not — Low

Where: public/js/docs-graph.js. buildList() (:312) builds the overlay's list as real <li><a class="d-graph-row-a" href="…"> elements (:322) — but it builds them into #d-graph-list inside #d-graph, which starts hidden and is only revealed by open() (:613). canvasIsLive() (:103) already treats the list as the map on a narrow screen, so the list is the accessible fallback; it just is not reachable without opening the overlay. Why it matters: a keyboard or screen-reader user has to open a modal to reach a list that is already plain links. Smallest fix: render the list inline on the guide page when the canvas is not live.

Dead or unused code

20. PAD_AXES is not exported — Low

Where: public/js/export-helpers.js:18. PAD_AXES is defined and used internally at :98, but the module returns ten names (:215) and this is not one of them.

Why it matters: an external consumer — including the server-side share renderer (lib/share-image.js:10 requires this module) — cannot reach it. Smallest fix: add it to the returned object.

21. routes/docs-write.js declares an unused LIMIT — Low

Where: routes/docs-write.js:27, const LIMIT = 220_000;. It is never referenced. The real body cap is docsWrite.MAX_BODY (lib/docs/write.js:41, 200 000), applied at routes/docs-write.js:68 and :184.

Why it matters: a reader comparing the two constants would conclude the cap is 220 KB, when the transport cap is the app-wide 64 MB body parser (server.js:35) and the storage cap is 200 KB. Smallest fix: delete the constant.

22. The account-transfer template has an unreachable branch — Low

Where: public/js/admin.js:248:

el.transferUser.innerHTML = state.users
  .filter((u) => u.id !== profile.uploaderId)
  .map((u) => `<option value="${u.id}">${escapeHtml(u.username)}${u.id === profile.uploaderId ? ' (current owner)' : ''}</option>`)

The filter on line 247 excludes the current uploader, so the ternary on line 248 can never be true.

Why it matters: none functionally. It reads as though there is a case that is handled and there is not. Smallest fix: drop the ternary.

23. steps-player.js declares BASE twice — Low

Where: public/js/steps-player.js:29 and :36. The second var overwrites the first with the same expression.

Why it matters: harmless. Documented here so a reader does not spend time on it. Smallest fix: delete one.

24. Two producers write different version for the same document — Low

Where: buildRecDoc() on the recordings page writes version: 1 (public/js/records.js:288); the recorder writes version: 2 (public/js/recorder.js:905).

Why it matters: both still work — the player and the replay read v1 steps as well as v2 — but a recording made in the Builder and re-saved on /records is re-serialised by buildRecDoc() and silently downgrades its version marker. Smallest fix: have buildRecDoc() preserve the stored version, or bump it to 2.

Known design limits, not bugs

25. Single process

lib/events.js is an in-process pub/sub — a Map of userId to open responses — so an SSE notification reaches only the process that produced it. better-sqlite3 is synchronous, so there is no I/O concurrency to exploit anyway. A second instance would need a shared pub/sub and a session store that is not in-process; the SQLite store would work, the events map would not.

26. A 12-hour session with no "stay signed in"

server.js:42 sets SESSION_TTL_MS to 12 hours and the cookie carries the same maxAge. There is no refresh and no remember-me. The cookie simply expires.

27. The sitemap caps dynamic entries at 100 threads

routes/seo.js:501 lists forum.latest(100) — "a sitemap is not an archive". Profiles and users are listed without a cap, so that part grows without bound.

28. Fixed-size lists with no paging

See items 2 and 3. Same root cause, same fix.

29. No moderation path for profiles beyond comments

A profile can be voted on, commented on and reported (routes/reports.js:11 allows profile, comment and post), but a profile's content cannot be moderated: an admin can delete a profile, and that is the only action available.

30. The shipped guides have drifted from the code in a few places

A snapshot of 2026-09-30. These may already have moved.

  • docs/profile-builder/deep-click.md:67 says the default zone suggestion is entry 20–100 % with a lower exit band. autoDeepRange() (public/js/editor.js:2123) gives the first zone 35–100 % with exitStart === entryStart (:2143), and every later zone Math.min(previousEnd, 95)–100.
  • docs/profile-builder/interface.md:78 lists the Bindings grouping as "Buttons → Wheel → Surface → Tilt / Pivot → Other → Settings". mappingGroupRank() (public/js/editor.js:3128) ranks buttons first, then tilt 1e3, wheel 2e3, surface 2.5e3, other 3e3, sys 1e4 — so Tilt/Pivot comes before Wheel. (Re-checked after the guides were rewritten for nesting: the line now names the Nesting tree, but the order is still wrong.)
  • docs/profile-builder/interface.md:35 says New "warns if you have unsaved work". loadNew() (public/js/editor.js:770) calls clearDraft() with no confirmation.
  • docs/profile-builder/bindings.md:30 and README.md:24 both say 16 physical buttons, and 16 is correct — BUTTON_SECTIONS (public/js/editor.js:1945) is 3 + 3 + 2 + 2 + 4 + 2. If you find "15" anywhere, that is the wrong number.

sequences-and-macros.md was renamed nesting.md by the nesting rework and its title is now Profile Builder · Nesting — one input gated behind another; anything still citing the old filename outside that folder is stale.

Fixed by the nesting rework

The rework landed in waves on 2026-10-01 and corrected the meaning of PredecessorId: it is input nesting / gating, not sequencing — a binding is active while the trigger above it is held. There is no if/else, no timed order and no priority, OutputActionSequenceDelay is 0 in all 1026 mappings across all 48 stored profiles and is no longer exposed in the UI, and nesting is arbitrarily deep to a limit of 20 edges and branches freely.

The entries below are in the same format as the rest of this file, because that is how a reader checks one in a minute. Each carries the severity it was, not the severity it is.

F1. The first-follower walk dropped 5 of 9 mappings in profile 69 — was High, fixed

Where: buildSequences() in lib/spcfParser.js (deleted), and chainsOf() in public/js/editor.js (now :2326, rewritten over lib/nesting.js).

Both walked forward from a root and, at each hop, followed only the first unseen follower. A parent with two children therefore lost everything past the first. Profile 69 (nesting_testing.spcf — 9 mappings, 1 root, 2 branch points, max depth 5) rendered 4 of 9. chainsOf() also capped the walk at 20 hops and had no notion of branching at all.

Why it matters: it was a silent data loss, not a display bug. Nothing threw, nothing logged, and a profile simply looked smaller than it was — the five bindings that fire while a button is held would be missing from the Builder, the Checker and the profile page, with no way to tell from the output that they were there. Smallest fix (applied): lib/nesting.js walk() visits every follower, and test/nesting-tree.test.js pins 0→[1] 1→[2,4] 2→[3] 4→[5] 5→[6,7] 7→[8] together with the assertion that traversal reaches all 9 — the assertion that fails against the old version.

Where: the old parseGroup() step at lib/spcfParser.js:544-555 (deleted).

PredecessorId is a raw ActionMappings.$values index, but buttonMappings is a filtered and compacted list. The parser rewrote every predecessorId into buttonMappings index space and set it to null whenever the target was not a button binding. On profile 69 that nulled 3 of the 4 parent links. Every renderer downstream then either dropped the mapping from its tree or — worse — resolved list[m.predecessorId] against the wrong array and named an unrelated button as the host.

Why it matters: the display model said a binding had no parent when it had one, and no consumer could tell that from the data it was given. tiltMappings was the worst case: it kept predecessorId in raw space while the button list had been translated, so the two halves of one model disagreed with each other. Smallest fix (applied): there is now exactly one index space. children / parentOf / depthOf are keyed by raw index, buttonMappings and tiltMappings contain nesting children rather than excluding them, and indexInList() is the only sanctioned raw → display-list bridge. test/parser-nesting.test.js asserts all 8 non-root parentOf links are non-null.

F3. Four renderer bugs with the same root cause — was High, fixed

All four read a raw index as a list position, or summed two lists that now overlap:

Where Symptom
public/js/profile-beta.js:978 chainFollowers() keyed by raw predecessorId and looked up with a list index — the two never met, so no follower row was ever reachable
public/js/profile-beta.js:845-863 Number(m.predecessorId) !== ownIdx compared two index spaces, so the requires pill appeared on almost every row; and the wording was after <button>, the wrong semantics
public/js/check.js:389-390, public/js/profile-beta.js:775-776 buttonMappings.length + tiltMappings.length + seqSteps double-counted every nested mapping once the flat lists began containing them
public/js/export.js:54-68, public/js/share-render.js the Tilt / IMU section filtered buttonMappings for imu*, a list that never holds tilt types — 177 tilt mappings in the corpus were invisible on every share image and in every export

Smallest fix (applied): key by m.parentIndex, count with g.nesting.count, read g.tiltMappings.

F4. BUTTON_LABELS had the legacy ids but not the short ones — was Medium, fixed

Where: lib/spcfParser.js. BUTTON_LABELS was keyed on primaryLeft, primaryRight, … and not on left, right, middle — which are the ids lib/spcfWriter.js writes. friendlyButtonName() fell through to KeyName, so the site read MouseButtonLeft where the Builder read Left Button for the same file. Smallest fix (applied): both spellings resolve.

F5. singleButtonStackMembers() did not terminate on a cyclic input — was High, fixed

Where: lib/nesting.js claim(). buildTree detaches a cycle at its lowest-index member but leaves the reciprocal edge in children, and claim() walked children with no seen guard — so on {1:[2], 2:[1]} the member array grew until Node threw RangeError: Invalid array length. An uncaught crash from a hand-edited file; lib/spcfParser.js had been working around it by cutting the cycle's parent links before calling. Smallest fix (applied): a seen set mirroring walk(), which is what makes the defensive contract in the module header actually true. The parser's workaround is gone.

F6. Every stored parsed_summary predates the nesting shape — was High, fixed

Where: routes/profiles.js:270 and lib/share-image.js:58.

parsed_summary is a cache of JSON.stringify(parseSpcf(file).groups), and all 48 stored rows predate the rework: no nesting key, and a predecessorId in the wrong index space. A consumer reading g.nesting off one of them got undefined and drew a profile whose nesting was silently missing — the bindings looked unconnected rather than wrong, which is the worst way to be wrong. Smallest fix (applied): lib/profile-groups.js groupsForRow(row) returns the cache only when every group already carries a nesting block, and otherwise re-parses format_header + "\n" + raw_json — the same bytes the download route hands back. Both readers use it; routes/profiles.js re-exports it as router.groupsForRow. A body that cannot be parsed degrades to the row as stored, so a malformed profile becomes an old-looking page rather than a 500. The rows were deliberately not backfilled: that fixes today's shape and leaves the identical trap for the next parser change, on a live site, for no gain.

F7. The Builder shipped the sequence UI the format does not have — was Medium, fixed

Where: public/js/editor.js, all of it removed in the editor slices of 2026-10-01. Every row of this table names something that is not in the file any more:

Removed What it was
the Run after dropdown let you pick a mapping's parent and framed it as "this fires after that one" — the opposite of what a gate means
the per-mapping delay input OutputActionSequenceDelay, 0–10000 ms, timing for a sequence that has no steps to time
the Sequences & macros block head and its empty state told the reader to set "Run after" in a binding to link steps into a sequence
the ▶ preview playChain() toggled a .playing class on the chain step and the matching .ed-mbtn, dwelling Math.max(360, OutputActionSequenceDelay) ms per step. That was the whole of it: it sent no input, synthesised no key, triggered no haptic and changed no OLED state
the diagnostic #<n> has a broken "Run after" link (# <p>) reworded to Binding #3 is meant to be held under #99, which is not a binding in this layer — it fires on its own instead
the toast "Run after" link cleared reworded to Binding #3 is now top-level — active whenever its own trigger fires

Why it matters: it was the last surface offering a dropdown that says "fires after" and a delay field the format has no use for, and the preview was worse than cosmetic — the old guide profile-builder/sequences-and-macros.md §5, since renamed to profile-builder/nesting.md, claimed the ▶ button "runs the chain's actions in the browser (key taps, delays, haptics, OLED…)" and then warned "previews play real key presses on your PC. Don't preview a chain that types passwords." It never sent a real key press. A guide that overstates what a button does is worth more than the button, so the claim had to go with the feature rather than be softened. chainsOf() — which walks the whole subtree and is correct — was kept as the renderer's data source; only the labels, the two inputs and the ▶ were wrong. Smallest fix (applied): the two inputs and the block are deleted, the nesting gate is now created from the Add-menu in the button card, and every string above is reworded in gate language (while … held, broken nesting link).

Still open after the nesting rework

31. The export and share renderers call a button's own deep zones "held" — Medium

Where: public/js/export.js:113 nestingLine() and public/js/share-render.js buildModel(). Verified by running the shipped code against profile 9 in this repository's own database:

### Right Fingertip Button
- **Right Fingertip Button Press** — 1 — `Hold 1`
  - **Right Fingertip Button Deep 31-100%** _(while Right Fingertip Button Press held)_ — 2 — `Hold 2`

Why it matters: that link is a button's press stack — a light press and the deep zone beneath it on one force axis — not a gate. Saying a deep zone is "held while the press of the same button is held" is precisely the confusion the rework set out to remove, and these two surfaces are now the only ones still doing it. public/js/check.js isPressStack() and public/js/profile-beta.js gateText() both suppress the text for this case. Smallest fix: reuse that test — nesting.pressStages, or pressStage on both nodes of the same button — inside nestingLine() and buildModel(), and draw those rows on the depth axis rather than as a child.

32. 23 of 48 stored profiles carry liftOffSelect as a Level string, not a number — Medium

Where: lib/spcfParser.js:295:

case 'liftOffSelect':
  return { kind: 'liftOff', mm: item.LiftOffMm };

Measured over the corpus: of 34 stored liftOffSelect nodes, 10 carry LiftOffMm: 7, 1 carries LiftOffMm: 30, and 23 carry Level: "two" (19) or Level: "three" (4) with no LiftOffMm at all — spread across 23 of the 48 profiles.

Why it matters: on the Checker the settings grid drops the Lift-off card entirely (if (settings.liftOffMm != null)), so the setting silently vanishes for nearly half the corpus. In the Markdown export and on every share image the shared label helper prints the value unconditionally — Lift-off ${item.mm} mm — so it reads Lift-off undefined mm. The parser's non-standard-value range check only runs on a numeric mm, so a string form gets no check at all. Smallest fix: read Level as well and map it (two → 2, three → 3) in describeOutputAction(). What the Control Panel means by each string is not verified — the spelling is suggestive, not documented — so confirm it against the driver before shipping the map.

33. macro-warning is the id of a warning about keys, not macros — Low

Where: public/profile-beta.html:235 and public/js/profile-beta.js:56.

Why it matters: nothing functional. The element is the fires on activation alert — the warning about settings.onLoadOutputs sending keyboard keys the instant a profile loads — and its id still carries the vocabulary the rework removed everywhere else. Smallest fix: rename it to load-warning in both files.

How to file a new finding

This file is a documentation artefact, not a tracker — there is no queue behind it and nothing is triaged. The useful thing you can add is a sentence naming the file and the line, the way every entry above does: that is what makes a finding checkable in a minute instead of a discussion. Add it to the right section, give it a severity, and say what the smallest fix would be. If you cannot say which of those you actually know, put it under not verified and say why.