4c5237ca4a
## What this does Rounds out the theme work behind the existing `hasThemeSwitcher` flag. **Two new themes.** Black and White sit alongside Dark and Light. They inherit their neighbour's whole token set and only pin their surfaces flat, so sections are separated by grid lines rather than layered fills. **`System` is now configurable at both ends.** You choose which theme the OS light setting lands on (Light or White) and which the dark setting lands on (Dark or Black). **Two accessibility toggles.** - *Stronger colors* — swaps tinted status chips for solid fills, drops decorative icon accents to monochrome, and darkens chart series that didn't clear 3:1 on a white plot. - *Underline links* — underlines body-text links, so an underline always means the preference is on rather than being a hover style. **Contrast slider.** Stores a 0–100 position within the active theme's own range rather than a shared scale, so 35% stays 35% when you switch themes. Each theme maps it in CSS, which keeps `system` working before hydration. **Appearance in the account popover.** A submenu listing the themes with a check against the current one, plus a link through to the full set on your profile. Picking one applies immediately rather than waiting for the write to round-trip. **Profile page.** Each row now saves on its own — no submit button. Name and email show their value inline with an edit button; the email row is read-only when an identity provider owns the address. **A `/storybook/colors` audit page.** Renders every colour-carrying pattern in the app once per theme plus once under Stronger colors, and measures contrast ratios off the live DOM rather than a hard-coded table, so it can't go stale. --- ## Demo https://github.com/user-attachments/assets/d56cd4d8-719f-4ec5-a990-e04cdb98def1 --- ## Compatibility The stored preference shape is unchanged (`version: "1"`), and the four new fields are all optional. The retired `classic` theme falls back to Dark, whose palette at contrast 0 is what Classic shipped. One deliberate change worth knowing: the default contrast moves from 50 to 0, so existing users who never touched the slider will see slightly less contrast than before. That's what makes 0 mean "the base palette". --- ## Testing Switched between every theme from both the account popover and the profile page, in the expanded and collapsed rail, checking `data-theme` follows and survives a reload. Dragged the contrast slider in each theme and confirmed the percentage label tracks the handle and resnaps if a save fails. Checked both accessibility toggles across the `/storybook/colors` page, which is also where the contrast ratios were read from. Confirmed the Appearance entry stays hidden for a non-admin while the flag is off. <!-- conductor-workspace-link --> --- [Open workspace in Conductor](https://app.conductor.build/workspace/fee50611-7623-4422-bada-ed1cba317ed1) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
import { createElement, type ReactNode } from "react";
|
|
import { z } from "zod";
|
|
|
|
export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
|
|
export type LogLevel = z.infer<typeof LogLevelSchema>;
|
|
|
|
// Default styles for search highlighting
|
|
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
|
|
backgroundColor: "#facc15", // yellow-400
|
|
color: "#000000",
|
|
fontWeight: "500",
|
|
borderRadius: "0.25rem",
|
|
padding: "0 0.125rem",
|
|
} as const;
|
|
|
|
/**
|
|
* Highlights all occurrences of a search term in text with consistent styling.
|
|
* Case-insensitive search with regex special character escaping.
|
|
*
|
|
* @param text - The text to search within
|
|
* @param searchTerm - The term to highlight (optional)
|
|
* @param style - Optional custom inline styles for highlights
|
|
* @returns React nodes with highlighted matches, or the original text if no matches
|
|
*/
|
|
export function highlightSearchText(
|
|
text: string,
|
|
searchTerm?: string,
|
|
style: React.CSSProperties = DEFAULT_HIGHLIGHT_STYLES
|
|
): ReactNode {
|
|
if (!searchTerm || searchTerm.trim() === "") {
|
|
return text;
|
|
}
|
|
|
|
// Defense in depth: limit search term length to prevent ReDoS and performance issues
|
|
const MAX_SEARCH_LENGTH = 500;
|
|
if (searchTerm.length > MAX_SEARCH_LENGTH) {
|
|
return text;
|
|
}
|
|
|
|
// Escape special regex characters in search term
|
|
const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const regex = new RegExp(escapedSearch, "gi");
|
|
|
|
const parts: ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
let match;
|
|
let matchCount = 0;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
// Add text before match
|
|
if (match.index > lastIndex) {
|
|
parts.push(text.substring(lastIndex, match.index));
|
|
}
|
|
// Add highlighted match
|
|
parts.push(createElement("span", { key: `match-${matchCount}`, style }, match[0]));
|
|
lastIndex = regex.lastIndex;
|
|
matchCount++;
|
|
}
|
|
|
|
// Add remaining text
|
|
if (lastIndex < text.length) {
|
|
parts.push(text.substring(lastIndex));
|
|
}
|
|
|
|
return parts.length > 0 ? parts : text;
|
|
}
|
|
|
|
// Convert ClickHouse kind to display level
|
|
export function kindToLevel(kind: string, status: string): LogLevel {
|
|
// ERROR can come from either kind or status
|
|
if (kind === "LOG_ERROR" || status === "ERROR") {
|
|
return "ERROR";
|
|
}
|
|
|
|
switch (kind) {
|
|
case "DEBUG_EVENT":
|
|
case "LOG_DEBUG":
|
|
return "DEBUG";
|
|
case "LOG_INFO":
|
|
return "INFO";
|
|
case "LOG_WARN":
|
|
return "WARN";
|
|
case "LOG_LOG":
|
|
return "INFO"; // Changed from "LOG"
|
|
case "SPAN":
|
|
return "TRACE";
|
|
case "ANCESTOR_OVERRIDE":
|
|
case "SPAN_EVENT":
|
|
default:
|
|
return "INFO";
|
|
}
|
|
}
|
|
|
|
/* Each chip is a translucent wash of its own accent, from tokens rather than the
|
|
raw palette so the "Stronger colors" preference can reach them. */
|
|
export function getLevelColor(level: LogLevel): string {
|
|
switch (level) {
|
|
case "ERROR":
|
|
return "text-error bg-error/10 border-error/20 system:border-transparent system:bg-error system:text-white";
|
|
case "WARN":
|
|
return "text-warning bg-warning/10 border-warning/20 system:border-transparent system:bg-warning system:text-white";
|
|
case "TRACE":
|
|
return "log-level-chip-trace text-log-trace bg-log-trace/10 border-log-trace/20 system:border-transparent system:bg-log-trace system:text-white";
|
|
case "DEBUG":
|
|
return "text-text-dimmed bg-black/5 border-black/10 dark:bg-white/5 dark:border-white/10 system:border-transparent system:bg-charcoal-500 system:text-white";
|
|
case "INFO":
|
|
return "text-pending bg-pending/10 border-pending/20 system:border-transparent system:bg-pending system:text-white";
|
|
default:
|
|
return "text-text-dimmed bg-black/5 border-black/10 dark:bg-white/5 dark:border-white/10 system:border-transparent system:bg-charcoal-500 system:text-white";
|
|
}
|
|
}
|