mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
* feat: Add reusable theme system with Plan 9 proof of concept Implement a comprehensive theme system that: - Defines typed Theme interface with colors, syntax highlighting, scrollbar, and gradient variables - Creates ThemeProvider with React context for runtime theme switching - Persists theme selection to localStorage - Includes 3 built-in themes: dark (default), light, and plan9 Theme structure supports: - Core UI colors (background, foreground, primary, secondary, accent, etc.) - Status colors (success, warning, info) replacing hardcoded Tailwind colors - Syntax highlighting variables for code blocks - Diff highlighting colors (inserted, deleted, meta) - Scrollbar styling variables - Gradient colors for branding Technical changes: - Update CSS to use new theme variables throughout - Update prism-theme.css to use syntax variables instead of hardcoded values - Remove chart colors (unused) - Add success/warning/info to tailwind.config.js - Wire up ThemeProvider in main.tsx For Nostr publishing (future): - d tag: "grimoire-theme" - name tag: theme display name * feat: Add theme selector to user menu, remove configurable border radius - Remove border radius from theme configuration (borders are always square) - Add theme selector dropdown to user menu (available to all users) - Theme selector shows active theme indicator - Theme selection persists via localStorage * fix: Improve theme contrast and persistence - Fix theme persistence: properly check localStorage before using default - Plan9: make blue subtler (reduce saturation), darken gradient colors for better contrast on pale yellow background - Light theme: improve contrast with darker muted foreground and borders - Change theme selector from flat list to dropdown submenu * fix: Replace Plan9 yellow accent with purple, add zap/live theme colors - Replace Plan9's bright yellow accent with purple (good contrast on pale yellow) - Add zap and live colors to theme system (used by ZapReceiptRenderer, StatusBadge) - Make light theme gradient orange darker for better contrast - Update ZapReceiptRenderer to use theme zap color instead of hardcoded yellow-500 - Update StatusBadge to use theme live color instead of hardcoded red-600 - Add CSS variables and Tailwind utilities for zap/live colors * fix: Make gradient orange darker, theme status colors - Make gradient orange darker in light and plan9 themes for better contrast - Make req viewer status colors themeable: - loading/connecting → text-warning - live/receiving → text-success - error/failed → text-destructive - eose → text-info - Update relay status icons to use theme colors - Update tests to expect theme color classes * fix: Use themeable zap color for active user names - Replace hardcoded text-orange-400 with text-zap in UserName component - Replace hardcoded text-orange-400 with text-zap in SpellRenderer ($me placeholder) - Now uses dark amber/gold with proper contrast on light/plan9 themes * feat: Add highlight theme color for active user display Add dedicated 'highlight' color to theme system for displaying the logged-in user's name, replacing the use of 'zap' color which felt semantically incorrect. The highlight color is optimized for contrast on each theme's background. - Add highlight to ThemeColors interface and apply.ts - Add --highlight CSS variable to index.css (light and dark) - Add highlight to tailwind.config.js - Configure appropriate highlight values for dark, light, and plan9 themes - Update UserName.tsx to use text-highlight for active account - Update SpellRenderer.tsx MePlaceholder to use text-highlight * fix: Restore original orange-400 highlight color for dark theme Update dark theme highlight to match original text-orange-400 color (27 96% 61%) for backward compatibility with existing appearance. --------- Co-authored-by: Claude <noreply@anthropic.com>
269 lines
7.9 KiB
TypeScript
269 lines
7.9 KiB
TypeScript
import type {
|
|
ReqRelayState,
|
|
ReqOverallState,
|
|
ReqOverallStatus,
|
|
} from "@/types/req-state";
|
|
|
|
/**
|
|
* Derive overall query status from individual relay states
|
|
*
|
|
* This function implements the core state machine logic that determines
|
|
* the overall status of a REQ subscription based on the states of individual
|
|
* relays. It handles edge cases like all-relays-disconnected, partial failures,
|
|
* and distinguishes between CLOSED and OFFLINE states.
|
|
*
|
|
* @param relayStates - Map of relay URLs to their current states
|
|
* @param overallEoseReceived - Whether the group subscription emitted EOSE
|
|
* @param isStreaming - Whether this is a streaming subscription (stream=true)
|
|
* @param queryStartedAt - Timestamp when the query started
|
|
* @returns Aggregated state for the entire query
|
|
*/
|
|
export function deriveOverallState(
|
|
relayStates: Map<string, ReqRelayState>,
|
|
overallEoseReceived: boolean,
|
|
isStreaming: boolean,
|
|
queryStartedAt: number,
|
|
): ReqOverallState {
|
|
const states = Array.from(relayStates.values());
|
|
|
|
// Count relay states
|
|
const totalRelays = states.length;
|
|
const connectedCount = states.filter(
|
|
(s) => s.connectionState === "connected",
|
|
).length;
|
|
const receivingCount = states.filter(
|
|
(s) => s.subscriptionState === "receiving",
|
|
).length;
|
|
const eoseCount = states.filter((s) => s.subscriptionState === "eose").length;
|
|
const errorCount = states.filter((s) => s.connectionState === "error").length;
|
|
const disconnectedCount = states.filter(
|
|
(s) => s.connectionState === "disconnected",
|
|
).length;
|
|
|
|
// Calculate flags
|
|
const hasReceivedEvents = states.some((s) => s.eventCount > 0);
|
|
const hasActiveRelays = connectedCount > 0;
|
|
const allRelaysFailed = totalRelays > 0 && errorCount === totalRelays;
|
|
const allDisconnected =
|
|
totalRelays > 0 && disconnectedCount + errorCount === totalRelays;
|
|
|
|
// Timing
|
|
const firstEventAt = states
|
|
.map((s) => s.firstEventAt)
|
|
.filter((t): t is number => t !== undefined)
|
|
.sort((a, b) => a - b)[0];
|
|
|
|
const allEoseAt = overallEoseReceived ? Date.now() : undefined;
|
|
|
|
// Check if all relays are in terminal states (won't make further progress)
|
|
const allRelaysTerminal = states.every(
|
|
(s) =>
|
|
s.subscriptionState === "eose" ||
|
|
s.connectionState === "error" ||
|
|
s.connectionState === "disconnected",
|
|
);
|
|
|
|
// Derive status based on relay states and flags
|
|
const status: ReqOverallStatus = (() => {
|
|
// No relays selected yet (NIP-65 discovery in progress)
|
|
if (totalRelays === 0) {
|
|
return "discovering";
|
|
}
|
|
|
|
// All relays failed to connect, no events received
|
|
if (allRelaysFailed && !hasReceivedEvents) {
|
|
return "failed";
|
|
}
|
|
|
|
// All relays are in terminal states (done trying)
|
|
// This handles the case where relays disconnect before EOSE
|
|
if (allRelaysTerminal && !overallEoseReceived) {
|
|
if (!hasReceivedEvents) {
|
|
// All relays gave up before sending events
|
|
return "failed";
|
|
}
|
|
if (!hasActiveRelays) {
|
|
// Received events but all relays disconnected before EOSE
|
|
if (isStreaming) {
|
|
return "offline"; // Was trying to stream, now offline
|
|
} else {
|
|
return "closed"; // Non-streaming query, relays closed
|
|
}
|
|
}
|
|
// Some relays still active but all others terminated
|
|
// This is a partial success scenario
|
|
return "partial";
|
|
}
|
|
|
|
// No relays connected and no events received yet
|
|
if (!hasActiveRelays && !hasReceivedEvents) {
|
|
return "connecting";
|
|
}
|
|
|
|
// Had events and EOSE, but all relays disconnected now
|
|
if (allDisconnected && hasReceivedEvents && overallEoseReceived) {
|
|
if (isStreaming) {
|
|
return "offline"; // Was live, now offline
|
|
} else {
|
|
return "closed"; // Completed and closed (expected)
|
|
}
|
|
}
|
|
|
|
// EOSE not received yet, still loading initial data
|
|
if (!overallEoseReceived) {
|
|
return "loading";
|
|
}
|
|
|
|
// EOSE received, but some relays have issues (check this before "live")
|
|
if (overallEoseReceived && (errorCount > 0 || disconnectedCount > 0)) {
|
|
if (hasActiveRelays) {
|
|
return "partial"; // Some working, some not
|
|
} else {
|
|
return "offline"; // All disconnected after EOSE
|
|
}
|
|
}
|
|
|
|
// EOSE received, streaming mode, all relays healthy and connected
|
|
if (overallEoseReceived && isStreaming && hasActiveRelays) {
|
|
return "live";
|
|
}
|
|
|
|
// EOSE received, not streaming, all done
|
|
if (overallEoseReceived && !isStreaming) {
|
|
return "closed";
|
|
}
|
|
|
|
// Default fallback (should rarely hit this)
|
|
return "loading";
|
|
})();
|
|
|
|
return {
|
|
status,
|
|
totalRelays,
|
|
connectedCount,
|
|
receivingCount,
|
|
eoseCount,
|
|
errorCount,
|
|
disconnectedCount,
|
|
hasReceivedEvents,
|
|
hasActiveRelays,
|
|
allRelaysFailed,
|
|
queryStartedAt,
|
|
firstEventAt,
|
|
allEoseAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get user-friendly status text for display
|
|
*/
|
|
export function getStatusText(state: ReqOverallState): string {
|
|
switch (state.status) {
|
|
case "discovering":
|
|
return "DISCOVERING";
|
|
case "connecting":
|
|
return "CONNECTING";
|
|
case "loading":
|
|
return "LOADING";
|
|
case "live":
|
|
return "LIVE";
|
|
case "partial":
|
|
return "PARTIAL";
|
|
case "offline":
|
|
return "OFFLINE";
|
|
case "closed":
|
|
return "CLOSED";
|
|
case "failed":
|
|
return "FAILED";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get detailed status description for tooltips
|
|
*/
|
|
export function getStatusTooltip(state: ReqOverallState): string {
|
|
const { status, connectedCount, totalRelays, hasReceivedEvents } = state;
|
|
|
|
switch (status) {
|
|
case "discovering":
|
|
return "Selecting optimal relays using NIP-65";
|
|
case "connecting":
|
|
return `Connecting to ${totalRelays} relay${totalRelays !== 1 ? "s" : ""}...`;
|
|
case "loading":
|
|
return hasReceivedEvents
|
|
? `Loading events from ${connectedCount}/${totalRelays} relays`
|
|
: `Waiting for events from ${connectedCount}/${totalRelays} relays`;
|
|
case "live":
|
|
return `Streaming live events from ${connectedCount}/${totalRelays} relays`;
|
|
case "partial":
|
|
return `${connectedCount}/${totalRelays} relays active, some failed or disconnected`;
|
|
case "offline":
|
|
return "All relays disconnected. Showing cached results.";
|
|
case "closed":
|
|
return "Query completed, all relays closed";
|
|
case "failed":
|
|
return `Failed to connect to any of ${totalRelays} relays`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get status indicator color class
|
|
*/
|
|
export function getStatusColor(status: ReqOverallStatus): string {
|
|
switch (status) {
|
|
case "discovering":
|
|
case "connecting":
|
|
case "loading":
|
|
return "text-warning";
|
|
case "live":
|
|
return "text-success";
|
|
case "partial":
|
|
return "text-warning";
|
|
case "closed":
|
|
return "text-muted-foreground";
|
|
case "offline":
|
|
case "failed":
|
|
return "text-destructive";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Should the status indicator pulse/animate?
|
|
*/
|
|
export function shouldAnimate(status: ReqOverallStatus): boolean {
|
|
return ["discovering", "connecting", "loading", "live"].includes(status);
|
|
}
|
|
|
|
/**
|
|
* Get relay subscription state badge text
|
|
*/
|
|
export function getRelayStateBadge(
|
|
relay: ReqRelayState,
|
|
): { text: string; color: string } | null {
|
|
const { subscriptionState, connectionState } = relay;
|
|
|
|
// Prioritize subscription state
|
|
if (subscriptionState === "receiving") {
|
|
return { text: "RECEIVING", color: "text-success" };
|
|
}
|
|
if (subscriptionState === "eose") {
|
|
return { text: "EOSE", color: "text-info" };
|
|
}
|
|
if (subscriptionState === "error") {
|
|
return { text: "ERROR", color: "text-destructive" };
|
|
}
|
|
|
|
// Show connection state if not connected
|
|
if (connectionState === "connecting") {
|
|
return { text: "CONNECTING", color: "text-warning" };
|
|
}
|
|
if (connectionState === "error") {
|
|
return { text: "ERROR", color: "text-destructive" };
|
|
}
|
|
if (connectionState === "disconnected") {
|
|
return { text: "OFFLINE", color: "text-muted-foreground" };
|
|
}
|
|
|
|
return null;
|
|
}
|