SWIFTPOINT//HUB
Support

Updates — what changed, and how to say it

The changelog at /updates: what changed on the site, and what changed in the mouse. This is the reference for public/updates.html + public/js/updates.js, lib/up

The changelog at /updates: what changed on the site, and what changed in the mouse. This is the reference for public/updates.html + public/js/updates.js, lib/updates.js and the four update routes in routes/forum.js.

What updates are

A changelog with two kinds:

value label short What it is
website Website update Site what changed on Swiftpoint Hub itself — a new tool, a fixed import, a change in how uploads work
mouse Mouse update Mouse what changed in the mouse — firmware, the Swiftpoint app, what a button does out of the box

lib/updates.js gives the reason the split is the feature rather than a decoration:

One undifferentiated "changelog" is a list half of whose entries are irrelevant, and a list with irrelevant entries is a list people stop opening. The two filters on the page are therefore the primary way to read it, not a nicety.

Only moderators and admins can write them (requireModerator on all three write routes; an admin passes because lib/admin.js makes every admin a moderator). Entries are filterable by kind, searchable, pinnable, and the newest few also appear on the front page.

See Roles and permissions.

The entry model

Every field, its limit and what it does:

Field Limit What it does
kind website | mouse which half this belongs to. An unknown kind falls back to website, not to a 400.
title MAX_TITLE 140, MIN_TITLE 4, required the thing a reader scans. The only required field.
summary MAX_SUMMARY 180 the teaser and the list line. Falls back to the first 200 characters of the body, as plain text, when empty.
version MAX_VERSION 40, optional the thing to look for; the placeholder is 2.1 · v1.4.0
accent fixed list of six the colour. An unknown accent falls back to lime.
tags MAX_TAGS 4, from a fixed list of seven where the change landed. Stored as a JSON array. Unknown tags are dropped, de-duplicated, capped at four.
body MAX_BODY 32000 the rendered markdown
pinned boolean pinned rows sort first, everywhere
author_user_id — joined to users for the byline
created_at / updated_at — SQLite datetime('now'), UTC, YYYY-MM-DD HH:MM:SS

Other defaults in the module: DEFAULT_LIMIT 30 and MAX_LIMIT 200, both clamped by clampLimit().

The design note for the shape of the form:

The title is the only required field. Everything else exists to make a list of thirty of these readable… A publish form that made all of those mandatory would just get filled with "asdf" and teach nobody anything.

Accents

ACCENTS in lib/updates.js, with the label and hint the editor shows on each swatch:

value label hint
lime Lime Ordinary news
blue Blue Something to know about
teal Teal Improvement
violet Violet Bigger change
orange Orange Deprecation, something to plan around
critical Red Broken, or a fix you should apply

The reason they are a fixed list and not a colour picker is in the source:

A free colour field is a background in pink on a dark page that stays unreadable forever, and the only thing a colour does here is say "look at this first" — which is a decision about meaning, not taste. critical exists because there are updates that genuinely need to interrupt a list: a broken upload path, a security fix somebody has to act on.

Accent and tag are the two things a reader scans on a list page, which is why both are fixed vocabularies rather than free text. The editor's own aside says exactly that: The accent and the tags are the parts a reader scans.

In the CSS each accent is an edge bar on the row (.up-item::before, position: absolute), and .up-item.a-critical resolves to var(--red). test/ui-shape.test.js checks all six.

Tags

TAGS, in the order the editor offers them:

value label
site Site
builder Profile Builder
oled OLED Editor
checker Checker
profiles Profiles
forum Forum
guides Guides

Maximum four. The editor dims the untaken buttons at the cap rather than hiding them — "a control that disappears is a control nobody can find" — and toasts Four is the most; take one off first. when a fifth is attempted. The dimming is btn.disabled = !on && state.draft.tags.length >= MAX_TAGS, pinned by a test.

The updates page

public/updates.html + public/js/updates.js.

Tool strip

Element Id Behaviour
Search #up-search placeholder "Search updates…"
Filters #up-filters built by JS
← Forum ghost link ./forum
New update #up-new hidden until moderator; a ?write=1 link from the header menu opens the editor

The eyebrow is Changelog and the h1 is What changed.

Rows

Rows are grouped by month under h3.up-month, with the heading falling back to Earlier when the row has no parseable date. The month label comes from monthOf() and is toLocaleDateString('en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).

Each row (updateHtml(), one function used by both the list and the editor preview) carries:

  • a kind pill <span class="up-kind k-<kind>"> with the full label
  • an optional version pill
  • a Pinned badge
  • the title, or Untitled update
  • a meta line with <time datetime> and, when updated_at != created_at, · edited <relTime>, then · and the author link
  • the summary as a teaser
  • the rendered body (u.html, injected unescaped — the server rendered it)
  • one tag chip per tag, each linking to ?kind=<current>&q=<LABEL>
  • for moderators, an action block: Edit, Pin / Unpin, Delete

Delete is guarded by confirm('Delete this update? Nobody who has read it will ever be told it is gone.'), then DELETE /api/forum/updates/:id and a toast Update deleted.

Filters and the URL

Filters are All plus one per kind with its count, the active one carrying aria-pressed. writeUrl() syncs the filter and the query with history.replaceState and writes only non-default values, so a default list has a clean address.

An unknown kind in a URL is ignored (readUrl() accepts only website, mouse, all) so a bad link never blanks the page. On the server an unknown kind returns an empty list rather than everything — "a stale ?kind= should not silently become 'all updates'".

Loading is:

GET /api/forum/updates?kind=<kind>&limit=60[&q=<term>]

and the response also supplies kinds, accents, tags and counts, so the editor's whole vocabulary comes from the server rather than from the page.

Two documented gaps

  • #up-search has no input listener. The field is only ever written to from ?q= (readUrl() sets searchEl.value). Typing in it does nothing, so q reaches the API only through the URL or by clicking a tag chip. test/ui-shape.test.js does not cover this.
  • There is no pager. The page sends limit=60 and never an offset, even though the API accepts offset and returns total.

See Known gaps.

Writing an update

#up-editor-overlay is a modal--form modal--full. modal--full is width: 100% in public/css/ui.css — "the body of an update is a markdown document, and a column you have to scroll sideways to write in is not a column". The sizes live only in ui.css; forum.css and updates.css are checked by a test not to define their own.

Element Id Content
heading #up-editor-title "New update" / "Edit update"
hint #up-editor-hint rewritten by kind; in the markup it explains that this is the page people read
hidden id #up-id the id when editing
kind cards #up-kinds radio cards built from state.kinds, each label + hint
title #up-title maxlength="140", required, placeholder "Firmware 2.1 stops the OLED going black"
counter #up-title-count live n / 140, .is-near above 85%
version #up-version maxlength="40", marked optional, placeholder 2.1 · v1.4.0
summary #up-summary maxlength="180", marked optional
accents #up-accents role="radiogroup"; each swatch's title is "<label> — <hint>" and its aria-label is "<label>: <hint>"
tags #up-tags role="group"; toggles with aria-pressed
editor mount #up-editor-mount SPMarkdown.create with blocks: true, draftKey: 'sp:updates:draft', max: 32000, 8 rows
pinned #up-pinned "Pin to the top"
preview #up-preview in the aside "How this will look"
foot #up-foot rewritten by kind
error #up-error role="alert"
submit #up-submit "Publish update", or "Save changes" when editing

The editor's placeholder text is itself a worked example of the blocks:

What changed, who it affects, and what to do about it.

::: fixed
One entry per thing you fixed — this is the part people scan.
:::

::: added
Something new.
:::

::: warning
Something they have to act on.
:::

The preview

#up-preview renders the same updateHtml() function the list uses, fed from the fields, with isPreview: true and no id. What you see is literally what will be published. The body is deliberately not rendered in that column — the editor's own Preview tab is the only rendered-markdown view, and rendering twice would mean two requests per keystroke.

describe()

The foot copy switches by kind:

kind #up-foot #up-editor-hint
mouse Goes to everybody who reads "Mouse update", which is nobody who only reads the site. A mouse update: firmware, the Swiftpoint app, what a button does out of the box. People who only use this site can skip the whole entry.
website Goes to everybody who reads "Website update". A website update: a tool, an import, the site itself. People whose problem is the hardware can skip the whole entry.

The submit label is Publish update when creating and Save changes when editing.

Two conveniences on open: a moderator writing from a filtered list gets that filter as the default kind, and the first time a mouse update is written the accent follows the kind — lime becomes violet, "because a mouse entry that looks like a site entry is a mis-read, not a choice".

Saving

POST /api/forum/updates or PUT /api/forum/updates/:id with:

{
  "kind": "mouse",
  "title": "…",
  "summary": "…",
  "version": "…",
  "accent": "violet",
  "tags": ["oled", "builder"],
  "body": "…",
  "pinned": false
}

Success closes the modal, clears the draft, toasts Update published. or Update saved. and reloads the list. Failure writes #up-error and re-enables submit.

Limit Value
MAX_TITLE 140
MIN_TITLE 4 (Give the update a title.)
MAX_SUMMARY 180
MAX_VERSION 40
MAX_TAGS 4
MAX_BODY 32000
draft key sp:updates:draft

The editor's own max: is 32000, which matches. test/limits.test.js exists because it once did not: the counter promised 8000 (the editor default) while the server kept 4000, so a long release note came back half-written with a "saved" confirmation. That test now checks the agreement — that no editor ever promises more than its server keeps — rather than freezing the number.

The shared preview endpoint POST /api/forum/preview serves both the forum and the updates editor, and derives its guard from the caps: PREVIEW_MAX = Math.max(forum.MAX_BODY, updates.MAX_BODY).

Markdown in an update

Updates use the posts engine with the up- class prefix, from their own renderer instance (createRenderer({ prefix: 'up' })), so a post and an update are styled independently and neither can change the other by writing a heading.

The editor is created with blocks: true, which is the only place in the product where the changelog toolbar row appears. The five blocks are:

Block Badge
::: added New
::: changed Changed
::: fixed Fixed
::: removed Removed
::: security Security

They render as <section class="up-entry up-entry-<type>" role="note" aria-label="<kind>"> with a badge paragraph, so the kind is announced and not only coloured.

Because updates are staff-only, env.changelog is not passed — the renderer's own condition is allowed = !env || env.changelog !== false, which is true when there is no env — so they always render. In a forum post they render only when the author is staff, decided from the author's own standing so a post looks the same to everybody who reads it.

Also available in an update body:

  • ::: note, ::: info, ::: tip, ::: warning
  • ::: spoiler "Title" — a native closed <details>; untitled it reads "Click to reveal"
  • ::: video "Title" <link or id> — the full option set, and the same videoEmbed() the forum gets, so a guide and a post cannot end up with two different iframe shapes
  • breaks: true, linkify: true, html: false, server-side code highlighting

Not available:

  • ::: steps — an update has no recording resolver at all (createRenderer({ prefix: 'up' }) is created without one), so a ::: steps in a release note always renders No walkthrough at …
  • ::: tabs
  • ::: danger, ::: success, ::: example
  • Obsidian > [!note] callouts
  • wikilinks and relative images

A worked example using everything:

Firmware 2.1 is out. It fixes the OLED going black after a wake, and it changes
one default. Read the **Fixed** block first if your display misbehaves.

::: fixed
The OLED no longer blanks when the mouse wakes from sleep. Wake the mouse from a
cold start on firmware 2.0 and the panel would stay black until the next click.
:::

::: changed
`Wake delay` now defaults to **200 ms** instead of 500 ms. Set it back under
**Settings → Display** if you preferred the slower wake.
:::

::: added
A **Tilt autozero** binding kind. Recalibrate the tilt sensor without reinstalling
the app.
:::

::: removed
The `legacy-hid` import route. It has been dead since 1.9; use **Checker → Import**.
:::

::: security
An import could write a profile outside your own profile folder on a machine
where two accounts shared a path. Fixed in 2.1. If you shared a profile directory
between accounts, rotate your profile folder once.
:::

::: tip
Firmware is signed. If the updater says *signature mismatch*, you have a partial
download — unplug, wait ten seconds, plug back in.
:::

::: spoiler "Full changelog for 2.1"
The complete list, including the four commits that only change log strings.
:::

::: warning
2.1 is the last release that supports the 2023 driver bundle. Update the driver
before you update the firmware.
:::

::: video "Flashing firmware 2.1" https://www.youtube.com/watch?v=dQw4w9WgXcQ
:::

See also Markdown blocks.

Where updates appear

Surface How
/updates the full list, grouped by month, filterable
the front page the 3 newest, server-rendered by landingUpdates() in routes/seo.js, so they are there without JavaScript
the forum sidebar #side-updates, the 3 newest, each linking to /updates#u-<id>
the sitemap /updates, changefreq=weekly, priority=0.6

The front-page rows carry the title, the kind as a badge, the version as a badge, the date and the teaser.

Publishing announces to staff with new_update:

notifyStaff({
  type: 'new_update',
  actorUserId: me.id,
  actorName: me.username,
  text: `${out.update.kindLabel}: ${out.update.title}`,
});

That is why writing an update is in the header's staff menu: #menu-staff-updates, labelled Write an update, hidden unless isModerator, and pointing at /updates?write=1. The page reads that flag, strips the query with history.replaceState, loads the list and then opens the editor — "a person who has just decided to write an update should land on the editor and not on a list they then have to find a button in".

How this is tested

test/forum.test.js carries the four update cases:

  • an update carries what makes a list of them readable — kind, title, summary, version, accent, tags, pinned, author and the rendered body all survive a round trip;
  • an update field is cleaned, not trusted — unknown kinds and accents fall back, unknown tags are dropped, over-long fields are truncated;
  • updates are public to read and moderator-only to write — a signed-out reader gets the list and a refusal on every write;
  • a published update is announced to the people who run the site — the staff notification fires with the kind label and the title.

test/ui-shape.test.js covers the page:

  • the update editor is a changelog editor, not a title and a box — all seven mounts exist, the modal is full width, and #up-preview is built by calling updateHtml() and repainting;
  • an update has a colour and a kind, both fixed lists — every one of the six accents has an .up-item.a-<accent> rule, critical resolves to var(--red), and every one of the five changelog kinds has a word, a colour, a toolbar button and an aria-label;
  • the update fields are capped and cleaned, and unknown values are dropped — the four ALTER TABLE updates ADD COLUMN migrations exist, MAX_SUMMARY = 180, MAX_TAGS = 4, unknown tags are gated on TAG.has(v) and an unknown accent returns lime;
  • the changelog blocks are for the updates form and nowhere else — wantBlocks is opt-in, every editor declares it, and the cheat sheet only lists the blocks for the editors that can insert them;
  • writing an update is reachable from the header, for staff — the menu ids exist, the entry is labelled "Write an update", it is gated on isModerator and its href is /updates?write=1.

test/limits.test.js carries:

  • the updates and guides editors allow 32000 characters;
  • no editor promises more than the server keeps — the check that the original 8000-vs-4000 drift would have failed;
  • the preview guard is at least as large as any surface it serves.

test/forum-pages.test.js checks that the update kinds are the same on both sides of the wire: every kind in updates.KINDS appears in public/js/updates.js and has a .k-<kind> rule in public/css/updates.css, and the page's own prose explains both kinds.

See also Testing.