156 labels named no field: no htmlFor and no control inside, so
clicking one did not focus the input and screen readers announced the
input without a name. Each now points at its field through a useId()
id. The change is additive only (ids and htmlFor), checked by comparing
tokens with the previous version.
Each list page restyled DataTable on top of the shared look: fourteen
re-skin classes, filled grey headers, padded cards inside padded pages
and nth-child column widths (which put Docker's Name column 300px in
once the checkbox column existed). The table wrap is now the only
frame, the header is unfilled, column widths come from the column
definition, and raw tables and ui Table uses render through DataTable.
scripts/check-tables.mjs holds it in lint.
Page bodies drop the 1400px reading column (and the per-page caps on
Domains, Deployments, Dashboard, detail pages and Recipes) for one
20px gutter everywhere; Settings caps only its form column. The top
bar gets the sidebar's surface and edge so the chrome reads apart from
the canvas, and the bulk-selection pill clears the status bar.
Also removes decorative chrome: gradient letter avatars and tinted
stat icons become neutral, Domains rows lose the icon tile and the
"Registrar -" sub-line, and destructive row actions are quiet ghost
buttons that turn red on hover (.btn-ghost.is-danger).
Service, database, security, Docker, monitoring and deploy-console
components showed English busy labels, status arms and fallbacks in
every locale. They now go through t() in house style, with _one/_other
plurals for counts. Sentences that were split around an element
(". The statements are unlikely to apply cleanly.") become whole
keys, so a translator can reorder them. LogFileList and
ImportDumpModal resolve their declared keys instead of raw labels.
Errors read five ways ("Failed to X", "X failed", "Could not X",
"Error loading X", "Couldn't X") and many dropped their period.
407 core strings now say "Couldn't {verb} {object}." with any
{{placeholder}} kept after it, and the generic common.error strings
say what to do next.
Interpolated English verbs could not be translated and produced
"Failed to restart container"-style grammar for every action, so 17
sites (container, compose, service, unit, process, alert and on/off
toasts, sort labels) now pick one string per action. 37 hand-rolled
plurals ("(s)", item{{value}}, fragment + `count === 1 ? '' : 's'`)
use i18next _one/_other keys: extract-i18n reads defaultValue_one /
defaultValue_other into k_one / k_other, and check-i18n accepts a
translator's extra forms (k_few, k_many) beside the English pair.
Validation says what is valid (IP or CIDR range, JSON payloads,
verification codes, sign-in links). The PRIMARY backup badge and the
upper-cased service type are plain text; container status labels go
to sentence case. Orphaned translations pruned; the i18n literal
ceiling drops to 840.
Error toasts said either what failed ("Failed to delete service")
with the reason thrown away, or only the server's reason ("Not
found") with no subject. utils/errorMessage.js is one door for both
halves: errorReason(err) reads data.error, data.message, then
err.message (a translated error code wins), and turns bare transport
failures (Failed to fetch, Request failed (500)) into a next step;
toastError(toast, message, err) renders "What failed. Why."
Moved onto it: 236 `err.message || t(...)` sites, 58 bare t() toasts
in catch blocks that dropped the error (51 gained a catch parameter),
and 63 toasts that showed only err.message, which now say what failed
under new keys. 56 "{{message}}" interpolations and ErrorState read
the reason through errorReason. The pages/ toast ratchet counts
toastError calls, since they are the same per-page presentation.
Applies the copy glossary to about 370 strings and 64 call sites:
service (not app/application/site), sign in/out (not log in/out),
server and panel server (not box/machine/host), Delete destroys /
Remove detaches / Uninstall is for software, New opens a form and
Create submits it, settings, backup vs snapshot vs restore point,
extensions and Features, environment variables, compose project,
two-factor authentication. Confirm dialogs that destroy something now
use the same verb on the title and the button instead of "Confirm".
Backend messages that reach toasts verbatim ("Application not
found" x95 and others) are listed in plan 88 for the typed-error
work rather than edited here.
1,250 strings across 223 files, edited at their inline English
defaults (en.json regenerated, keys unchanged):
- sentence case for buttons, titles, labels, tabs and nav (827),
with an allowlist of product names and acronyms
- every em dash rewritten (184); "successfully", neutral "!" and
short-toast periods dropped; helper sentences end with a period
- "Are you sure?" confirms now state the consequence; "Danger Zone",
"no going back", "to get started" and "You're all set" gone
- straight quotes, "…", "time zone", unit spacing, "and" for "&"
- Confirm/Cancel and four other hardcoded labels wrapped in t()
scripts/copy-rewrite.mjs applies a {key: English} map at every call
site; the glossary pass reuses it.
A failed request in these tabs read as "nothing here": the firewall
tab offered to install a firewall, Cloud said not connected, About
invented version 1.0.0, and the security config, site settings and
2FA policy forms rendered with defaults, so saving after a failed
load would overwrite the real settings. Each tab now separates
loading, error (with the server's reason and Try again) and empty,
and keeps existing data with a compact error when a refresh fails.
Table columns, detail rows, the status bar, the sidebar and the
dashboard widgets showed dates, counts, enum words and labels in the
monospace face. They now use the UI face (sk-cell-dim for dim table
cells); monospace stays on hosts, IPs, ports, versions, paths, IDs,
hashes, code and logs. check-type-scale ratchets the number of
monospace rules so it only goes down.
Secrets (API key, webhook secret, 2FA setup) are CopyFields that stay
masked until revealed, SMTP and firewall ports are PortFields, the
canonical and base domain inputs are DomainFields, detail rows are
InfoList, and every native select is the shared dropdown.
en.json drops from 8107 to 6624 keys: repeated UI copy (Cancel x95,
Status x67, Refresh x54, ...) now lives under common.actions /
common.labels / common.loading, and extractor-era suffix duplicates
(refresh2, status3) within a component collapse onto one key.
Trailing ... defaults are normalized to the ellipsis character.
Translations are preserved: all 16 locales were re-keyed, carrying
existing per-component translations onto their new common.* keys
(majority vote where sources disagreed), and stale keys were pruned.
Verified: npm run lint (0 errors), node --test (140 pass), vite build.
edit
Mass conversion of ~8,000 English literals across 320+ files to
t('app.<file>.<key>', 'English default') calls, plus hand-conversion of
the 43 literals the codemod refused (concatenated toasts, module-level
helpers, class components) using bare t() from i18n/t or restructured
{{placeholder}} keys. Marketplace unsigned-release dialog now translates
the Install/Update verb inside the placeholder.
ESLint: 0 errors. Literal checker: 0 remaining.
statusKind()/statusVariant()/statusDotClass()/statusLabel() drive both
Pill kinds and Badge variants from ONE table; ~40 per-page maps across
35+ files migrate to it. Scoped overrides stay in the same file:
serviceStatusKind (running IS the healthy steady state for services/
units/previews -> green), processStateVariant, alertStatusKind,
envActionKind, impactTone. Unifications: run-shaped 'running' is cyan
everywhere (Git.jsx's amber gone), QueueDetail/QueueOperations' invalid
blue/yellow kinds fixed, severity tones (critical/high/medium/low)
agree across security surfaces. Ratchet: npm run check:status fails on
new local maps outside ds/status.js (frozen allowlist for domain maps
and D4-territory hex/css-class renderers). Lint + build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Docker's four remaining tabs (Compose, Images, Volumes, Networks) drop their raw
<table className="docker-table"> markup and bespoke toolbars for the shape
ContainersTab already used. Each gets a distinct viewPageKey AND urlScope, since
one route now mounts five tables and a shared scope has them writing each other's
?view=. Views were chosen from what the payload actually carries rather than what
would read well: Volumes has no "Unused" view because neither transport reports a
reference count, and a Networks "Containers" column was dropped because the agent
sends the count and the local service does not — a column that is permanently
blank on half your hosts is the exact failure this round exists to stop.
Terminal stops hand-rolling two surfaces that already had converged twins in the
repo, imported by nothing but the Style Guide: Processes renders ProcessTable,
and Services renders serverdetail/ServicesTab, taught to serve the local host as
well as a paired agent. This was mostly deletion.
Databases: the explorer's tables move onto the shared chrome, losing three raw
search inputs, two page-level row counts and their segmented rows.
Deployments: the fake table (a header row over <button> rows) becomes a real one,
and the target-server <select> becomes the Target column's own menu.
Templates keeps its card grid — a catalog is a legitimate place for tiles — but
loses the row count, filter-chip row and sort control the shared chrome replaces.
FTP and WorkspaceDetail get chrome on tables that had none.
components/SearchFilterBar.jsx is deleted: a complete competing search/filter/sort
bar with zero importers, confirmed not on the SDK export list.
Fixes for regressions the verification pass caught, before they shipped:
- Terminal > Processes swapped the whole table out on every `loading` tick. It
auto-refreshes on a timer, so the chrome, active view, column rules, sort and
scroll position were being unmounted and rebuilt every few seconds. Only the
first load swaps now.
- The Databases backups pane returned its empty state INSTEAD of the chrome, so
a tab with no backups had no Refresh — exactly when you want one, because a
backup that just finished is why the list looks empty.
- Every open table tab kept its chrome mounted while sharing one urlScope, so
four open tables fought over ?dbstruct.view=. Scoped per tab. The process list
is namespaced per ENGINE too: `state` reads p.command on MySQL and p.state on
PostgreSQL, so one namespace let a MySQL view apply itself to Postgres and
match nothing.
- The Images size column disagreed with itself: the rule value was base-1000 MB
while the cell rendered base-1024, so "Size is over 400 MB" matched a row whose
own cell read "392.9 MB".
- Deployments' server filter used to reach the whole history through ?server_id=;
as a column rule it only reaches what was fetched. The window is explicit now —
a full page offers Load more instead of quietly filtering a truncated list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workspace sites/services/servers/members, the domain DNS record grid and the
service packages table now use the shared primitive with sortable columns and
footers; conditional DNS columns and fixed-layout classes preserved.