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.
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.
criticalexists 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
Pinnedbadge - the title, or
Untitled update - a meta line with
<time datetime>and, whenupdated_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-searchhas no input listener. The field is only ever written to from?q=(readUrl()setssearchEl.value). Typing in it does nothing, soqreaches the API only through the URL or by clicking a tag chip.test/ui-shape.test.jsdoes not cover this.- There is no pager. The page sends
limit=60and never an offset, even though the API acceptsoffsetand returnstotal.
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 samevideoEmbed()the forum gets, so a guide and a post cannot end up with two different iframe shapesbreaks: 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::: stepsin a release note always rendersNo 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-previewis built by callingupdateHtml()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,criticalresolves tovar(--red), and every one of the five changelog kinds has a word, a colour, a toolbar button and anaria-label; - the update fields are capped and cleaned, and unknown values are dropped — the four
ALTER TABLE updates ADD COLUMNmigrations exist,MAX_SUMMARY = 180,MAX_TAGS = 4, unknown tags are gated onTAG.has(v)and an unknown accent returnslime; - the changelog blocks are for the updates form and nowhere else —
wantBlocksis 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
isModeratorand 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.