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.
16. toDiscord mangles links to non-guides ending in .md — Medium
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.
17. The guides preview cannot resolve wikilinks — Low
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:67says 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 % withexitStart === entryStart(:2143), and every later zoneMath.min(previousEnd, 95)–100.docs/profile-builder/interface.md:78lists the Bindings grouping as "Buttons → Wheel → Surface → Tilt / Pivot → Other → Settings".mappingGroupRank()(public/js/editor.js:3128) ranks buttons first, thentilt1e3,wheel2e3,surface2.5e3,other3e3,sys1e4 — 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:35says New "warns if you have unsaved work".loadNew()(public/js/editor.js:770) callsclearDraft()with no confirmation.docs/profile-builder/bindings.md:30andREADME.md:24both 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.
F2. The index translation nulled 3 of 4 parent links — was High, fixed
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.