Taking a board from one version to the next: what to do, in what order, and how far you can jump — followed by the behaviour changes each upgrade brings, so nothing changes under you unannounced.
Deploy the new code, then run the upgrade:
meith upgrade --dry-run # read what it will do
meith upgradeOn the documented deployments the core migrations are already applied by
then — the migrate container runs to completion before anything
serves — so upgrade is what carries plugin migrations and records the
version. The admin panel shows a notice until you run it.
meith is the operator CLI;
Operations § The operator CLI has
the invocation for each deployment.
Caution
Migrations are forward-only. Restoring a backup is the only way back, which makes the backup your rollback plan rather than a precaution.
There is no down migration and there will not be one: a down migration that drops a column is a data-loss button on a live board, and some migrations — a destructive backfill, a column collapsed into another — cannot be reversed at all. A "roll back" that worked for some and silently did nothing for others would be worse than its absence.
Take a backup before every upgrade, and make sure it is one you have actually restored at least once. See backup and restore.
Four things, in this order:
- Core migrations. Everything else assumes the schema they create.
- Plugin migrations, per plugin.
- Plugin versions recorded, one per plugin.
- The core version recorded, last.
The version is written last on purpose: a version written before the work would mean a failed upgrade leaves a board claiming to be something it is not — and the next run would find nothing to do. Same reasoning as the installer's seal.
Plugin dependencies are declared, not guessed. A plugin names what it needs:
export const badges = definePlugin({
key: "badges",
version: "1.2.0",
dependsOn: ["points"],
// …
})The planner refuses a dependency cycle (naming the tangled keys) and a
dependency on a plugin that is not installed (by name), rather than
quietly running against a table that does not exist. Migrations are then
applied plugin by plugin in the order meith.plugins.ts lists them —
so list a plugin after the plugins it depends on.
An interrupted upgrade is safe to re-run. Each plugin migration is applied and recorded in one transaction — the only arrangement that survives a crash between the two. Applied-but-not-recorded would re-apply on the next run; recorded-but-not-applied would be a column that never exists. Because the two are atomic, "try the upgrade again" is always a safe instruction: a re-run re-applies nothing it already did.
The plugin list is your board's. meith upgrade reads
meith.plugins.ts, compiled into the command when the image is
built. A plugin listed with enabled: false is skipped — creating tables
for code that will not run would leave your schema ahead of your board.
Two majors. A board at 1.x can upgrade directly to 3.x; 1.x to 4.x is refused.
The limit is honesty rather than caution: supporting an arbitrary jump would mean every migration staying correct against every schema that ever existed — a promise nobody can test, and therefore one that should not be made. Two majors is what the migration set is exercised against, so two majors is what is claimed.
A board further behind is not stuck — upgrade in stages, checking out the last release of each major in turn:
git checkout v2.9.1 && docker compose up -d --build && meith upgrade
git checkout v3.6.0 && docker compose up -d --build && meith upgrade
git checkout v4.2.0 && docker compose up -d --build && meith upgradeEach stage is an ordinary upgrade with an ordinary backup in front of it.
Refused. Migrations are forward-only, so "downgrading" would mean running old code against a schema that has been migrated past it — which usually appears to work and corrupts something a week later.
| Situation | Do this |
|---|---|
| You deployed a version you did not mean to | Deploy the newer one again |
| The newer one is broken | Restore the backup |
Under Coolify, the upgrade is the Redeploy
button, pressed after a release. The compose file pins the exact version
(ghcr.io/meith-dev/meith:0.6.0), and every release moves that pin on
the release branch — so a redeploy deploys whatever release the branch
holds, exactly.
Know what else presses that button. Coolify re-reads the compose file from the branch head on every deploy of the resource, and on a compose resource the panel's Restart button is a deploy — it re-runs the deployment rather than restarting the containers. If a release has landed since you last deployed, Restart upgrades you to it, migrations and all. Only restarts below the panel — a crash, a host reboot — are guaranteed to re-create the version already running.
That is why the Quickstart
has you pin the version on the resource: MEITH_IMAGE in the resource's
environment, set to the exact image, wins over whatever the branch
holds, so Restart and Redeploy re-create that version and nothing else.
The upgrade is then three deliberate acts: take the backup, move
MEITH_IMAGE to the new version, press Redeploy.
Without the pin, enable the webhook and releases deploy themselves; leave it off and upgrades wait for a button — either button. Take the backup first in any case.
The one ceremony you may skip is for a patch: the release policy is that a patch never carries a migration, which is what makes taking one immediately always safe.
Under Compose it is three commands — check out the release, never
main:
git fetch --tags
git checkout v0.6.0 # the release you are moving to
docker compose up -d --buildEither way the ordering is handled for you: migrate runs to completion
first, and web and worker wait for it, so the new code never serves
against the old schema. That covers core migrations only — plugin
migrations still go through meith upgrade.
A board scaffolded by create-meith — one with its own package.json
depending on @meith/web, whether it deploys as a container or to Vercel —
upgrades by moving that manifest, since nothing else names the version:
npm install --save-exact @meith/web@latest @meith/cli@latest @meith/theme-default@latest
npm install --save-exact next@$(node -p "require('./node_modules/@meith/web/package.json').dependencies.next")The second command matters as much as the first. Such a board pins next
itself — a Vercel deployment needs it in the manifest, because that is where
the platform reads the framework version it builds with
(Building where Vercel looks) —
and no release moves that pin for you. Bump only the @meith/* packages and
npm resolves the mismatch by installing two copies of Next: the board's
pinned one at the root, and the one @meith/web now depends on nested under
it. forum-web runs whichever Next @meith/web resolves, while everything
reading package.json — Vercel's builder included — sees the other. Reading
the version straight out of the freshly installed @meith/web is what keeps
the two the same without anybody having to know the number.
Inside this repository the same coherence is a check rather than a
convention: scripts/workspace-check.mjs refuses a tree where any manifest,
or create-meith's own scaffold, pins a different next from @meith/web.
It cannot reach a board outside the repository, which is why that board's
README carries the second command too.
The compose files pin the database image, and a release can move that pin
across a Postgres major version — as the move from postgres:16-alpine
to postgres:18-alpine did. A Postgres major is not an ordinary
redeploy: the data directory's on-disk format belongs to the major that
wrote it, and the 18 image also keeps its cluster at a different path
inside the volume than 16 did — so bringing the new image up on the old
volume gets you a board that is empty rather than broken, which is
worse, because it looks like data loss and is merely data ignored.
The way across is the backup, which is the point of this page's first section. On Compose, from the checkout of the new release:
mkdir -p backups
docker compose build web
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" -v "$PWD/backups":/backup web \
node apps/cli/cli.cjs backup --out /backup/pre-18.tar.gz
docker compose down
docker volume rm docker_pgdata
docker compose up -d postgres
RESTORE_DATABASE_URL="postgres://community:$POSTGRES_PASSWORD@postgres:5432/community" \
docker compose run --rm --no-deps -e RESTORE_DATABASE_URL -v "$PWD/backups":/backup web \
node apps/cli/cli.cjs restore /backup/pre-18.tar.gz --skip-uploads
docker compose up -d --buildThe mkdir and the --user are what let the container write the bundle
onto your host at all — the image runs as uid 1001, which owns nothing
there; Backup explains it in full. Get them
wrong and the run refuses immediately, before it dumps anything, naming
the directory that needs write access — so you find out here rather than
two lines further down, where this runbook destroys the volume.
The new image's pg_dump reads the old server fine — clients dump any
older server, which is why the backup comes from the new build against
the still-running old database. docker volume ls names the real
pgdata volume; --skip-uploads because the uploads volume never went
anywhere and a restore refuses to write into a directory that is not
empty. The restore reads the same mount and needs no --user, since
files in the image are world-readable. Keep the bundle until the board
has served for a while — it is also the rollback.
Under Coolify the same sequence runs from the resource's terminal, with the volume deleted in the panel between the backup and the restore.
Deploy some other way and the two come apart: the board runs the new code as soon as the deployment is live, and the schema does not change until you run the command. Between them, new logic is talking to an old schema.
That window is why the admin notice exists: it names both versions and the number of migrations waiting, so the failure mode — "column does not exist" in whichever request touches it first — becomes a sentence somebody read before it happened.
For a board with real traffic:
| Migration kind | When to run it |
|---|---|
| Adds things only | Before or after the deploy; either is safe |
| Removes or renames | Two-step: ship code that tolerates both shapes, migrate, then ship code that assumes the new one |
Release notes say which kind each release is.
On a platform that only builds and serves, there is nowhere to run a one-shot migration job, so the migration goes in the build command, ahead of the build:
meith migrate && forum-web buildThe && is the whole mechanism. A migration that fails exits non-zero,
the build never starts, and the deployment fails carrying the
migration's own error rather than shipping new code onto an old schema.
There is deliberately no forum-web build --migrate: keeping them two
commands is what makes a failed deploy attributable to the step that
actually failed.
This does not remove the window the previous section describes — it turns it around. The migration runs during the build, while the previous deployment is still serving, so between the migration and the cutover it is the old code talking to the new schema. For a release that only adds things, that is safe.
For one that removes or renames, the two-step rule still holds, but the freedom to order its steps does not survive the move. The previous section could sequence them however it liked because the migration was a separate operator action, run at a moment of your choosing; welding the migration to a build spends exactly that degree of freedom. What is left is a single invariant:
A release's migration must be tolerated by the release before it, because that is the code serving while this release's build migrates.
So the destructive migration cannot travel in the same release as the tolerant code. That release's build would run the migration while the pre-tolerant release is still live — which is precisely the breakage the two-step exists to prevent. The two steps have to be two deploys:
- Release A ships code that tolerates both the old and the new shape, and carries no destructive migration. Its build migrates nothing that matters, and it goes live.
- Release B carries the destructive migration and the code that assumes the new shape. B's build is safe only because A is already live and serving — A's tolerant code is what meets the new schema during B's build.
Shipping A and B as one release is the mistake this rule is for.
The && guards one direction only. It stops new code reaching an old
schema; it does nothing about the reverse. If meith migrate
succeeds and forum-web build then fails, the deployment aborts with the
migration already applied and the previous release still serving, and it
stays that way until some later build succeeds. The window this section
opened with stops being a window and becomes the board's steady state.
The instinct at that point is to roll back, and rolling back does nothing: the old code is already what is serving, and no rollback undoes a migration. Fix the build and deploy forward. Until it lands the board is running the previous release against the new schema — the tolerated case for a release that only adds things, and the broken one for a migration that has just dropped something the live code still reads.
Overlapping builds are safe. Two deploys triggered close together queue on an advisory lock, and the second finds nothing left to do — see Migrations.
A rollback is not one of them. The instant rollback these platforms offer
— promoting a previous deployment, re-pointing an alias at an artefact
that was built already — runs no build, so it never calls meith migrate. There is nothing to queue on the lock and nothing that could
undo the schema. Rolling back the other way, by redeploying an older
commit, does build and does run meith migrate, which then applies
nothing, because migrations are forward-only.
Either route puts the old code back and leaves the schema where it is, so a rollback is only safe while the older code tolerates the newer schema. Downgrades still applies.
Nothing here widens how far you can jump. A build-time migration is the same migration set under the same two-major limit, and a board further behind still upgrades in stages.
The build command is the build command. It runs for every deployment the
platform builds — the pull-request preview, the branch deployment, the
redeploy of an old commit — and each of those runs meith migrate
against whatever database that deployment's own environment variables
name.
This is where the pattern cuts. These platforms commonly default a new
variable to every environment, which points preview and branch builds
at the production database; the first preview build of an unmerged branch
then migrates production, from a schema nobody has reviewed, with no
deploy of that branch ever having happened. Nothing in the build command
can detect this, because from the migration's point of view it is an
ordinary run against an ordinary DATABASE_URL.
Scope DATABASE_URL and DIRECT_DATABASE_URL to production, and give
preview and branch environments a database of their own — a separate
instance, or a branch of the managed one where the provider offers that.
Check the scoping before the first preview build rather than after: by
the time it is visible the migration has applied, and a migration does
not come back off.
Such a platform's database is usually a managed one, which means
DATABASE_URL is a transaction-mode pooler string that cannot hold the
lock. Set DIRECT_DATABASE_URL to the direct string as well, in every
environment that builds — see
connection pooling.
The rest of this page is the operator-facing change log for the 0.x series: defaults that moved, settings that started being enforced, and behaviour that changed shape. Skim the headings; each entry says whether a board that never touched the setting is affected.
A board setting is stored only once somebody changes it, so a default that moves applies to every board that never touched the switch. There is nothing to run; the point of listing it is that behaviour changed without anybody on your board doing anything.
| Setting | Was | Is | On a board that never set it |
|---|---|---|---|
reputation.comment_required |
on | off | Posts gain a one-press Thanks button; a rating no longer has to carry a reason. Set it back under Settings → Reputation if your board wants every rating to say why — the right choice for a board that allows negative ratings. |
Two things that were environment variables and nothing else are board settings now. Nothing changes for a board that had them set — the environment still wins outright, and the settings screen says so. What changes is the board that never set them, which previously could not fix either without a redeploy:
| Was | Is | |
|---|---|---|
MAIL_DRIVER and friends, read at boot |
MAIL_DRIVER=http or =smtp still wins. log, or unset, hands the decision to /admin/settings?group=mail — which has a Send a test message button that shows the provider's own refusal verbatim. |
|
| The board's address | APP_URL, read at boot |
APP_URL still wins. Unset, it comes from Board address on /admin/settings?group=board, and the installer asks for it on a fresh board. |
Worth doing once after the upgrade: open /admin/settings?group=mail and
press the test button. Mail is the subsystem where a misconfiguration is
silent by construction, so "we believe mail works" and "a message
arrived" are worth reconciling.
MAIL_DRIVER=smtp boots now. It used to refuse to start, on purpose:
there was no SMTP driver, and quietly downgrading to the log driver would
have meant password resets vanishing with no error. There is a driver
now; MAIL_SMTP_HOST and MAIL_FROM are required with it. If you have
been running a separate relay to bridge the gap, it can go.
TICK_DEADLINE_MS and TICK_MAX_JOBS are gone. They were read by
nothing — declared, documented, and consulted by no code. Leaving them in
your .env is harmless (unknown variables are ignored); delete them when
convenient.
A setting can also change behaviour by starting to be read. Nothing to run — but worth knowing which switches on your board were, until now, decorative.
posting.edit_grace_seconds had no reader: the Last edited by line
rendered on every edit whatever the box said. It is read now, with a
default of 300 seconds, so an author fixing their own post within
five minutes leaves no notice. This is a visible change on a board that
never touched the setting — notices that used to appear on quick typo
fixes stop appearing. posting.edit_grace_seconds 0 restores the old
behaviour exactly.
Nothing is hidden that should not be: a moderator editing somebody else's post is never silent, the revision history is unchanged, and a silent edit does not clear a notice already on the post.
search.min_word_length never reached the query parser, which carried a
hard-coded 2 — a board that asked for 5 got 2, and a board that asked for
1 also got 2. It is read now, and its default moved from 3 to 2 in the
same release, so nothing changes on a board that never set it: 2 is
what every board has actually been enforcing. A board that stored a
number now gets that number, which is the change it asked for.
The rule is at least one word, not every word: a search is refused only when every word in it is shorter than the setting, and short words in a search that also has a long one are passed to the index. The label now says so.
Another switch nobody read: the Search link was unconditional, /search
ran queries, and the API answered them. It is read now, in all three
places — the link goes, the search pages say search is off, and
GET /api/v1/search answers 403. The default is on. A board that
stored false loses its search the moment it upgrades — which is what
it asked for, though worth telling your members. The index is still
maintained while search is off, so switching back on needs no reindex.
Whatever the switch said, /register rendered its form and the action
created the account. It is read now: off takes the Register link away,
replaces the form with a notice, and answers a direct POST with 403. The
default is on, so an untouched board sees nothing change; a board that
stored false gets the closure it asked for on upgrade — and if it has
been quietly accepting registrations, its member list is worth a look.
Neither the installer nor meith user:create consults it: an
operator at a terminal cannot be locked out of the board they are
installing. See
The organiser's guide § Registration.
The activation dropdown stored its value and every account was created as
though it said none. It is honoured everywhere now, and its default
moved to none in the same release, which is what keeps this from
changing anything under you:
| Your board stored | Before | Now |
|---|---|---|
Nothing — never opened the screen, or chose email while it did nothing |
Accounts active immediately | Unchanged |
none |
Accounts active immediately | Unchanged |
admin |
Active immediately, contrary to the setting | Accounts wait for an administrator |
both |
Active immediately, contrary to the setting | A confirmation link, then an administrator |
The first row needs explaining: a value equal to its default is not
stored, so an operator who selected email back when it did nothing has
no row and is indistinguishable from somebody who never opened the
screen. Defaulting to email would have switched confirmation on for
both — on boards that very often had no mail configured, leaving them
unable to register anybody. The default follows the behaviour every
board actually had.
If you did want confirmed addresses, say so again — this time it works. Configure mail first, prove it with the test button, then set the method under Settings → Registration.
Important
email or both on a board with no working mail is a board nobody can
join: the links are minted, written to the log, and never sent. The
registration settings screen and /admin/system both say so for as
long as it is true.
Accounts stuck at awaiting activation can be activated by hand under
Admin → Members, and anybody who never received a link can ask for
another at /verify/resend.
registration.min_password_length, registration.username_min and
registration.username_max were served from constants — the form went on
enforcing 8, 3 and 30 whatever the settings said. They are read now, by
the board and by meith user:create (a CLI that enforced
different rules would create accounts the board itself would reject).
The registry defaults are 10, 3 and 30, so an untouched board gets a minimum password length of 10 rather than 8 — the one change here that can surprise somebody. It applies to new passwords only; existing passwords are untouched and nobody is locked out.
Note
A minimum username length above the maximum is impossible to satisfy, so the pair is ignored rather than enforced — both fall back to 3 and 30 and the board keeps registering people. Fix the pair on the settings screen.
The session lifetime, the failed-login count and the lockout duration were all stored and never read — the lockout ran on compiled-in constants. They are wired now, so a board that changed one of them at some point is about to get the behaviour it asked for. Worth a look before you deploy if you ever touched that screen.
Two things moved as part of it: the session lifetime now says 14
days (which is what the board was actually doing), and
security.max_account_login_attempts is new, defaulting to the 50 the
code always used. See
Spam controls and rate limits § The three login counters.
Restoring a post or thread was gated on Delete posts, so a moderator ticked for restore alone could restore nothing, and one ticked for delete quietly got the undo too. Restoring now needs Restore posts.
Nobody loses an undo they were using: a one-off migration granted Restore posts to every existing appointment that held Delete posts. New appointments get exactly what is ticked — tick both boxes if you mean both. See Forums and permissions § What an appointment grants for the nine grants an appointment carries.
Delete permanently, Manage polls and See posters' addresses granted nothing — there is no hard-delete path, no per-forum poll management, and the address lookup is staff-only — so the checkboxes are gone and their columns dropped. Whatever was ticked in them was already inert; nothing observable changes.
It read "delete a whole thread you started" and nothing read it. Granted,
the thread's author now gets a Delete thread button that moves the
thread to visibility=deleted — reversible, exactly as a moderator's
delete is; restoring stays a moderator right. It is off by default
and nothing changes until you tick it — read
letting members delete their own threads
first, because a thread is deleted whole and takes other people's replies
with it.
It read "hard-delete anyone's post", and the board has never had a hard
delete: removing somebody else's post has always gone through
canSoftDeletePosts, which is reversible. The cell was a promise the
board had no way to keep, so migration 0042 drops the column from
usergroups and forum_permissions. Whatever was ticked was already
inert.
One knock-on: the column was one of the permissions that marked a group
as carrying power (barring it from "may be granted by plugins" and
forcing its members to display as staff). canEditOthersPosts and
canSoftDeletePosts still cover that ground, so a group with real
moderation power is still barred; a group whose only power was this
dead column stops being treated as powerful — which is now the truth
about it.
"Daily post cap. 0 = unlimited" was stored, resolved and displayed, and no write path looked at it — a group set to five posts a day could post five thousand. It is spent now, in the write path, against the same database counters the hourly limits use: threads and replies together, over a UTC day, on the REST API's endpoints as much as the forms.
Check your groups before deploying this. 0 still means unlimited
and is the default, so an untouched board is unaffected — but a board
that set a number, believing it was doing something, will start
enforcing a number nobody has looked at in a while. Bypass flood check
does not lift this cap; to exempt a group, set its value to 0. See
the daily post allowance.
The same story on its own counter. 0 remains the default and remains
unlimited; check the number on any group where you set one. Do not
confuse it with privateMessageQuota, which has always worked and caps
what a member may keep, not what they may send in a day.
The backup and restore page used to
be commands you copied; it is now meith backup and
meith restore — one bundle carrying the database dump and the
uploads together, restored only into a new, empty database, with the
post-restore checks run for you. Nothing changes for a cron built on
pg_dump; the verb is the same dump with the uploads problem solved
beside it, and CI restores one on every change.
The compose files pin postgres:18-alpine where they pinned
postgres:16-alpine, and the board image carries the matching client
tools. A running board does not cross a Postgres major by
redeploying — the old data directory would be silently ignored, not
upgraded — so follow
when a release moves Postgres the next
time you take this release onto an existing board.
A post used to be anchored by its id (#post-90, under a corner reading
#6); it is anchored by its number now (#post-6), and everything the
board writes links ?post=90 instead — the thread page finds the post
and redirects to the page holding it, anchored at its number. The board
rebuilds its own links, so there is nothing to run; the gain is that a
link to the four-hundredth post of a thread now lands at the post
rather than at the top of page one.
What changes without asking is a link already out in the world: an old
#post-90 now names the ninetieth post of that thread if it has one,
and otherwise lands at the top — either way on the right thread. A theme
you maintain must anchor posts by number:
the post anchor is the shape.
Two notice parameters moved out of ?post='s way at the same time:
deleting a post now returns to ?removed=post, and restoring an
already-visible one to ?unchanged=post. They are notices on a redirect
the board issues itself; nothing stores them.
A category used to 404 when opened directly — which the breadcrumb on
every thread invited you to do. /{id}-{slug} on a category is now a
section page: its forums, listed the way the index lists them. Nothing
to run or configure.
Allow new threads on a category now means what it says. It is off on every category — the migration that ships with this makes that true for existing rows — so a board that wants nothing to change does nothing. Turn it on from the category's options; turning it off again stops new threads and returns the page to its forums (threads already posted keep their addresses and stay in search, but the category stops listing them).
The board now formats every timestamp in the zone the reader is actually
in, detected from the browser and remembered in a cookie; a reader with
JavaScript off gets UTC, and the footer says so. The migration moves
every member whose stored timezone is UTC onto the new Automatic
setting — before this, "chose UTC" and "never chose" were the same value,
and leaving them all alone would have meant the change reached nobody
with an existing account. Members who picked any other zone keep it. A
member who genuinely wants UTC picks it once, under UserCP → Options.
The board used to take the left-most X-Forwarded-For entry — which
is whatever the caller put there. It now counts back from the right-hand
end, and TRUSTED_PROXY_HOPS says how far. A board behind one reverse
proxy — the documented shape — needs nothing: the default of 1
resolves the same address it always did. Behind more than one hop (a CDN
in front of your proxy), set TRUSTED_PROXY_HOPS=2 — leave it at 1
and every visitor resolves to the CDN, visible immediately as an
allowlist that admits nobody and a moderator log full of one address.
See
Deploying by hand § Count your proxies.
Everything else on the anti-spam screen ships off; these arrived on, because each bounds something a board cannot want unbounded and none is reachable by a member doing anything ordinary:
| Setting | Default | Bounds |
|---|---|---|
antispam.register_ip_per_hour |
10/hour per /24 | Registrations from one address range |
antispam.reset_per_hour |
5/hour per address | Reset mails sent to one e-mail address |
antispam.reset_ip_per_hour |
20/hour per /24 | Reset requests from one caller |
antispam.login_ip_attempts |
100 per lockout window | Failed logins from one address, whatever accounts they name |
The one to look at is the first, and only if your members share an
address — a school, an office, a conference. 0 switches any of them
off. See
the limits on pages nobody has signed in to.
The command used to pass no plugins at all, so a board could be told by
the panel to run it and be no further on afterwards. It reads your
board's plugin list now. If you have been running a plugin whose
migrations the panel reported as pending, run meith upgrade once
more — re-running is safe, since applying and recording a migration are
one transaction and a re-run of an applied one is a no-op.
The row of links across the top of the board used to be six items compiled into the app. They are rows in the database now, and the migration seeds exactly those six with their existing addresses, ordering and audiences — an upgraded board's menu looks and behaves the way it did, including Search disappearing when search is off and My posts staying away from signed-out visitors.
What is new is that you can change it, under Admin → Content → Navigation: drag an item to reorder it, drag it to the right to hang it under the item above as a sub-menu, rename it, hide it, delete it, or add your own — to a chat server, a wiki, anything with an address. Items can be limited to an audience or to particular groups, and one marked as opening in a new tab does so in every theme that honours it. The screen works with JavaScript off: every drag has an arrow button beside it.
A theme you maintain still compiles unchanged. LinkModel gained two
optional fields, newTab and submenu. A theme that ignores submenu
renders the top level and drops the entries under it, so a theme meant to
follow the board navigation should render one level of nested links,
revealed on both :hover and :focus-within. See
the theme API.