SWIFTPOINT//HUB
Support

Gallery and profiles — browsing and sharing

This covers the three surfaces a community profile appears on: the browse gallery at /profiles/hub, the profile detail page at /profiles/:id, and the member pag

This covers the three surfaces a community profile appears on: the browse gallery at /profiles/hub, the profile detail page at /profiles/:id, and the member page at /users/:id. It also covers the shared card renderer, the upload flow, the game taxonomy, voting, and the Markdown and PNG exports. Everything here is server-rendered HTML plus plain scripts; see 20 Security and privacy for why that matters.

Route /profiles/hub, files public/gallery.html, public/js/app.js, public/js/card.js. The route lives in routes/seo.js and is registered above /profiles/:id, because that handler answers for any single segment after /profiles/ and would otherwise read hub as a profile id.

The live stats strip

Three numbers in dl.gal-stats, fetched from their own unfiltered request:

Element Id Source
Profiles stat-profiles COUNT(*) FROM profiles
Games & apps stat-games COUNT(DISTINCT game) over non-empty games
Button mappings stat-mappings SUM(total_buttons) across every profile

They come from GET /api/profiles/stats, deliberately not from the page being shown. A search for cs2 must not make the site claim it has three profiles.

Search, clear, sort, count

Control Id Behaviour
Search box search-input Debounced 250 ms, re-requests GET /api/profiles?q=&sort=. Escape clears it and reloads.
Clear button search-clear Hidden until the box has text. Appears also in the empty state, as data-clear-search.
Sort select sort-select change fires an immediate re-request.
Result count result-count role="status".

The count line is the difference between "nothing is here" and "nothing matches what you typed":

7 results for “cs2”
7 profiles

The server-side search is one LIKE across five columns — title, author_name, description, tags, game — with the pattern %<query>%. There is no full-text index and no ranking; it is a substring match.

Every sort the API supports

routes/profiles.js holds the whole list. An unknown sort falls back to newest.

sort Label in the select ORDER BY What it means
newest Newest first created_at DESC Default. Upload time, newest at the top.
oldest Oldest first created_at ASC The reverse, which is how the original imports read.
name Name A–Z title COLLATE NOCASE ASC Case-insensitive by the column collation, not by a lower() wrapper.
buttons Most mappings total_buttons DESC The number of button mappings the parser found, not the file size.
dpi Highest DPI max_dpi DESC The profile's top DPI level. Profiles with no DPI set sort last.
votes Most voted (upvotes - downvotes) DESC Net score, not the upvote count.
visited Most visited views DESC Admin page views are excluded from this counter, so this is a count of other people.
downloaded Most downloaded downloads DESC Incremented on every GET /api/profiles/:id/download.

There is no secondary sort key. Two profiles uploaded in the same second come back in whatever order SQLite returns.

The tag filter and the game filter

There are none, and that is the point. GET /api/profiles still accepts ?tag= and ?game= — tag matches a comma-delimited %,<tag>,% against the tags column, game is an exact = — but nothing in the current markup sends them. public/js/app.js says why in a comment above the search listener:

There used to be a chip per game under the search field, 27 of them on a page whose search box already filters by game. Two controls for one job, and the second one was the one people did not use.

The endpoints survive for API consumers; the chips do not. GET /api/profiles/tags still returns the distinct tag list.

The profile grid

#gallery is filled with state.profiles.map(cardHtml).join(''). Two empty states, chosen by whether the search box has text:

Nothing matches “xyz”
The search looks at titles, authors, games and tags.

No profiles yet
Be the first to upload one.

Toasts live in #toast-stack, last 4000 ms, and the stack keeps at most five by dropping the oldest. ?upload=1 opens the upload overlay for a signed-in visitor; ?admin=denied produces the toast "The admin panel is only available to admin accounts." — that is the page that explains a refused /admin (see 16 Admin panel).

Profile cards

public/js/card.js is the shared kit. It is a plain IIFE with no module system and no dependencies, exposing window.SPHCard:

Export What it does
gameHue(game) Folds the lowercased game name into a h*31 + charCode hash and takes it modulo ten hues: 78, 168, 30, 210, 265, 340, 140, 195, 15, 50.
fmtCount(n) Below 1000 the plain number; below 10000 one decimal with .0 stripped (1.2k); above that rounded to whole thousands (14k).
dpiRange(p) null when minDpi is absent — a profile with no DPI gets no bar, not a bar at zero. Otherwise a log-scale position between FLOOR = 100 and CEIL = 30000, with a minimum visible width of 1.5 %.
avatar(p, cls) The uploader's <img> with loading="lazy" and referrerpolicy="no-referrer", or a cls--letter span holding the first letter of authorName.
escapeHtml(str) Escapes & < > " '. It lives here because the card is the thing that prints user text.

Both gallery.html and user.html load js/card.js before their own script, and test/gallery-links.test.js asserts the order, because the kit has no guard: a page that used it without loading it first throws on the first card and the grid silently never renders.

Rendered by cardHtml(p) in app.js as an <article class="card card--link"> with the game hue inline as --game-hue:

  • the game name as p.card-game, tinted by gameHue
  • the title, escaped
  • the uploader's avatar, the author name and the upload date (fmtDate, en-GB, two-digit day, short month, numeric year)
  • the description, the only element allowed to grow
  • a DPI bar: the range printed as min–max (or just min when the two are equal) beside a log-scaled track
  • the metrics Mappings (totalButtons, or –), Views and Downloads, the latter two through fmtCount
  • the vote block: an up button, a down button, and the net score with a + for a non-negative value
  • the tag pills, each escaped

The card falls back to tags[0] and then to 'Uncategorised' when game is null, so an untagged profile still has a colour and a label.

The markup contract

Two decisions, both asserted in test/gallery-links.test.js:

  1. The card is a real link. It carries <a class="card-hit" href="{BASE}/profiles/{id}">, absolutely positioned over the whole card via inset: 0. There is no role="link", no data-href, and no location.href navigation in JavaScript — the test asserts each of those is absent. The old shape was a <div role="link" tabindex="0"> with a click handler and a synthetic Enter/Space keydown, which gave up middle-click, open-in-new-tab, copy-link-address and crawler discovery.
  2. The vote buttons live outside the link. The test slices the card block, counts <a> elements (must be exactly one), and asserts class="vote-btn up" appears after the anchor. It also asserts the CSS raises the vote controls above the stretched link, because a link painted on top of a button eats the click.

The href is built from the numeric id alone; nothing user-typed goes into it.

The dashboard variant on /users/:id is a different shape on purpose — the whole card is one <a class="card card--link">, with no vote buttons and nothing to keep outside the link. The test asserts that shape too, so the gallery change cannot fork it.

Uploading

The overlay is #upload-overlay with #upload-form inside it. It opens from #upload-btn (revealed by the shared header for a signed-in session) and from /profiles/hub?upload=1. Escape, a backdrop click and any [data-close-modal] button close it.

Field Id Client limit Server limit Server constant
Profile name upload-title required, no maxlength 80 MAX_TITLE
Game / App upload-game <datalist id="game-suggestions">, autocomplete="off" 80 MAX_GAME in lib/games.js
Author upload-author placeholder only 60, fallback Anonym MAX_AUTHOR
Description upload-desc placeholder only 400 MAX_DESCRIPTION
File upload-file accept=".spcf", required 2 MB multer fileSize

#upload-error carries whatever the server said. On success the overlay closes, the form resets, the stats and the grid reload, and the browser is sent to /profiles/{id} — the newly created profile, not the gallery.

The per-user cap

MAX_PROFILES_PER_USER defaults to 3 from the environment. currentMaxProfiles() reads the max_profiles_per_user setting first and falls back to the environment value, clamped to at least 1. Admins are exempt (if (!req.session.user.isAdmin)). Hitting it is a 429:

{ "error": "You can upload at most 3 profiles. Delete one first to make room." }

The number in the message is the effective limit, so an admin who raises the setting sees the raised number.

The gallery lede (#lede-sub) reads "Signed in? You can upload up to 3 profiles to share with the community." for everyone except an admin, who gets "You are an admin — no upload limit."

Size and validation

The 2 MB limit is enforced by multer's memoryStorage, and the error handler in server.js turns LIMIT_FILE_SIZE into a 413:

{ "error": "File too large. Maximum size is 2 MB." }

Then parseSpcf() runs, and its errors become a 400 with the parser's own message. The checks, in order:

Check Message
A newline exists (there is a header line) File has no recognizable format (no header found).
The header matches `/^json([,\s] $)/`
No NUL byte in the body File appears to be binary — a .spcf file must be text.
The body parses as JSON Could not parse the JSON content of the .spcf file: …
The document is an object Profile content is not a JSON object.
RootProfile is an object File does not contain a valid "RootProfile".
RootProfile.Groups is a non-empty array Profile contains no groups.
Nesting depth ≤ 20 (MAX_NESTING) Profile groups are nested too deeply.
Each node is an object Malformed group entry in profile.
Group count ≤ 100 (MAX_GROUPS) Profile has too many groups.
ActionMappings.$values is an array Malformed ActionMappings in profile.
Mapping count ≤ 1000 (MAX_MAPPINGS) Profile has too many mappings.

A UTF-8 BOM is stripped before anything else. User-typed fields go through cleanField(): control characters removed, <...> replaced with a space, stray angle brackets removed, whitespace collapsed, sliced to the maximum. See SpCF format for the document itself, and Roles and permissions for who may upload at all.

On success the raw bytes are stored split at the first newline: format_header in one column, everything after it in raw_json. The download endpoint concatenates them back, which is what makes a round trip byte-identical. Every upload also fires a new_profile notification to the admins.

Games, names and the taxonomy

lib/games.js is the most interesting small module on the site. Its own header explains the problem it solves: the game field grew by accretion until "CS2", "Counter Strike 2" and "Counter-Strike 2" were three entries in the same filter, two games were spelled with a typo, and a visitor searching for one spelling found none of the others.

The catalogue and the alias table

GAMES is a flat array of 44 names in the spelling the hub uses, including three that are not games at all (Desktop, Web, Application) because a profile is often for the desktop or for an application. ALIASES maps 52 alternative spellings onto them.

The comparison is key(name):

String(name).toLowerCase().replace(/[^a-z0-9]+/g, '')

Case and punctuation disappear, so Escape From Tarkov, escape: from tarkov and ESCAPE-FROM-TARKOV all reduce to escapefromtarkov. The alias table therefore only has to list words that are spelled differently, not capitalisation or punctuation.

cleanGame(raw) is the same cleaning the rest of the upload form uses — control characters stripped, <...> to a space, stray angle brackets removed, whitespace collapsed, sliced to MAX_GAME = 80.

canonicalGame(raw, known) is the whole mechanism, and it is three steps:

  1. Look the key up in BY_KEY (built once from GAMES plus ALIASES). A hit returns the canonical name.
  2. Otherwise adopt another spelling already in the database with the same key, so a game the table has never heard of still merges with the way the site spells it.
  3. Otherwise keep the cleaned typed value.

A brand-new game is therefore always allowed. The taxonomy exists to keep the same game in one place, not to decide which games may exist.

The deliberate non-alias

csgo: 'Counter-Strike: Global Offensive',

csgo is not folded into Counter-Strike 2. They are two different games, and the alias table is written out by hand precisely so that a typo in it is a visible list of strings rather than a silent fuzzy match. Two different games are never merged.

One control, one catalogue

The gallery has exactly one filter control — the search box — and it filters games as well as titles. There is no second game picker anywhere in the markup.

The upload form and the Profile Builder's publish dialog read the same list from the same endpoint. GET /api/profiles/games returns the canonical catalogue merged with the distinct games actually in the database, each canonicalised, sorted:

Apex Legends, Application, Arma 3, Battlefield, Bodycam, …

app.js fills <datalist id="game-suggestions"> from it. editor.js fills <datalist id="pub-games"> from the same fetch. A name that exists only in the database still appears, spelled the way the hub spells it.

migrate()

Called once at boot from server.js, before any request can read a stored name:

const result = games.migrate();
if (result.changed) {
  console.log(`Canonicalised ${result.changed} of ${result.scanned} game names`);
}

It walks every row with a non-empty game, and for each one:

  • canonicalises the game through canonicalGame(row.game, distinct)
  • strips every spelling of the old name out of the tag list and puts the canonical one back once, so a profile typed as Wardogs ends up with one WarDogs tag rather than the old one sitting next to the new one
  • compares the result to what is stored and writes only the rows that actually changed
  • runs inside a single db.transaction

It is idempotent: a second boot over an already-canonical database changes nothing and logs nothing. A failure is caught and printed as Game taxonomy migration failed: … — the server still starts.

The known limit of the tag invariant. The tag strip filters on key(t) !== key(row.game), i.e. only the exact spelling that was stored. A row that is already canonical but still carries an older alias tag from before the migration keeps it: game: "Counter-Strike 2" with tags: "CS2,Counter-Strike 2" is left untouched, because CS2 is not the spelling that was stored. The live database currently has no row with two tags sharing a key, but the gap is real and belongs in Known gaps.

Voting

POST /api/profiles/:id/vote, behind requireUser. The body is { value } and Number(req.body.value) must be exactly 1, -1 or 0:

{ "error": "Vote must be 1 (up), -1 (down) or 0 (remove)." }

Clicking the button that is already active sends 0, which is how a vote is removed. You cannot vote on your own profile:

{ "error": "You can't vote on your own profile." }

The vote is an upsert on (profile_id, user_id). The counters are then recomputed from the votes table, not incremented:

upvotes   = (SELECT COUNT(*) FROM votes WHERE profile_id = ? AND value = 1)
downvotes = (SELECT COUNT(*) FROM votes WHERE profile_id = ? AND value = -1)

The response is { upvotes, downvotes, myVote }.

A non-zero vote notifies the admins (new_vote, "<user> upvoted "<title>"") and then the profile owner — unless the owner is an admin, who already received it through the broadcast, or the voter is the owner. The response myVote is the viewer's own value and is what lights up a button.

GET /api/profiles attaches myVote to every row for the signed-in user in one extra query over an IN (…) list, so the gallery can highlight without a request per card.

The browser never compares two Discord ids. GET /api/profiles/:id returns isUploader, computed server-side as row.uploader_discord_id === viewerDiscordId(req), and the comment list returns canPostPatches, isMine and isAuthor rather than an id. rowToSummary publishes only id, username and avatarUrl under uploader. test/public-api-privacy.test.js asserts all of it: that no public response key starts with discord, that no route builds a discord.com/users/ link, that isUploader is derived from the session, and that none of profile-beta.js, user.js or app.js contains the string discordId at all. The one place the id still appears is /api/auth/me, which is a person's own account id shown to themselves.

A profile page

Route /profiles/:id, files public/profile-beta.html and public/js/profile-beta.js (2028 lines). The HTML ships noindex,follow because it was the beta preview; routes/seo.js rewrites that to index,follow on the way out.

Legacy URLs

routes/seo.js matches /^(\d+)b$/i on the id. /57b resolves to profile 57 with no redirect. Anything not matching ^\d+$ after that is a 404.

The hero

  • #s-game — the game chip, hidden when there is no game
  • #s-title — the profile title; also written into document.title
  • #s-byline — "by authorName", then "uploaded by @username" (linked to /users/{id}), then "created …", then "updated …"
  • #s-desc — the description, hidden when empty
  • #s-stats — Views, Downloads, Buttons, Pivot (only when present), Tilt (only when present), DPI min, DPI max, Version

The vote box is #vote-group with #vote-up, #hv-score and #vote-down; #hv-sub reads "N up · N down". Clicking the same direction twice removes the vote. When isUploader is true the whole vote box is hidden.

The tool strip

The strip is the shared header's second row, the same tbar-* vocabulary as the Profile Builder and the OLED editor.

Id What it does
p-download GET /api/profiles/:id/download
share-btn Opens the share-image overlay
md-export-btn Opens the Markdown export overlay
report-btn Opens the report modal
bind-search Live binding filter, hidden when the profile has no bindings
pb-raw Toggles the raw-box <details> and follows it back
owner-actions edit-btn, update-btn, delete-btn — uploader or admin only

The binding explorer

#mapping-list is a grid of .map-cards, one per physical button. Inside a card, one .map-row per mapping, with a trigger label (Press, Deep press with the percentage range, Scroll), output badges, the mapping's own label and any OLED block.

Mappings that produce no output at all are dropped; a button card survives as long as any of its mappings produces something, so an empty light press next to a deep press keeps the card. buttonMappings contains the nesting children, so a card can hold both a root and the bindings that only exist while it is held. A mapping with no Button key of its own — a gated scroll wheel, a tilt region — is filed under the trigger that gates it, and anything with a parentIndex carries a while <trigger> held pill naming its real parent.

The old wording after <button> is gone: it said "runs after", which is the opposite of what a PredecessorId means.

The nested-bindings section

#nest-section holds one card per root that actually gates something, each card holding that root's whole subtree with every child indented under the trigger that holds it. Depth is drawn as indentation plus a tree elbow — never as a step number, because a 3 of 7 badge would claim an order the format does not have. Each child carries a while <parent trigger> held pill. Roots with no children are not repeated (they already have a card above), and neither is a run made entirely of press stages, which is a force axis rather than a gate and is drawn by the deep-click ladder. The badge reads Held while; the count reads N nested · D levels deep.

Profile 69, the reference file, renders as one card with all 9 of its mappings, both branch points and depth 5 — keyed by raw index throughout, which is why two mappings with the same trigger on different branches both appear.

#layers and #layer-chips are the sub-profile breadcrumb: a Base chip for the root group, then one chip per level, then one Sub chip per child group with the child application's name. Selecting a chip re-renders the group content with the parent's settings merged underneath. When the file has several top-level groups, the same chip row becomes a tab strip with one chip per base group.

The filter is a plain substring match over each row's text plus its card's name, wired once via bind-search dataset.wired. When nothing matches, a .no-results paragraph appears with No bindings match “xyz”., or No button mappings in this layer. with an empty box.

Warnings

Two alerts above the sections, both derived from the parsed data:

Id Fires when Text
loop-warning A goto with loopCount === 0 and no stopOnRelease "Endless loop detected. A jump-to-action repeats forever with no stop-on-release…"
macro-warning The group has onLoadOutputs "Fires on activation. This profile sends keyboard keys automatically as soon as it is activated…"

(macro-warning is a leftover name, not a macro: the element is the fires-on-activation alert and has nothing to do with nesting. See Known gaps.)

A profile with zero groups gets "This file contains no profile layers." in #mapping-list, and the tilt section is hidden.

The markdown view

#md-btn in the comment toolbar opens #md-overlay, whose #md-grid holds fifteen worked examples of the light markdown subset comments are rendered with — bold, italic, strikethrough, inline code, code block, link, auto-link, heading, unordered and ordered lists, task list, blockquote, emoji shortcuts, horizontal rule and line break. Each card shows the source and the rendered result.

The full export is separate. #md-export-btn calls SPHubExport.buildMarkdown() and puts the result in #export-md-text inside #md-export-overlay, with md-copy (clipboard, toasts "Markdown copied to clipboard.") and md-download (a Blob named <slug>-profile-<id>.md). Escape, the backdrop and [data-close-modal] all close it.

The share card

#share-btn waits for document.fonts.ready, calls SPHubExport.renderImage(), and shows the result in #share-img. #share-copy writes the PNG blob to the clipboard via ClipboardItem, falling back to copying the data URL. #share-download saves it as <slug>-profile-<id>.png.

Comments

Element Id Notes
List comment-list Oldest first. Each row: avatar, @user linked to /users/{id}, a Patch notes badge when kind === 'patch', the date, a rendered body, Report, and Delete when isMine or admin.
Form comment-form Shown when signed in; otherwise #comment-login links to the Discord login. #comment-input has maxlength="1000".
Kind select kind-select Two buttons, comment and patch. Hidden entirely unless canPostPatches is true.
Emoji emoji-btn / emoji-popover Forty-eight emoji inserted at the caret; closes on an outside click.

kind: 'patch' is a server-enforced reservation: POST /api/profiles/:id/comments returns 403 "Only the profile author can post patch notes." for anybody else. Comments are capped at 1000 characters after cleanFieldMultiline.

The edit overlay

#edit-btn opens #edit-overlay with #edit-title (80), #edit-author (60), #edit-desc (400) with a live counter in #edit-count, #edit-error and #edit-save. Saving is PATCH /api/profiles/:id with { title, authorName, description }; the .spcf is untouched. #delete-btn confirms with Delete "<title>"? This cannot be undone. and then goes to /profiles/hub. #update-btn opens a hidden file input and PUTs a replacement .spcf, keeping the id and the comments.

The whole #owner-actions group is revealed when me.isAdmin || isOwner, and the owner flag is profile.isUploader — the server's answer, not a comparison the browser makes.

Page furniture

Canonical link to /profiles/{id}, index,follow, og:type="article", a CreativeWork JSON-LD block, and its own OG card at /profiles/{id}/share.png (1200×630). The view counter is incremented in routes/seo.js for every visitor except an admin session — if (!isAdminSession(req)) guards both the pageview row and the views update.

The Markdown export

public/js/export.js is UMD, exposing window.SPHubExport in the browser and module.exports in Node. It exports three functions: buildMarkdown, renderImage, renderOgCard.

buildMarkdown(profile, H)

The second argument is a small adapter (buildExportHelpers() in the page, buildH() in lib/share-image.js) that binds the shared helpers to the names the renderer wants. The document is assembled in this order:

  1. # <title>
  2. the description, every line prefixed >
  3. a meta line joined with ·, built from whichever of these exist: **Game:** <game>, **by** <authorName>, **Updated:** <fmtDate>
  4. This profile contains no bindings. when there are no groups
  5. per group: ## <group.name or 'Profile'>, then one section per nesting root — ### <button name, else the trigger text> — with every mapping under that root as a - row indented by two spaces per level: - **<trigger>** — label — out, and `- **<trigger>** _(while <parent trigger> held)_ — label — `out for a child. The parent is named in the line itself, not only by the indentation. A group with no tree at all (an old cached parsed_summary, or a profile with no links) falls back to the old grouping: one ### <button name> per button, then ### Tilt / IMU for g.tiltMappings
  6. the standing note, emitted above the first nesting section whenever the group has any depth > 0: Nested bindings are gated, not sequenced: an indented binding is active **while the trigger above it is held**. Nothing runs in a timed order.
  7. ### On activation for settings.onLoadOutputs, a single row prefixed ⚡
  8. --- and the footer *Generated on [Swiftpoint Hub](https://mecke.dev/swiftpoint) · profile #N*

There is no ### Macro sequences section any more. nestingForest(g) reads group.nesting — keyed by raw index, so nothing here translates indices — and falls back to the per-mapping parentIndex / children links the parser writes on every mapping, and then to the flat lists. A damaged file therefore loses the shape of a branch, never a binding.

The fallback path also fixed a hole of its own: the Tilt / IMU section filtered buttonMappings for imu*, but that list never contains tilt types, so 177 tilt mappings in the corpus were invisible on every share image and in every export. It now reads g.tiltMappings.

A worked example

Real output, produced by running the shipped code against profile 9 in this repository's own database:

# Star Citizen Basic

> didnt put much love into it, but maybe itll help

**Game:** Star Citizen · **by** dragonemperor2237

## StarCitizen

Nested bindings are gated, not sequenced: an indented binding is active **while the trigger above it is held**. Nothing runs in a timed order.

### On profileChanged
- **On profileChanged** — `DPI: 1700` `1000 Hz` `Lift-off undefined mm` `Device mode: none` `LED: hueRotation` `OLED: cube` `Auto-release: Off` `Tilt auto-zero: Off` `OLED frame 1.5s · none`

### Roll left 2°
- **Roll left 2°** — Q — `Hold Q`

### Roll right 2°
- **Roll right 2°** — E — `Hold E`

### Left Trigger Push
- **Left Trigger Push** — Alt-Tab — `Hold LAlt` `Hold Tab`

### Bottom Thumb
- **Bottom Thumb Press** — F — `Hold F`

### Rear Edge
- **Rear Edge Press** — T — `Hold T`

### Left Fingertip Button
- **Left Fingertip Button Press** — R — `Hold R`

### 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`

### Top Thumb
- **Top Thumb Press** — Y — `Hold Y`

### On activation
- ⚡ `OLED frame 1.5s · none`

---
*Generated on [Swiftpoint Hub](https://mecke.dev/swiftpoint) · profile #9*

Four things to read off it, and one of them is a defect:

  • Every root gets a section, not every button. The heading is the root's own button name, and a root with no button of its own (the settings mapping, a gated scroll, a system event) is headed by its trigger text — which is why ### On profileChanged and ### Roll left 2° appear.
  • The trigger is the parser's own node.trigger, so a press reads Bottom Thumb Press and a deep zone reads Right Fingertip Button Deep 31-100%, rather than the old generic Press / Deep press (≥N%). The two tilt roots are the exception: Roll left 2° / Roll right 2° are pre-fix output — node.trigger for a tilt region ignored the stored direction, and both of profile 9's tilt regions are Direction: "both", so one label names a side the file does not store. That label is being corrected in lib/spcfParser.js describeTrigger(); read those two lines as evidence of the defect, not of the current wording.
  • Lift-off undefined mm is real and is not a renderer bug: this profile's liftOffSelect node carries a Level: "three" string instead of the LiftOffMm number every other profile has, so the parser reads no mm and the shared label helper prints undefined. It is recorded in Known gaps.
  • _(while Right Fingertip Button Press held)_ is wrong, and it is the same class of error the rest of the rework removed. That link is a button's own press stack — a light press and the deep zone beneath it on one axis — not a gate. The Checker's isPressStack() and the profile page's gateText() both suppress the "while held" text for exactly this case; export.js and share-render.js do not. It is recorded in Known gaps.

The old example in this file was a two-step macro and an IMU section; neither exists in the code any more.

renderImage and renderOgCard

Both delegate to public/js/share-render.js through withCanvas():

const makeCanvas = isNode
  ? require('@napi-rs/canvas').createCanvas
  : () => document.createElement('canvas');
return fn(R, makeCanvas);

In the browser makeCanvas() makes an element; in Node it is @napi-rs/canvas's createCanvas. share-render.js is itself UMD (window.SPHubShareRender / module.exports), and lib/share-image.js requires the same file with the same helpers, so the in-page PNG and the served /profiles/:id/share.png are pixel-identical rather than two implementations that agree by eye. lib/share-image.js optionally registers real brand fonts from FONTS_DIR (SpaceGrotesk.ttf, Inter.ttf, JetBrainsMono.ttf) and falls back to system fonts otherwise.

Renderer Canvas Layout
renderImage 1200 px wide, height computed from the content Header, then per group a title, an ON ACTIVATION: line in amber, and the bindings in two columns balanced by shorter-column-first. Chips read PRESS, DEEP ≥N%, ROOT, HELD or TILT. A nesting card gets the violet accent and its rows are indented by depth, so a gated row is visibly narrower than its parent. Footer: mecke.dev/swiftpoint · Swiftpoint Z3 Community Profiles.
renderOgCard 1200 × 630 Header, then three columns of bindings, each row a chip plus label with the outputs on a second line. Nesting is drawn at a tighter step: a row is indented by its own depth. Overflow is dropped and reported in the footer as +N more. Footer: mecke.dev/swiftpoint · N bindings.

A group that has any nested row gets a one-line hint across its cards, NESTED: an indented binding is active while the trigger above it is held. The ROOT / HELD chips replace the old step number: ROOT is the trigger being held, HELD a binding that only exists while the row above it is. Indentation is capped so a 20-deep tree stays inside the column.

An empty profile renders This profile contains no bindings. in both.

The shared vocabulary

public/js/export-helpers.js is UMD too (window.SPHubExportHelpers). It is the reason the profile page, the in-page PNG and the served OG card all describe the same binding the same way — the browser and Node run identical code.

Export Behaviour
fmtDate(iso) toLocaleString('en-GB') with two-digit day, short month, numeric year, two-digit hour and minute. Falls back to the raw string.
stateLabel(s) enabled → On, disabled → Off, otherwise the value or –.
fmtSec(s) Number(s.toFixed(2)) + 's'.
describeOutputItems(items) The whole activation vocabulary; returns null for an empty list.
describeTiltGesture(axis, region) Picks the entry edge nearest level (180°) and prints the direction plus the distance rounded to one decimal.
describeInput(m) The trigger vocabulary.
mappingButtonId(m) input.button.Id / .id, else buttonName, else '?'.
isDrawableOled(item) true for an oled or oledFlash item carrying an image — both kinds render as pixel art.
HAPTIC_DRIVER_SCALE 1.2. Driver strength is multiplied by this before it is printed as a percentage.
TILT_DIRS The direction table, below.

PAD_AXES is defined in the module and used by describeOutputItems, but it is not in the exported object — only the ten names above leave the module.

const TILT_DIRS = {
  pitch: { up: 'Tilt Forward', down: 'Tilt backwards' },
  roll:  { up: 'Roll right',   down: 'Roll left' },
  yaw:   { up: 'Pivot right',  down: 'Pivot left' },
};

const PAD_AXES = {
  leftRight:       'X (left/right)',
  forwardBack:     'Y (forward/back)',
  rotateLeftRight: 'Rot (yaw)',
  upDown:          'Z (thrust)',
};

Exact trigger phrasings

Input Output
buttonToggle Press <button>
forceRegionToggle Deep click <button> 40–60% (the range is omitted when there is no region)
imuAngleRegionToggle Roll right 15°, Tilt backwards 8°, Pivot left 12.5° — or Axis <axis> · range <start>°–<end>° when no edge is finite
imuAngleRange Tilt ±10° around level / Roll ±10° around level, Tilt up to 10° one way when only one side is set
scroll Scroll
systemEvent System event
anything else the raw type string

Exact output phrasings

Kind Output
key with state: 'downOn' Hold <text>
key otherwise <text>
media Media: <text>
haptic Haptic 60% · 4s — each part only when present, time10ms / 100 through fmtSec, strength through HAPTIC_DRIVER_SCALE
wait Wait 80ms, or Wait
dpiSelect next / previous DPI cycle: next level / DPI cycle: previous level
dpiSelect increase / decrease DPI +500 / DPI −500
dpiSelect other DPI → <value>
dpiConfigure DPI: 400 / 800×1.5
reportInterval 800 Hz
liftOff Lift-off 2 mm
gameDevice Device mode: <device>
mouseButton downOn / upOff Hold Left click / Release Left click — ids 0, 1, 2 are Left, Right, Middle, otherwise Mouse button <id + 1>
joystickButton Hold pad btn 3 / Release pad btn 3 / Pad btn 3 (the driver reports ButtonId as-is)
joystickStick Joystick Y (forward/back), plus a range and mirrored when present
led LED: <mode>
oled OLED: <message> or OLED: <mode>
oledFlash OLED frame 1.2s · once
autoRelease Auto-release: On
tiltAutoZeroing Tilt auto-zero: Off
goto with loopCount > 0 Jump to action #1 (×4)
goto with loopCount === 0 and stopOnRelease Jump to action #2 (loops while held)
goto with loopCount === 0 Jump to action #3 (endless loop)
goto with a negative loopCount Jump to action #2
unknown the raw kind string

The minus in DPI −500 is U+2212, not a hyphen, which matters if you diff the output.

The joystick range

The default 0–50 / 50–100 split means "full throw both ways" and is hidden. Otherwise:

Left / right ranges Printed
identical, start 0 ±45%
identical, start > 0 ±45% (from 20%)
different, both present L 0–45% · R 45–100%
only one present L 0–45%

With mirrored: true the full string is Joystick Y (forward/back) · ±45% · mirrored.

One honest defect: an oled item carrying neither message nor mode prints the literal OLED: undefined, because the fallback is a template rather than a guard.

Member pages

Route /users/:id, files public/user.html and public/js/user.js. One request to GET /api/users/:id fills the whole page.

The card

Element Id Content
Avatar u-avatar The CDN image, or hidden when there is none
Name u-name The username
Badge u-admin Admin, shown when isAdmin
Badge u-moderator Moderator, shown when isModerator && !isAdmin
Meta u-meta Member since 06 Mar 2026 · Admin · Moderator
Bio u-bio The bio, hidden when empty
Editor bio-edit, bio-input, bio-count, bio-save, bio-cancel, bio-edit-btn Own profile only

The moderator badge is shown only when the user is not an admin, deliberately:

A moderator is not an admin and the badge says so: somebody who works the ticket queue should not look like somebody who can delete a profile.

The badge is also a badge, not a permission — the API is what decides who may moderate; this only says so a reader can see it. routes/users.js says exactly that in a comment.

The quota bar

#quota-bar appears only on your own profile (d.isMe plus a signed-in session). #quota-label reads N / max profile(s) used — plural handled — and #quota-fill gains the warn class at used >= max, so the bar turns amber exactly when the next upload is a 429.

The eight stat cards

Card Id Source
Profiles st-profiles profile_count
Comments st-comments comment_count
Votes given st-votes-given upvotes given + downvotes given
Score received st-votes-received received up − received down, with a + when non-negative
Profile views st-views SUM(views) over the member's profiles
Downloads st-downloads SUM(downloads)
Threads st-threads threads they opened
Forum posts st-posts posts with deleted = 0

Profiles, comments, forum

The profile grid uses the same card shape minus the vote buttons — the whole card is the link, with Score added to the metrics. Comments are capped at 100 server-side (LIMIT 100), newest first, each with the date and an on "<profile title>" link.

The forum section is hidden entirely when the member opened no threads:

The section stays hidden when there are none: a heading with an empty list under it is a thing the page is telling you about somebody when it is really telling you about itself.

Rows show the title, the category, the reply count with reply/replies handled, the last-post date, and — for a ticket — the status badge. forum.threadsByUser(user.id, 10) caps it at ten.

Privacy

The public payload contains id, username, avatarUrl, bio, createdAt, isAdmin and isModerator — and no Discord id. The comment in routes/users.js says why:

No Discord id: this endpoint is public, and an id identifies an account rather than describing a person who uses this site. The avatar is a public CDN URL and stays — it is how the page shows who somebody is.

The bio editor posts to POST /api/users/:id/bio, which is 403 "You can only edit your own bio." for anybody else. MAX_BIO is 300, the textarea carries maxlength="300", and bio-count is a live N/300 counter.


Related: 03 Pages and routes, 14 SpCF format, 15 Roles and permissions, 18 API reference, 20 Security and privacy, 23 Known gaps.