Plan 76 milestone G, last item. 151 hex literals live outside the token files
across 35 partials. A theme is a JSON map of CSS custom properties (plan 60), so
only values written as var(--token) change when someone switches skin — every
literal is a spot that stays the default palette on every theme, which is a
functional hole in the themes feature rather than untidiness.
Per-file exact counts rather than a ban, for the reason milestone G gives
explicitly: legitimate brand and data-visualisation colours stay. They just have
to be listed, so the number is honest and can only go down.
Not migrated: picking the right token per site is a semantic judgement (is this
`--green` or `--success`?) with visual consequences, not a substitution — the
same reason .empty-state was ratcheted rather than merged.
Verified non-vacuous: adding one literal to _terminal.scss fails it (20 vs 19),
and it passes again on revert.
Gate: boundary check green, npm run lint 0 errors / 953 warnings, build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone G. 114 top-level class definitions share 50 names across
partials, so those classes have no owner: every definition applies, merged
per-property in main.scss import order, and editing one file changes only the
properties that file happens to win.
`.empty-state` is the worked example the audit named. Three partials define it —
components/_empty-state.scss (flex column, min-height 200px), _cards.scss (card
background, solid border, radius), _users.scss (padding, colour) — and the
compiled sheet carries five competing top-level rules. What renders takes its
background and *dashed* border from one, its padding and display from another,
and its colour from a third. No author wrote that element.
Ratcheted rather than merged, on purpose. Reproducing the cascade's computed
result has no byte-identical-output proof available (unlike the @keyframes
sweep, where deleting seven dead copies left the CSS unchanged to the byte), so
it needs eyes on the affected pages — plan invariant 9, no repo-wide mechanical
rewrite without behavioural tests.
The ratchet counts DEFINITIONS, not names: counting names would let an
already-shared class spread to a fourth and fifth partial without moving the
number, which is the failure mode worth stopping. Mutation-checked both ways —
adding a definition of an existing shared class and introducing a brand-new
shared name each fail it, and both pass on revert.
Wired into `npm run lint`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone G. A @keyframes name is global and last-definition-wins across
every partial main.scss pulls in, so a duplicate name is not wasted bytes — one
copy is silently dead, and which one depends on import order.
`spin` was defined 8 times. The build already emitted exactly one, so seven were
dead: deleting them left the compiled CSS byte-for-byte identical (same content
hash, 958878 bytes), which is the proof rather than an argument. Three of the
seven also differed — `to`-only rather than `from`/`to` — so anyone editing
those was changing nothing.
`pulse-dot` was the one that actually mattered: components/_uptime defined it
with a scale (1 -> 1.2) and pages/_services defined it opacity-only, and
_services is imported later, so the uptime dot has been running the services
animation. Renamed the uptime one to `uptime-pulse-dot`; service-detail keeps
the shared `pulse-dot` it already got.
**Visual change**: the uptime status dot now scales as its own stylesheet always
said it should. Nothing else moves.
`pulse` (identical twice) and `drawer-fade`/`drawer-slide` (identical in
_docker and _file-manager) are consolidated into base/_utilities.scss, so
neither page owns the other page's animation.
Ratchet: the boundary check now scans every .scss partial and fails on any
@keyframes name defined more than once. 66 unique names, zero duplicates.
Verified non-vacuous: an injected second `spin` fails it, revert passes.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, boundary check
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 0bfcf69c. Ratchet 16 sites / 12 files -> 11 / 10.
Dashboard's remote-metrics poll and its socket-down HTTP fallback, ImportWizard's
active-import poll, and TestSandbox's run poll plus its open-log fan-out.
TestSandbox's log refresher fanned out one request per open log per tick and
returned nothing, so a tick reported done before any of them had come back. It
now returns a single Promise.all, which is what the in-flight guard needs to
mean anything for a fan-out.
That leaves 11 raw setInterval sites, and they are deliberate:
- 5 are clock ticks with nothing to overlap and nothing to fetch — an elapsed
timer, a countdown, a board refresh pulse, two wall clocks.
- 3 are the socket-fallback pollers in useMetrics, useContainerStatus and
useDeployJobStream, driven by imperative startPolling()/stopPolling() from
socket handlers with a 3s grace period. Expressing that as a declarative
`enabled` is a real restructure of each hook and they only poll when the
socket is already down.
- 2 belong to the serverkit-gui extension, whose source is the sibling repo;
its frame loop already carries its own in-flight guard.
- 1 is ServerGui's user-pausable capture interval.
The ratchet keeps all of them at exact counts, so the reasoning above has to be
revisited rather than forgotten if one changes.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 89c3896e. Ratchet 22 sites / 15 files -> 16 / 12.
DoctorPanel's fleet sweep and ScannerTab's malware scan were imperative
pollers: a function started an interval, and the interval cleared itself once
the job reached a terminal status, with a second clearInterval in an unmount
cleanup to catch the case where it never did. Both now hold the job id in state
and let usePolling own the schedule —
const pollSweep = useCallback((jobId) => setSweepJobId(jobId), []);
usePolling(tick, SWEEP_POLL_MS, { enabled: Boolean(sweepJobId) });
which is what makes the visibility pause and the in-flight guard apply to them
too. A job poller is the shape that most wants both: it runs while someone
watches a spinner, and its endpoint is doing real work.
Also ContainersTab's stats poll and log tail, and OnboardingWizard's status
poll (its 1s elapsed-time clock stays a plain interval — nothing to overlap).
Two `useRef`s and one `clearInterval` in an unmount cleanup went with them.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing f9657d38. Ratchet 27 sites / 18 files -> 22 / 15.
Terminal's three tab components (file logs, journal, processes), ServicesTab's
unit log tail, and the GUI extension's synthetic desktop. All were the
auto-refresh tail shape: poll only while the toggle is on, never fetch on mount
from the interval effect.
These are the sites where a hidden tab mattered most — a log tail at 3s left
open in a background tab is the single most wasteful poller in the panel, and
every one of them also re-ran on `lineCount`/`priority`/`sort` changes via a
dependency array with `// eslint-disable-line` on it. That silent restart is now
explicit: the tail's own load effects still handle those, the poll handles the
repeat.
Four `intervalRef`s and one `logIntervalRef` went with them.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 3ac566cb. Ratchet 31 sites / 22 files -> 27 / 18.
QueueDetail and QueueOperations each fired two requests per tick. Their ticks
now return a single awaited Promise.all, because the in-flight guard only knows
a tick has finished when the promise it was handed settles — a bare `.then()`
inside the callback reports done immediately and lets the next tick start on top
of a request still in flight, which is the exact overlap the hook exists to
prevent.
ServerDetail and LinkPanelForm keep their mount-time load as its own effect;
the poll is `immediate: false` so nothing fetches twice.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing f21abe7a. Poller ratchet 41 sites / 31 files
-> 31 / 22.
Migrated: Jobs, Monitors, MonitorDetail, DeliveryLog, Deployments, Databases,
VulnerabilityTab, and the service-detail Logs/Metrics/Overview tabs.
The trap flagged in the plan bit on four of them, so it is worth restating: the
interval effect's dependency array was doing double duty as the "reload because
the query changed" trigger —
useEffect(() => {
load(); // <- reload on filter change
pollRef.current = setInterval(load, POLL_MS);
return () => clearInterval(pollRef.current);
}, [isAdmin, load]); // <- load() is a useCallback of the filters
usePolling holds the callback in a ref precisely so it does not restart on every
render, which means a naive swap silently drops that reload and nothing fails to
compile. Each of those four keeps an explicit `useEffect(() => load(), [load])`
alongside the poll, with `immediate: false` so mount does not fetch twice.
Six magic interval literals became named constants while their lines were being
touched (LOG_REFRESH_MS, METRICS_REFRESH_MS, SCAN_POLL_MS, DEPLOY_POLL_MS,
ENGINE_POLL_MS).
Not migrated, and not mechanical: useMetrics, useContainerStatus and
useDeployJobStream are socket-fallback pollers driven by imperative
startPolling()/stopPolling() calls from socket event handlers, with a 3s grace
period before falling back. Expressing that as a declarative `enabled` flag is a
real restructure of each hook, and they only poll when the socket is already
down. Left for when they are next touched.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings (one below the
954 baseline — four orphaned useRef imports removed), npm test 72 passed,
boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone F3 (the blob half) plus the C4-interim dead-file deletion the
audit called out.
The six-line "save this to disk" anchor ritual was pasted into 14 components,
and the copies had drifted in two ways that decide whether the download
actually happens:
- only 3 of 14 appended the anchor to the document. Chrome dispatches a click
on a detached anchor; Firefox has not always done so, which makes "the
download button does nothing" a browser-specific bug that never reproduces
for whoever wrote it.
- most revoked the object URL on the very next line after click(). The download
starts asynchronously, so revoking immediately can cancel it — showing up as
a flaky empty-or-missing file rather than a clean failure.
utils/downloadBlob.js does both correctly (append, click, remove, revoke on a
later task) and accepts either a string or an existing Blob, which is what
FleetCapacityPanel needs since its CSV arrives from the API already blobbed.
Also deletes src/services/wordpress.js: 494 lines, zero importers, and a stale
duplicate of the live src/services/api/wordpress.js that the ApiService barrel
actually wires in. Verified by import graph and a clean production build.
Ratchet: the boundary check now fails on any `.download =` assignment outside
the helper — a zero baseline, not a shrinking one, since every site is
migrated. Verified non-vacuous: an injected anchor fails it, revert passes.
Gate: npm run build clean, npm run lint 0 errors / 954 warnings (unchanged),
npm test 72 passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, drift/bug row 4. 45 hand-rolled setInterval sites across
35 files, with `visibilitychange` handled in exactly one of them and an
in-flight guard in exactly one of them — the documented poller-stampede shape.
Two failures every hand-rolled `setInterval(load, 5000)` shares:
- it starts a request every 5s whether or not the previous one returned. A slow
endpoint does not slow the poller down, it makes it stack; each extra request
makes the server slower, which makes the next overlap more likely. This is
what a DevTools capture of /domains showed as a wall of pending rows.
- it keeps polling a tab nobody is looking at, where browsers throttle timers
unevenly anyway, so it is not even a reliable refresh.
utils/pollScheduler.js holds the rules as a plain object so they are testable
without a DOM (the repo has no jsdom — this follows formState.js/useForm).
hooks/usePolling.js is the React binding, and useServerQuery gains
`refetchInterval` built on the same scheduler, so there is one implementation
rather than a second one for query users.
Two design points worth stating, since both differ from what the sites did:
- the next tick is armed by the previous run *finishing*, not by setInterval.
With setInterval the gap is measured tick-to-tick regardless of how long the
work took, so slow work silently turns a 5s gap into no gap. The in-flight
guard therefore protects the out-of-band paths — refresh() and the visibility
catch-up — rather than the schedule, and the test says so instead of implying
a guard the schedule can no longer exercise.
- the callback is held in a ref, so an inline arrow re-created each render does
not restart the timer. That is the usual reason a hand-rolled poller runs far
faster than its stated interval.
Migrated four as reference implementations, per the plan's "migrate a
representative flow before broader adoption": DeployPill (which had worked all
of this out by hand and loses ~20 lines), MonitorsSummary, appdetail/LogsTab,
and ProcessListPanel. MonitorsSummary keeps its `refreshKey` prop as an explicit
out-of-band refresh rather than folding it into the schedule.
Ratchet: check-frontend-boundaries.mjs gains LEGACY_POLLERS with exact per-file
counts, now 41 sites in 31 files, so every future cleanup must shrink it and a
new bare setInterval fails lint. Genuine non-fetching timers (clock ticks,
countdowns) are listed too rather than pattern-matched away — they are harmless
but the exact count is what makes the ratchet trustworthy.
Gate: npm test (72 passed, 11 of them new), npm run lint (0 errors; 954
warnings, one fewer than the 955 baseline), npm run build clean, boundary check
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone F3, drift/bug row 5. 33 call sites in 25 files reached for
navigator.clipboard.writeText directly, against 4 files using the shared
copyToClipboard/useClipboard helpers.
This is a fix rather than a tidy-up: navigator.clipboard is undefined in an
insecure context, and ServerKit's SSL is optional by policy, so on an
HTTP-served panel every one of those copy buttons did nothing. The failure was
silent at most sites — a bare fire-and-forget writeText, or a `.catch` that
swallowed. utils/clipboard.js already carries the execCommand fallback that
makes them work; it just had almost no callers.
Two of the bypasses were local functions *named* copyToClipboard
(ServerSettingsTab, Downloads) — the same shadowing shape as the backend's
private get_current_user() helpers, and the reason the call sites read as
though they were already correct. ServerSettingsTab's was a hand-rolled
useClipboard (copy + toast) and now uses the hook; Downloads' is renamed
copyCommand since it also owns per-command copied state.
Behaviour preserved per site, with two deliberate improvements the helper's
contract allows:
- state that was set unconditionally (`copied` flags, keyed copy markers) is
now set only when the copy actually succeeded
- CreateTableModal's "this browser will not give the page clipboard access"
notice is gone; the fallback is what it was warning about
`.then(onOk, onErr)` chains became `.then(ok => ...)` because copyToClipboard
resolves false instead of rejecting — the second callback would never have run.
The existing frontend boundary ratchet held an exact per-file legacy baseline;
it is now empty and documented as needing to stay that way, so a new direct
call fails `npm run lint`.
Gate: node scripts/check-frontend-boundaries.mjs (0 clipboard files ratcheted),
npm run lint (0 errors, 955 pre-existing warnings — unchanged), npm test
(61 passed), npm run build (clean).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture tool shipped with two targets and had evidently never been
run past them — every one of them failed for reasons the tool could not
report.
Three bugs, in the order they bit:
- login filled the first `input[type="text"]` on the page, which is a 2FA
digit box rendered above the real form. The credential form stayed
empty, so every target then timed out waiting for content on a page
that was still /login. Target #email/#password, and fail loudly rather
than silently proceeding unauthenticated.
- The API client clears both tokens from localStorage whenever any
request 401s, and one admin-only endpoint doing so was enough to bounce
every later target back to /login. The captured tokens are now re-seeded
before app code runs on each navigation, with a re-auth-and-retry as
backstop.
- A bare "waitForSelector timeout" hid all of the above. Failures now say
which cause applies: bounced to /login, root selector absent, or the
page rendered its empty state.
Targets grew to services and domains alongside ssl and wordpress-list.
/templates is deliberately excluded: its catalog measures ~975 bones over
5700px — a skeleton nobody scrolls, at a node count costing more to
animate than the page costs to load. A MAX_BONES cap now rejects that
class of capture outright, pointing at the PageSkeleton archetypes, and
the target list documents that a page only qualifies once the dev
database reliably has rows for it — bones measured from an empty page
would predict that nothing is coming.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add useSelfCapturedBones(key, {ready}): after a page's real content renders it
snapshots the region into bones (shared src/utils/snapshotBones.js) and caches
them in localStorage under sk-bones:<key>, versioned so stale layouts are purged;
the next load replays a pixel-accurate skeleton. First-ever visit has no cache and
falls back to the composed skeleton — no regression.
SkeletonBoundary now forwards its ref to the measured container, and the SSL page
is wired as the demonstration. The capture script (phase 2) is DRY'd to inject the
same shared snapshotBones source rather than an inline copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an accuracy path for skeletons: a dev-only `npm run capture:skeletons` tool
(scripts/capture-skeletons.mjs) drives a headless, logged-in dev session, measures
designated page regions into boneyard-format bones ({x,y,w,h,r}; x/w %, y/h px),
and bakes them to frontend/src/skeletons/*.json as static assets. Nothing browser-
or capture-related ships in the product.
SkeletonBoundary gains an optional `bones` prop that replays a captured layout via
a new `renderBones` mapper — SCSS-classed absolutely-positioned placeholders reusing
the shared .skeleton look (shimmer + reduced-motion). boneyard-js is added as a
devDependency for parity/format reference (never imported by shipped code). Ships a
README runbook and an example.json documenting the format.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useSettingFocus rides the useFocusParam rail with a new `setting` kind: a
palette result navigates to /settings/<tab>?focus=setting:<id>, and the target
card scrolls into view and flashes a token-based highlight for ~2s. Wires the
headline Security (2FA card + admin require-2FA policy, now mounted) and
Notifications (email/SMTP, channels, severity, categories, quiet hours) cards.
Adds scripts/check-settings-index.mjs (dependency-free) to the lint step so
every settings tab must carry at least one index entry — a new tab can't ship
invisible to search.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0 of the ServerKit UI redesign (see docs/REDESIGN_MAP.md):
- Remap design tokens to the new infra-console palette behind existing names; add prototype token names (surface ramp, cyan/violet, accent-bright/-bg). Dark/light/system.
- Self-host IBM Plex (woff2 vendored via scripts/fetch-fonts.mjs); no Google Fonts CDN.
- Runtime-derive the full accent ramp in ThemeContext.
- Add shared design-system primitives in components/ds (Pill, EnvTag, SegControl, MetricCard, Gauge, ServiceTile, Sparkline, AreaChart, ScoreGauge, Feed, Drawer).
- Restyle the sidebar (gradient rail, mono section headers, left accent bar) and switch the brand to the demo's gradient-tile logo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>