Files
grimoire/src/lib/req-state-machine.ts
Alejandro f464c68bde feat: theme selector (#95)
* 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>
2026-01-14 19:24:37 +01:00

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;
}