Buttons, inputs, selects, pickers, toggles and tabs all declared 34 while
IconButton and the PageHeader action sat at 32, so anything mixing them
in a row was 2px out of alignment.
Everything now reads CONTROL_HEIGHT (32) from constants, so the number
lives in one place instead of nine.
Two content-box fixes were needed to make 32 safe, both measured in the
browser rather than assumed:
- MUI's button line-height is 1.75 - 22.75px at our 13px base - which
with 6px padding needs 34.75px. It was already over its own box at 34
and would have clipped at 32. Now 1.2, which leaves headroom for the
taller scripts we ship (ar, th, vi, ja, zh).
- MUI pads the inner input 8.5px top and bottom, so an input's content
needed 35.7px inside a 32px shell and was being cut off by
overflow:hidden. Padding is zeroed in the theme for both the input and
the Select's div, which centres instead.
Verified in the DOM: button, select and input all render at exactly 32
with scrollHeight equal to height, so nothing is clipped.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
The sidebar regression is the real fix here. NavItem paints the selected
item with action.selected but hovers unselected items with MUI's default
action.hover. Once the sidebar moved onto `paper`, selected composited to
rgb(31,38,41) - darker than the ground - while hover came out lighter at
rgb(46,46,51), so hovering any other item made it read as more selected
than the actual selection. Both states now come from the same family
(selectedHover / rowHover), so selected always reads stronger.
Also from review:
- Dropped "Inter" from the status page SANS_STACK. Its webfont went with
the Google Fonts import, so on Windows and Linux the stack was silently
falling through to Segoe UI / Roboto on pages users publish.
- Button focus rings now use the same dark-mode green as input focus
rings. They were still on primary.main at 2.86:1, which is the exact
problem primary.light was added to solve.
- Removed 13 size={16} props made redundant by the Icon default change.
The load-bearing pins stay: size={20} on the public status page, and
the header pins that were relying on the old default.
- Documented that alpha() replaces rather than multiplies the alpha
channel in StatusBox, so the grid is coupled to `divider` being opaque.
- Noted on the isRowSelected JSDoc that the small-screen card branch does
not style selection, same limitation getRowSx already had.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Inputs disagreed on both hover and focus. The theme's MuiOutlinedInput
hovered to text.primary - near-black in light mode - while Select,
TextInput and DatePicker each overrode hover back to `divider`, which is
the resting colour, so those had no hover cue at all. Four behaviours
across four components.
Now defined once in the theme and inherited by every outlined input:
rest divider 1.55:1 light / 1.73:1 dark
hover text.disabled 3.64:1 light / 4.66:1 dark
focus primary, 2px 5.93:1 light / 6.36:1 dark
error error.main
The per-component overrides in Select, TextInput and DatePicker are gone,
so there is one place to change this next time.
Focus in dark mode takes primary.light. The brand green is only 2.86:1 on
the dark ground, which made a focused input dimmer than a hovered one.
`main` is deliberately left alone: it is the fill for contained buttons,
and lightening it would flip their label to dark text across 31 sites.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Third time this bug has surfaced, so this fixes the cause. Icon defaulted
to 20px, but 17 call sites already asked for 16 explicitly against 2 for
20, and the sidebar clamps to 16 in CSS - so 16 was the house size and
the 20 default was one nobody chose. Anything bare rendered above the cap
height of the 13px text beside it.
The default is now 16. Five sites that were relying on the old default
and sit next to labels are pinned explicitly anyway (team header, status
page controls, status page delete, theme toggle), and the public status
page's warning icon is pinned at 20 so the default change does not alter
a page users publish.
Sidebar icons are unaffected either way - their CSS already forced 16.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
The offline banner used error.main, which in dark mode is light enough
that MUI derives near-black contrastText - a pale salmon bar with dark
text on the one surface where urgency is the point. It now uses
error.dark with explicit white text: 11.12:1 in light mode, 5.04:1 in
dark, and a solid red bar in both.
Darkening error.main itself was the other option, but roughly 30 places
use it as text or a chart mark on a dark ground, where the lighter value
is correct - it would have cost all of them to fix one banner.
The incident summary icons rendered at the shared Icon default of 20px
in lucide's currentColor, so they sat larger and darker than the 13px
label beside them. Now 16px, matching the sidebar and the uptime header
buttons, and coloured from SummaryItem so the icon is a supporting mark
rather than competing with its own label. The 32px status icon on the
active-incidents card keeps its size and semantic colour; it is a focal
indicator, not a label decoration.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Selection never showed. getRowSx set a background on the <tr>, but every
<td> paints background.paper over it, and the hover rule targets cells -
so hover won and selection was invisible. Selection and hover are now
both cell-level rules in DataTable, with selection first so hover layers
on top, and rows carry an `is-selected` class from a new isRowSelected
prop. Uptime, Docker and Infrastructure tables all had the same broken
copy of this; they now share the one implementation, and any future table
gets it for free.
action.selected/selectedHover are defined explicitly and tinted with the
brand, so a selected row reads as chosen rather than dirty. Kept low
(6-11%) so the highlight does not swamp the row, especially in dark mode.
From code review:
- Dropped the Google Fonts @import at the top of index.css. Nothing has
used Inter since the switch to Geist, and it was a render-blocking
third-party request on every load - exactly what self-hosting the font
was meant to avoid on air-gapped installs.
- TextInput's autofill override painted background.default, which used
to equal paper and no longer does, so Chrome-autofilled fields showed
a grey block on white cards. Now paper.
- Sidebar background moved from sx to the bgcolor prop (convention rule
1, and it was a new lint warning), and the backdropFilter dropped -
the background is opaque, so the blur only cost a compositing layer.
- Tab no longer bolds the selected label. That is the same reflow the
ToggleButton change removed, and worse here because the indicator
animates toward a target that moves as the label widens.
- Corrected the StatBox gradient comment: contrastText is white in light
mode but near-black in dark, so the constraint is staying near `main`,
not staying dark.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
PageHeader capped its description at maxWidth: 640, which wraps every
page description well before the text runs out - the uptime one is 100
characters and needs roughly 700px at 14px.
Now 108ch, sized against the longest description in en.json (113 chars,
the notifications page). Still a cap, so the line wraps on narrow
viewports and does not run the full width of a large display, and `ch`
tracks the font size rather than assuming 14px forever.
The empty-state description keeps its 360px cap - that copy is centred
and reads better narrow.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
The sidebar set no background, so it inherited background.default. Once
that became an off-white ground rather than plain white, the nav read as
a grey slab against the content.
It now sits on background.paper, which also gives the layout a real
figure/ground: white nav, slightly darker content area behind it. The
existing right border and the mobile Backdrop still do their jobs, and
an opaque panel is the correct behaviour over that backdrop.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Two mismatches in the monitor detail header:
- The action button icons rendered at the shared Icon default of 20px,
while the sidebar clamps the same lucide icons to 16px in CSS. The
buttons were therefore 25% larger than every other icon in the app,
and taller than the cap height of the 13px label beside them. Now 16px
at all eight call sites. Left at full opacity - the sidebar dims to
0.81 because nav is recessive, but these are active controls.
- ToggleButton went to fontWeight 500 when selected while Button is a
flat 400, so the time range group sat heavier than the button row
above it. Weight now stays at 400 and the tinted fill plus text.primary
ink carry the selected state; that is a 2.9x difference in ink against
the unselected label, so selection is still obvious. It also stops the
label reflowing as you switch range.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
The coloured stat box ran its gradient from `<palette>.light` to
`<palette>.main`. With Material's stock warning that was #ff9800 ->
#ed6c02, a bright orange. The new warning.main is a dark amber chosen so
the "Paused" chip text clears 4.5:1, and MUI derives `light` from it, so
the gradient became brown mud.
The two roles were never compatible: `main` has to be dark enough for
small text on white, and the same value was being used as a large filled
background. The gradient now runs from lighten(main, 0.12) to main,
which keeps the box in the status colour, restores the depth that the
flat brown lost, and holds white contrastText at 4.64:1 or better at the
lightest point for all four palettes.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Three fixes, all fallout from giving `divider` a real value:
- The graph-paper texture behind the status counts is drawn with
`divider`. That used to be rgba(0,0,0,.12) and near-invisible; now it
is opaque, so the grid jumped forward and read as the loudest thing on
the page. It is texture, not a rule, so it now takes divider at 40%.
- GradientBox called lighten() on background.paper to build its
gradient. paper is white in light mode and lightening white is a
no-op, so the gradient collapsed flat. Darkens slightly toward the
page ground instead.
- Borders were too faint at #D9E1DD (1.33:1 against paper). Now #C7D2CC
(1.55:1), and dark mode #2C3532 -> #3A4642 (1.73:1), so tables and
cards have a visible edge without turning into hard rules.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Tabs.tsx hardcoded fontSize: 14, so the tab bar sat a pixel above the
13px base the rest of the chrome uses. Now typographyLevels.m, which
also satisfies the no-hardcoded-literals rule.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
The palette declared a brand green and six chart hues but never defined
the neutrals, so MUI's defaults filled the gap: body text at 60% black,
dividers at 12%, and background.default equal to background.paper. That
is the "washed out" everything sat on - table cells render through
text.secondary, and cards had no ground to sit on.
- Opaque neutral ramp, biased slightly green so it sits with the brand.
background.default is now off-white so paper separates from the page.
- Semantic success/error/warning derived from the brand instead of
Material's stock red/green/orange. Only `main` is set: `light` is
consumed as a saturated colour by status dots, histogram bars and
heatmap cells, so MUI must keep deriving it.
- Dark mode gets the same treatment; it defined background and secondary
but its text and divider were still MUI defaults.
- Geist Variable, self-hosted via @fontsource-variable so air-gapped
installs keep the typeface. Base size stays at 13px.
- h1 700 / h2 600 against 400 body, and table headers 500 -> 600. Two
weights was why everything read at the same visual weight.
All contrast pairs pass WCAG AA in both themes (lowest 5.98:1).
No component markup changed.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
The group labels sat 11px left of the icon column and rendered at the
same 13px as the nav items, so the groups read as indented blocks rather
than headings over a list.
The label used paddingLeft LAYOUT.LG (16px) while NavItem uses
theme.spacing(5) (10px); matching NavItem puts the label flush with the
icons. The eyebrow variant is 13px, so the size is overridden to
typographyLevels.s (11px) to sit below the item text. Top padding goes
from XS to SM for a little more separation above each group.
Measured in the browser: label and first icon both start at x=12, label
renders 11px against the items' 13px.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Browser testing showed the alert stretching the full width of the save
bar while Save was pushed against the right edge, and the two sat on
different vertical centres.
The alert carried flexGrow: 1, so it consumed all the free space in the
row, plus mb: 2 and mr: 2, which offset it against a button that had
neither. The row itself set no alignItems, so the items aligned by
stretch rather than centre.
The row now centres its items and uses gap for spacing, and the alert
drops its sx entirely: growing was what stretched it, and the margins
are the gap's job. Measured: the alert sizes to its content (297px, was
1168px), 16px from Save, centres within 2px.
Removing the sx also clears two of the four pre-existing lint warnings
on this file.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Second review pass on the tab grouping.
The save bar listed raw schema keys, and react-hook-form nests errors by
path, so a threshold error arrived as { globalThresholds: { cpu: {...} } }.
Iterating only the top level produced an entry whose own message was
undefined, rendering "globalThresholds: Invalid value" -- naming none of
the four sliders and carrying no reason. Errors are now flattened to
their leaves, so the row names the field that actually failed.
Rows also showed untranslated identifiers such as
systemEmailRejectUnauthorized. Every field already owns a translated
label, so those are reused via FIELD_LABEL_KEY; no new strings. Labels
that append an explanation after a dash are trimmed to the name.
Both defects predate this branch, but the tab work promoted these rows
into clickable links, which made unusable names more prominent.
Also guards visibleTabs[0], which is safe only because "general" is
currently ungated: gating it later, or adding a second role, would throw
on an empty list and blank the page. And a "tab" parameter that does not
resolve -- an unknown key, or an admin tab followed by a non-admin -- is
now dropped, so the URL stops claiming a tab the page is not showing.
Valid deep links are untouched.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
Code review caught that the tab panels never hid.
Each panel was a `<Stack hidden={activeTab !== key}>`. The `hidden`
attribute reaches the DOM, but the only rule acting on it is the user
agent's `[hidden] { display: none }`, and MUI's Stack sets
`display: flex` in an author-level emotion class
(@mui/system/Stack/createStack.js:70). Author origin beats user agent
regardless of specificity, so every panel stayed visible: all five tabs
rendered stacked down the page and clicking a tab changed nothing.
Panels are now conditionally rendered, matching Pages/Account. This also
fixes a second defect: BasePage wraps children in `Stack spacing`, whose
sibling selector keys on DOM position rather than visibility, so the four
empty panels a non-admin never sees still collected a margin-top each and
left four blank gaps below "About".
My commit message for the previous commit argued panels had to stay
mounted or a partial tree would compute a stale `isValid` and lock
saving. That was wrong. No Form*Field passes `shouldUnregister`, and
react-hook-form defaults it to false, so unmounting a panel preserves its
values and validation state.
Also moves the tab-reveal off `onError`, which was unreachable: the save
button is `disabled={!isValid}` under `mode: "onChange"`, so
handleSubmit's error branch never fires. The validation Alert renders
straight from `formState.errors`, so a field on a hidden tab could be
named with no way to reach it. Each field in the Alert is now a button
that switches to its own tab.
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
The settings page rendered all 13 ConfigBox sections in one column, so
finding anything meant scrolling past everything else.
The sections are now grouped under five tabs by subject:
General timezone, appearance, status page URL, about
Monitoring thresholds, check retention, demo monitors
Email email settings
Integrations global proxy, PageSpeed API key
Data import/export, monitor history, system reset
No section changed. Every ConfigBox moved verbatim with its props,
children and isAdmin guard intact; the only edit inside one is Prettier
rewrapping an arrow function that shifted indentation level.
Tabs are derived from a filtered array rather than conditional JSX, so
indices stay correct when admin-only tabs drop out. Nine of the thirteen
sections are admin-gated, which leaves a non-admin four sections in
General alone -- the bar is hidden entirely in that case rather than
showing four empty tabs.
Panels stay mounted and toggle with `hidden`. Conditionally rendering
them would unmount inputs belonging to the shared react-hook-form, and
since Save is disabled on !isValid, a partial tree can compute a stale
isValid and lock saving.
The active tab lives in the URL as ?tab=<key> so reloads and shared
links land in the right place, following the useSearchParams pattern in
StatusPage/Status. An unknown tab, or an admin tab requested by a
non-admin, falls back to the first visible tab.
Both confirmation dialogs and the sticky save bar stay outside the
panels: the dialogs would unmount mid-request otherwise, and the save
bar serves the whole form.
Because the save bar lists validation errors by field name, an error
could name a field on a hidden tab. onError now switches to the tab
owning the first error before the alert renders.
Two titles are corrected to sentence case while their sections move:
"Global Thresholds" and "Monitor IP/URL on Status Page".
Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd