diff --git a/web/src/app.html b/web/src/app.html index 6a2bb58..2333503 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -4,6 +4,28 @@ + %sveltekit.head% diff --git a/web/src/lib/density.svelte.ts b/web/src/lib/density.svelte.ts new file mode 100644 index 0000000..0cb428e --- /dev/null +++ b/web/src/lib/density.svelte.ts @@ -0,0 +1,42 @@ +// Density is per-user, persisted, and global -- not per-page. A log +// table wants compact by default and a dashboard wants comfortable, but +// that's each page choosing a sensible *initial* value to hand the +// toggle, not two separate settings; switching it on one page is +// expected to carry over. See app.html's inline script for the +// before-first-paint application of the same stored value. + +export type Density = 'comfortable' | 'compact'; + +const STORAGE_KEY = 'sentry.density'; + +function readStored(): Density { + if (typeof localStorage === 'undefined') return 'comfortable'; + return localStorage.getItem(STORAGE_KEY) === 'compact' ? 'compact' : 'comfortable'; +} + +function apply(d: Density) { + if (typeof document === 'undefined') return; + document.documentElement.classList.toggle('density-compact', d === 'compact'); +} + +const initial = readStored(); +let density = $state(initial); +apply(initial); + +export function getDensity(): Density { + return density; +} + +export function setDensity(d: Density) { + density = d; + apply(d); + try { + localStorage.setItem(STORAGE_KEY, d); + } catch { + // storage unavailable -- the choice just won't persist across reloads + } +} + +export function toggleDensity() { + setDensity(density === 'compact' ? 'comfortable' : 'compact'); +} diff --git a/web/src/lib/severity.ts b/web/src/lib/severity.ts new file mode 100644 index 0000000..99ba550 --- /dev/null +++ b/web/src/lib/severity.ts @@ -0,0 +1,22 @@ +// Maps the seven OTel severity strings ingest/internal/normalize writes +// (see /storage/README.md) onto the four visual accent tiers plus one +// "quiet" state design-system.md documents. Deliberately not a 1:1 +// color-per-severity mapping -- seven distinct colors would be seven +// things to memorize at a glance, four is workable. + +export type SeverityTier = 'quiet' | 'info' | 'warn' | 'error' | 'critical'; + +const TIER_BY_SEVERITY: Record = { + TRACE: 'quiet', + DEBUG: 'quiet', + UNSPECIFIED: 'quiet', + INFO: 'info', + WARN: 'warn', + ERROR: 'error', + FATAL: 'critical' +}; + +export function severityTier(severity: string | undefined | null): SeverityTier { + if (!severity) return 'quiet'; + return TIER_BY_SEVERITY[severity.toUpperCase()] ?? 'quiet'; +} diff --git a/web/src/lib/styles/app.css b/web/src/lib/styles/app.css new file mode 100644 index 0000000..96fde60 --- /dev/null +++ b/web/src/lib/styles/app.css @@ -0,0 +1,83 @@ +@import './fonts.css'; +@import './tokens.css'; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + color-scheme: dark; +} +html[data-theme='light'] { + color-scheme: light; +} +@media (prefers-color-scheme: light) { + html:not([data-theme='dark']) { + color-scheme: light; + } +} + +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-ui); + font-size: var(--text-base); + line-height: var(--line-height-normal); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3, +h4 { + font-weight: var(--font-weight-bold); + text-wrap: balance; + margin: 0; +} + +a { + color: var(--color-accent); +} + +code, +pre, +.mono { + font-family: var(--font-mono); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +::selection { + background: var(--color-accent); + color: var(--color-on-accent); +} + +:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-radius: var(--radius-sm); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/web/src/lib/styles/fonts.css b/web/src/lib/styles/fonts.css new file mode 100644 index 0000000..24f561a --- /dev/null +++ b/web/src/lib/styles/fonts.css @@ -0,0 +1,20 @@ +/* Self-hosted (not a Google Fonts CDN link -- no runtime dependency on a + third party for the app to render correctly). See + /web/static/fonts/LICENSE.txt. Both are variable fonts, one file + covers the whole weight range used. */ + +@font-face { + font-family: 'Overpass'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/overpass-variable.woff2') format('woff2'); +} + +@font-face { + font-family: 'Overpass Mono'; + font-style: normal; + font-weight: 300 700; + font-display: swap; + src: url('/fonts/overpass-mono-variable.woff2') format('woff2'); +} diff --git a/web/src/lib/styles/tokens.css b/web/src/lib/styles/tokens.css new file mode 100644 index 0000000..438aed9 --- /dev/null +++ b/web/src/lib/styles/tokens.css @@ -0,0 +1,206 @@ +/* Design tokens -- Signal direction (see /docs/design-system.md for the + full rationale). Dark is the true default, not a retrofit: :root + defines the dark palette directly; light is the override, both via + `@media (prefers-color-scheme: light)` (system preference) and + `[data-theme="light"]` (explicit user choice, see $lib/theme.ts). + Component styles must only ever read a token (--color-..., --space-..., + and so on), never a literal hex -- that's what keeps dark/light/density + a token swap instead of a per-component rewrite. */ + +:root { + /* ---- color: surfaces ---- */ + --color-bg: #0a0a0b; + --color-surface: #17181a; + --color-surface-raised: #1e2023; + --color-border: #2a2c2f; + --color-border-strong: #3a3d41; + + /* ---- color: text ---- */ + --color-text: #f0f0f1; + --color-text-muted: #85888d; + /* Placeholder/hint text -- lighter than the original #55585d, which + axe-core's color-contrast check correctly flagged at ~2.5:1 against + --color-surface (needs 4.5:1 for AA body text). #8a8d92 clears + ~5.2:1 against both --color-surface and --color-bg. */ + --color-text-faint: #8a8d92; + + /* ---- color: accent (interactive only -- never reused for severity, + see $lib/severity.ts) ---- */ + --color-accent: #3fb6ff; + --color-accent-strong: #6fc9ff; + --color-on-accent: #04121a; + + /* ---- color: severity tiers. Four accent tiers plus one "quiet" + state for TRACE/DEBUG/UNSPECIFIED -- see /docs/design-system.md's + severity-mapping table. Chosen as a blue -> amber -> orange -> + magenta progression (hue *and* lightness both shift) so no two + adjacent tiers rely on red/green to be told apart. + quiet/info/critical -bg alphas lowered from their original values + (24/29/33) -- axe-core caught .pill.critical (AlertStatePill, + rendered on an opaque --color-surface ancestor, unlike the plain + page background most severity chips sit on) at 4.04:1, below the + 4.5:1 AA text threshold. A translucent background tints *toward* + the foreground color, which for these saturated/low-luminance + hues *lowers* contrast as alpha increases -- so the fix is less + alpha, not more. quiet/info were still within AA on the page + background but would have failed the same way the moment either + rendered on a Card/section surface, so fixed alongside critical + rather than left as a latent recurrence of the same bug. ---- */ + --color-sev-quiet: #85888d; + --color-sev-quiet-bg: #85888d12; + --color-sev-info: #4c8dff; + --color-sev-info-bg: #4c8dff20; + --color-sev-warn: #f5c242; + --color-sev-warn-bg: #f5c2422e; + --color-sev-error: #ff6a39; + --color-sev-error-bg: #ff6a392e; + --color-sev-critical: #ff2d78; + --color-sev-critical-bg: #ff2d7814; + + /* ---- color: semantic feedback (distinct from severity -- form + validation / toast state, not log data) ---- */ + --color-success: #35c98f; + --color-danger: #ff5c5c; + + /* ---- type ---- */ + --font-ui: + 'Overpass', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: + 'Overpass Mono', ui-monospace, 'SF Mono', Consolas, monospace; + + /* modular scale, 1.2 ratio, 14px base -- dense-first, per the brief: + dashboards read --text-md/lg, log tables and data read --text-sm. */ + --text-xs: 0.6875rem; /* 11px */ + --text-sm: 0.8125rem; /* 13px */ + --text-base: 0.875rem; /* 14px */ + --text-md: 1rem; /* 16px */ + --text-lg: 1.25rem; /* 20px */ + --text-xl: 1.75rem; /* 28px */ + --text-2xl: 2.5rem; /* 40px */ + + --font-weight-normal: 400; + --font-weight-medium: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.25; + --line-height-normal: 1.5; + + /* ---- spacing (8px base) ---- */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.5rem; /* 24px */ + --space-6: 2rem; /* 32px */ + --space-7: 3rem; /* 48px */ + --space-8: 4rem; /* 64px */ + + /* ---- radius ---- */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-full: 999px; + + /* ---- shadow (used sparingly -- Signal's restraint extends to + elevation; only true overlays get one) ---- */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.55); + + --focus-ring: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-accent); + + /* ---- density (see $lib/density.ts). Comfortable is the default; + .density-compact on swaps these -- log tables/results + opt into compact explicitly, dashboards/forms stay comfortable. ---- */ + --row-height: 2.75rem; + --row-padding-y: var(--space-3); + --row-padding-x: var(--space-4); + --panel-padding: var(--space-4); + --control-height: 2.25rem; +} + +html.density-compact { + --row-height: 1.75rem; + --row-padding-y: var(--space-1); + --row-padding-x: var(--space-3); + --panel-padding: var(--space-2); + --control-height: 1.75rem; + --text-base: var(--text-sm); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme='dark']) { + --color-bg: #f7f7f8; + --color-surface: #ffffff; + --color-surface-raised: #ffffff; + --color-border: #dfe0e2; + --color-border-strong: #c7c9cc; + + --color-text: #101113; + --color-text-muted: #6b6e73; + --color-text-faint: #75787d; /* ~4.6:1 against light-mode surfaces, was 2.72:1 */ + + --color-accent: #0b84d6; + --color-accent-strong: #0a6fb3; + --color-on-accent: #ffffff; + + /* quiet-bg alpha lowered (1a -> 12), warn's base color darkened + (was #9c7300, only 4.32:1 against white -- below AA even as + plain text, before any background tint) and warn/error -bg + alphas lowered -- same axe-confirmed translucent-background + pattern documented on the dark-mode block above, checked here + against a solid white ancestor. critical/info were already + >=4.5:1 against white and are unchanged. */ + --color-sev-quiet: #6b6e73; + --color-sev-quiet-bg: #6b6e7312; + --color-sev-info: #1a63d6; + --color-sev-info-bg: #1a63d61c; + --color-sev-warn: #8c6800; + --color-sev-warn-bg: #8c680010; + --color-sev-error: #c94b1e; + --color-sev-error-bg: #c94b1e05; + --color-sev-critical: #c21362; + --color-sev-critical-bg: #c213621c; + + --color-success: #12805f; + --color-danger: #c22d2d; + + --shadow-sm: 0 1px 2px rgba(16, 17, 19, 0.08); + --shadow-md: 0 4px 16px rgba(16, 17, 19, 0.1); + --shadow-lg: 0 12px 40px rgba(16, 17, 19, 0.16); + } +} + +:root[data-theme='light'] { + --color-bg: #f7f7f8; + --color-surface: #ffffff; + --color-surface-raised: #ffffff; + --color-border: #dfe0e2; + --color-border-strong: #c7c9cc; + + --color-text: #101113; + --color-text-muted: #6b6e73; + --color-text-faint: #75787d; /* ~4.6:1 against light-mode surfaces, was 2.72:1 */ + + --color-accent: #0b84d6; + --color-accent-strong: #0a6fb3; + --color-on-accent: #ffffff; + + --color-sev-quiet: #6b6e73; + --color-sev-quiet-bg: #6b6e731a; + --color-sev-info: #1a63d6; + --color-sev-info-bg: #1a63d61c; + --color-sev-warn: #9c7300; + --color-sev-warn-bg: #9c73001c; + --color-sev-error: #c94b1e; + --color-sev-error-bg: #c94b1e1c; + --color-sev-critical: #c21362; + --color-sev-critical-bg: #c213621c; + + --color-success: #12805f; + --color-danger: #c22d2d; + + --shadow-sm: 0 1px 2px rgba(16, 17, 19, 0.08); + --shadow-md: 0 4px 16px rgba(16, 17, 19, 0.1); + --shadow-lg: 0 12px 40px rgba(16, 17, 19, 0.16); +} diff --git a/web/src/lib/theme.svelte.ts b/web/src/lib/theme.svelte.ts new file mode 100644 index 0000000..0282982 --- /dev/null +++ b/web/src/lib/theme.svelte.ts @@ -0,0 +1,49 @@ +// Theme is dark/light/"system" -- but unlike most apps, an unset +// preference means dark, not system. That's the whole point of "real +// dark mode as the default, not an afterthought": a first-time visitor +// on a light-OS machine still lands in dark, matching the actual usage +// pattern this product is designed for (long sessions, often at night). +// "system" is available for anyone who wants their OS setting to win +// instead, but it's a deliberate opt-in, not the fallback. +// +// The synchronous inline script in app.html applies the same stored +// value before first paint -- this module is what UI controls read/write +// after hydration; the two must stay in sync on the storage key and +// values, not just in spirit. + +export type Theme = 'dark' | 'light' | 'system'; + +const STORAGE_KEY = 'sentry.theme'; + +function readStored(): Theme { + if (typeof localStorage === 'undefined') return 'dark'; + const v = localStorage.getItem(STORAGE_KEY); + return v === 'light' || v === 'system' ? v : 'dark'; +} + +function apply(t: Theme) { + if (typeof document === 'undefined') return; + if (t === 'system') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', t); + } +} + +const initial = readStored(); +let theme = $state(initial); +apply(initial); + +export function getTheme(): Theme { + return theme; +} + +export function setTheme(t: Theme) { + theme = t; + apply(t); + try { + localStorage.setItem(STORAGE_KEY, t); + } catch { + // storage unavailable -- the choice just won't persist across reloads + } +} diff --git a/web/static/fonts/LICENSE.txt b/web/static/fonts/LICENSE.txt new file mode 100644 index 0000000..c9e56b9 --- /dev/null +++ b/web/static/fonts/LICENSE.txt @@ -0,0 +1,7 @@ +Overpass and Overpass Mono are licensed under the SIL Open Font License, +Version 1.1: https://openfontlicense.org/ + +Fetched from Google Fonts (fonts.google.com/specimen/Overpass, +fonts.google.com/specimen/Overpass+Mono) and self-hosted here rather than +loaded from a CDN, so the app has no runtime dependency on Google's font +service. diff --git a/web/static/fonts/overpass-mono-variable.woff2 b/web/static/fonts/overpass-mono-variable.woff2 new file mode 100644 index 0000000..da84631 Binary files /dev/null and b/web/static/fonts/overpass-mono-variable.woff2 differ diff --git a/web/static/fonts/overpass-variable.woff2 b/web/static/fonts/overpass-variable.woff2 new file mode 100644 index 0000000..d1b0a73 Binary files /dev/null and b/web/static/fonts/overpass-variable.woff2 differ