The preview previously rendered the themed status page inline in the
admin document, so document-global concerns leaked in both directions:
custom CSS spilled into the admin UI and the theme body painting never
showed. Pointing the BrowserFrame at the real public URL in an iframe
isolates the CSS completely and makes the preview pixel-identical to
what visitors see. Auth for unpublished pages keeps working because the
same-origin iframe shares localStorage and the API client reads the
token from the rehydrated store.
The customCSS field existed on the model, types, and form schema but was
never editable, sent to the API, or rendered. Wire it end to end:
- Accept customCSS in the create/update status page body validation and
regenerate the OpenAPI spec
- Add a Custom CSS editor (multiline field) to the status page form and
include the value in the submitted form data
- Support multiline mode in the shared TextInput component
- Inject the saved CSS via a style tag when rendering the status page
Anonymous visitors to a public status page have no persisted Redux
state, so ThemedHeatmap and ThemedHistogram tooltips always rendered
timestamps in the hardcoded "America/Toronto" default — regardless of
the timezone the operator configured. Even on the same domain,
ThemedHeatmap and ThemedHistogram had no code path to consume the
operator-configured statusPage.timezone returned by the API.
- Add SYSTEM_TIMEZONE module constant and a small resolveTimezone()
helper to TimeUtils.ts. formatDateWithTz's timezone parameter is now
optional and defaults to the system tz, so callers don't need to
rebuild the prop -> browser -> UTC chain inline.
- Add a `timezone` field to StatusPageThemeContextValue so any themed
component on the public page can read the page-wide tz without prop
drilling (parallels how `theme` and `mode` are already exposed).
- Wire statusPage.timezone into StatusPageThemeProvider from
Pages/StatusPage/Status/index.tsx (both the public route and the
admin preview).
- ThemedHeatmap and ThemedHistogram now read tz from the context
instead of useSelector(state.ui.timezone) — that selector was
unreliable for anonymous visitors and removing it cuts a Redux
subscription on the public page.
- Replace the inline `data?.timezone || Intl.DateTimeFormat()...`
expression in useStatusPageForm.ts with resolveTimezone().
Closes#3628.
Four small follow-ups from a code review pass:
1. Drop the redundant `?? THEMED_RENDERERS[DEFAULT_STATUS_PAGE_THEME]`
fallback in Status/index.tsx — the map is now a total Record, so the
first lookup cannot return undefined.
2. Extract the literal "/status/public" into PUBLIC_STATUS_PAGE_PREFIX in
Types/StatusPage.ts and reuse from the three runtime call sites
(Status/index.tsx, HeaderStatusPageControls.tsx, StatusPagesTable.tsx).
The Routes/index.tsx route definition keeps its string literal — the
route table is otherwise all literals and the prefix only matters when
it's interpolated.
3. Delete two narrating comments above the public/admin branches in
Status/index.tsx — the JSX is self-describing.
4. Move the inline `style` object construction in StatusPageThemeProvider
into the existing useMemo so the wrapper div receives a stable style
reference. The previous spread defeated the surrounding memoization on
every render.
The 4 themed renderers (refined, modern, bold, editorial) cover every
StatusPageTheme value and the model defaults to "refined", so the legacy
fallback path in Status/index.tsx was unreachable in practice.
Removing it lets us delete:
- theme-overrides.css (the only remaining CSS file in StatusPage)
- StatusBar / MonitorsList / InfrastructureMetrics (only the legacy
fallback consumed them)
- The .sp-* className hooks that wired CSS rules into the components
Pure sx via the per-theme styles.ts recipes is now the single source of
truth for every status page surface. Net diff is -801 / +42.
Status/index.tsx now resolves a renderer from the theme map (falling back
to the default theme) and keeps the public-route and admin-preview
branches it already had.
Second /simplify pass picked up:
- pillHardware in refined and modern hardcoded blue hex codes (#60a5fa, #1d4ed8, #2563eb) while bold and editorial already used the green token system. Switch refined and modern to tokens.up / tokens.upSoft so all four themes are consistent and the public status page no longer leaks blue
- pill and pillHardware in refined and modern duplicated the shared 6 typography fields (fontSize, textTransform, letterSpacing, padding, borderRadius, fontWeight). Extract a pillBase constant per theme and spread, leaving only the colour deltas on each
- The 4 themes each defined their own font-stack constants. Consolidate them in themes/shared/fontStacks.ts (SANS_STACK, BOLD_SANS_STACK, SERIF_STACK, EDITORIAL_SECONDARY_SANS_STACK, MONO_STACK) and import. Drops 6 duplicated literal stacks
- modern dark-mode card hover used the light-mode shadow as a fallback. Add darkShadowHover and pick it up via cardHoverShadow when isDark
- refined and editorial styles factories silence their unused isDark parameter with `void isDark` instead of an underscore prefix; bold and modern still use it actively
Apply review feedback:
- Editorial chartSwitch: drop the per-button isFirst flag and put the divider on the parent via "& > button + button { borderLeft }"; chartSwitchButton(active) now matches bold/modern's signature
- Drop the empty company: {} entries in bold/modern and the wrapper span around the company name; the parent brand row already styles it
- Editorial styles factory: keep the (tokens, isDark) signature for cross-theme uniformity but acknowledge the unused isDark with a void-statement instead of an underscore prefix
Finishes the migration started for refined. Each theme now consumes the same pattern:
- A typed *Styles interface that names every entry the page consumes (no `as Function` casts at the call site)
- A *Styles factory that takes (tokens, isDark) and returns sx recipes derived from existing tokens.ts plus the shared toneColor/toneSoft helpers in shared/overallStatus
- A useMemo around the factory call so MUI's sx processor sees referential equality across renders that don't change tokens
- modern.css's keyframes (pulse + staggered fade-in + reduced-motion guard) ported via @mui/system keyframes inside the modern styles factory
- bold.css's hero glow + conic logo + pseudo-elements ported as inline sx via "&::before" / "& > *" selectors
- editorial.css's serif typography + hairline dividers + flat rectangular badges ported as plain sx with the serif/sans family stacks named at module scope
Drop the four .css files (~1789 lines) along with the className= attributes and the import "./*.css" lines in each StatusPage component.
The shared themed components (ThemedHeatmap, ThemedHistogram, ThemedInfrastructure) lose their legacy classPrefix branch entirely now that no theme uses it. The dual-API gate is gone; there is one path.
Verified: tsc --noEmit clean, prettier clean.
Note: theme-overrides.css remains for the legacy non-themed status page components (StatusBar, MonitorsList, HeaderStatusPageControls). That's separate scope from the four-theme work the reviewer flagged; can be migrated in a follow-up.
Apply review feedback:
- Type the styles factory's return as a named RefinedStyles interface so the consumer no longer needs `as Function` / `as any` casts on factory entries
- Lift toneColor / toneSoft to shared/overallStatus alongside the existing OverallTone type — the next three themes will need the same mapping
- Reuse heatLevel() in the legacy classPrefix branch of ThemedInfrastructure so both API paths share one threshold table
- Memoize the styles map in RefinedStatusPage so MUI's sx processor sees referential equality across renders that don't actually change tokens
- Make the dual-API gate in ThemedHeatmap / ThemedHistogram require ALL relevant sx props together so a partial migration can't silently fall back to the legacy class path
The reviewer's feedback on this PR is that the themes ship hardcoded class names against per-theme CSS files instead of following the project's MUI sx + theme.spacing()/palette pattern. This commit handles the refined theme as a proof of concept; the other three follow.
What changed
- New src/Pages/StatusPage/Status/themes/refined/styles.ts: a single function (tokens, isDark) -> sx recipes that mirror every rule in the deleted refined.css. All values are derived from the tokens already declared in tokens.ts so the colour/spacing source of truth doesn't change.
- RefinedStatusPage.tsx: drops the className= attributes and the import "./refined.css" in favour of sx={styles.x} drawn from the recipe map. Hardcoded class strings gone.
- refined.css: deleted (was 420 lines).
- Shared themed components (ThemedHeatmap, ThemedHistogram, ThemedInfrastructure): grew an opt-in sx-based API alongside the existing classPrefix one. The new API is what refined uses; bold / modern / editorial keep working unchanged on the legacy classPrefix path until they are migrated, after which the legacy code path is removed.
Verified
- tsc --noEmit clean
- prettier clean
- Pre-existing vite build error on this branch (flag-icons resolve, unrelated) is untouched
Next
- Convert the bold, modern and editorial themes the same way and remove the legacy classPrefix branch from the shared components.
- Replace duplicated page/rowsPerPage state in Team, Notifications and Status Pages tables with a shared useClientPagination hook
- Drop unused placeholderColor prop from Select; placeholder always uses secondary text
- Restore LAYOUT.MD constant for the account menu item gap
- New EmptyState + EmptyStateIllustration components: stacked-cards SVG
with grid background, theme-aware light/dark palette.
- Variants: default (centered with title + description), compact
(horizontal, in-section), fullscreen (vertically centers in viewport).
- Wired into Checks, Incidents, Logs (Logs+Queue tabs), Notifications,
Maintenance, Status pages, and Team tab — each with i18n title +
description and a fullscreen layout when truly empty.
- Table.EmptyView now wraps the EmptyState in a divider-bordered card
so short messages no longer look orphaned.
- Incidents Option-A short-circuit: when summary.total === 0, replace
the whole page chrome with EmptyState; otherwise keep filters + cards.
- Fallback.tsx (BaseFallback / EmptyFallback / EmptyMonitorFallback /
ErrorFallback) refactored to delegate to EmptyState.
Adds four fully-designed visual themes for the public status page
(refined | modern | bold | editorial) with a light/dark mode selector,
admin preview in a mock browser frame, and a shared theme provider
that keeps the component tree unified while letting each theme own
its visual identity.
## Selectable themes
- Refined — clean, flat cards, soft shadows, colored status dot.
- Modern — flat surfaces with a pulsing status dot and staggered
fade-in cards.
- Bold — conic-gradient logo, 34px hero title with a check disc,
subtle dual-radial glow on dark mode, accent-green hover border.
- Editorial — serif headings, narrow newspaper column, 'Service
Report' eyebrow + title, flat rectangular badges and dateline.
## Architecture
- Single `StatusPageThemeProvider` hosts CSS custom-property tokens
per theme and resolves light/dark based on admin-forced mode,
falling back to `prefers-color-scheme` with a matchMedia listener.
- Each theme owns only its `*StatusPage.tsx` + scoped CSS file.
The heatmap, histogram, and infrastructure renderers are shared
across all four themes (parameterized by a `classPrefix`) to avoid
the 12-file duplication the initial draft had.
- Renderer lookup table in `Status/index.tsx` replaces a 4-way
ternary chain; unknown themes fall through to the legacy renderer.
- `resolveOverallStatus`, `statusBadgeKey`, `monoFirstChar`, and
`monitorBadgeTone` are extracted to `themes/shared/overallStatus.tsx`.
## Admin UX
- `ThemePicker` uses the project's `RadioWithDescription` and
`Select` components — one radio card per theme plus an Auto/
Light-only/Dark-only mode dropdown.
- `BrowserFrame` wraps the preview in the admin panel with a mock
chrome (traffic lights + URL bar) that adopts the status page's
theme tokens. Edge-to-edge fill with no gray bleed.
- Public route renders the theme directly on the viewport with the
document body painted to the theme background, reverted on unmount.
## Performance & polish
- Histogram's derived stats (padded array, max, avg, peak) are
memoized on `checks`; `max` is reused for `peak` instead of
computing twice.
- Editorial's dateline is wrapped in `useMemo` so the 10s poll
doesn't rerender "April 23, 2026" as a changing string.
- Provider's `setSystemMode` reconciliation uses the updater form
to avoid an extra render when the value is unchanged.
- `monoFirstChar` no longer uses a non-null assertion.
## Form plumbing
- `useStatusPageForm` defaults include `theme` + `themeMode`.
- `Validation/statusPage.ts` accepts both as optional zod enums.
- `Pages/StatusPage/Create/index.tsx` appends both to FormData on
create and update.
## CLAUDE.md
- Dev port note: local client runs on 10001 (5173 is used by a
different local project); server `CLIENT_HOST` is set to match.