diff --git a/plugins/mock/plugin.js b/plugins/mock/plugin.js index b2e23f92..ea8b2d3f 100644 --- a/plugins/mock/plugin.js +++ b/plugins/mock/plugin.js @@ -1,342 +1,55 @@ (function () { - const DEFAULT_CONFIG = { - // By default this plugin is intentionally non-deterministic / failure-prone. - // If you want to pin a specific mode, set { pinned: true, mode: "..." }. - mode: "chaos", - pinned: false, - } - function lineText(opts) { - const line = { type: "text", label: opts.label, value: opts.value } + var line = { type: "text", label: opts.label, value: opts.value } if (opts.color) line.color = opts.color if (opts.subtitle) line.subtitle = opts.subtitle return line } function lineProgress(opts) { - const line = { type: "progress", label: opts.label, used: opts.used, limit: opts.limit, format: opts.format } + var line = { type: "progress", label: opts.label, used: opts.used, limit: opts.limit, format: opts.format } if (opts.resetsAt) line.resetsAt = opts.resetsAt + if (opts.periodDurationMs) line.periodDurationMs = opts.periodDurationMs if (opts.color) line.color = opts.color return line } function lineBadge(opts) { - const line = { type: "badge", label: opts.label, text: opts.text } + var line = { type: "badge", label: opts.label, text: opts.text } if (opts.color) line.color = opts.color if (opts.subtitle) line.subtitle = opts.subtitle return line } - function safeString(value) { - try { - if (value === null) return "null" - if (value === undefined) return "undefined" - if (typeof value === "string") return value - return JSON.stringify(value) - } catch { - return String(value) - } - } - - function readJson(ctx, path) { - try { - if (!ctx.host.fs.exists(path)) return null - const text = ctx.host.fs.readText(path) - return JSON.parse(text) - } catch { - return null - } - } - - function writeJson(ctx, path, value) { - try { - ctx.host.fs.writeText(path, JSON.stringify(value, null, 2)) - } catch {} - } - - function readConfig(ctx, configPath) { - const parsed = readJson(ctx, configPath) - - // Initialize config on first run. - if (!parsed || typeof parsed !== "object") { - writeJson(ctx, configPath, DEFAULT_CONFIG) - return DEFAULT_CONFIG - } - - const pinned = typeof parsed.pinned === "boolean" ? parsed.pinned : false - const mode = parsed.mode ?? DEFAULT_CONFIG.mode - - // Auto-migrate legacy configs that were auto-created as { mode: "ok" }. - if (!pinned && mode === "ok") { - writeJson(ctx, configPath, DEFAULT_CONFIG) - return DEFAULT_CONFIG - } - - return { mode, pinned } - } - - function chooseChaosCase(ctx, pluginDataDir) { - const statePath = pluginDataDir + "/state.json" - const state = readJson(ctx, statePath) - const prevCounter = Number(state && state.counter) - const counter = Number.isFinite(prevCounter) && prevCounter >= 0 ? prevCounter + 1 : 0 - - const cases = [ - // "Looks fine" baseline - "ok", - - // Subtle API misuse that doesn't crash but yields wrong UI - "progress_missing_format", - "progress_used_negative", - "progress_limit_zero", - "progress_percent_limit_not_100", - "progress_count_missing_suffix", - "progress_resetsAt_invalid", - "badge_text_number", - - // Hard schema issues (host returns a single Error badge) - "lines_not_array", - "line_not_object", - - // Explicit runtime failures (realistic errors) - "auth_required_cli", - "token_expired_cli", - "refresh_revoked", - "network_error", - "rate_limited", - - // Promise behavior - "unresolved_promise", - "http_throw", - "sqlite_throw", - ] - - const idx = counter % cases.length - const picked = cases[idx] - - writeJson(ctx, statePath, { counter, picked, nowIso: ctx.nowIso }) - return { counter, picked } - } - - function writeLastCase(ctx, pluginDataDir, picked) { - writeJson(ctx, pluginDataDir + "/last_case.json", { picked, nowIso: ctx.nowIso }) - } - - function probe(ctx) { - const configPath = ctx.app.pluginDataDir + "/config.json" - const config = readConfig(ctx, configPath) - const pinned = !!config.pinned - const requestedMode = config.mode ?? DEFAULT_CONFIG.mode - const effectiveMode = pinned ? requestedMode : "chaos" - - let mode = effectiveMode - if (effectiveMode === "chaos") { - const picked = chooseChaosCase(ctx, ctx.app.pluginDataDir).picked - writeLastCase(ctx, ctx.app.pluginDataDir, picked) - mode = picked - } - - const plan = safeString(effectiveMode) - - // Non-throwing modes should always include a "where to change this" hint. - const hintLines = [ - lineText({ label: "Config", value: configPath }), - ] - - if (mode === "ok") { - return { - plan: plan, - lines: [ - ...hintLines, - effectiveMode === "chaos" ? lineBadge({ label: "Case", text: "ok", color: "#000000" }) : null, - lineProgress({ label: "Percent", used: 42, limit: 100, format: { kind: "percent" }, color: "#22c55e" }), - lineProgress({ label: "Dollars", used: 12.34, limit: 100, format: { kind: "dollars" }, color: "#3b82f6" }), - lineText({ label: "Now", value: ctx.nowIso }), - ].filter(Boolean), - } - } - - if (mode === "auth_required_cli") { - throw "Not logged in. Run mockctl to authenticate." - } - - if (mode === "token_expired_cli") { - return Promise.reject("Token expired. Run mockctl to refresh.") - } - - if (mode === "refresh_revoked") { - throw "Token revoked. Run mockctl to log in again." - } - - if (mode === "network_error") { - throw "Network error. Check your connection." - } - - if (mode === "rate_limited") { - throw "Rate limited. Wait a few minutes." - } - - if (mode === "unresolved_promise") { - return new Promise(function () { - // Intentionally never resolves/rejects. - }) - } - - if (mode === "non_object") { - return "not an object" - } - - if (mode === "missing_lines") { - return {} - } - - if (mode === "unknown_line_type") { - return { - plan: plan, - lines: [ - ...hintLines, - { type: "nope", label: "Bad", value: "data" }, - ], - } - } - - if (mode === "lines_not_array") { - // Host expects `lines` to be an Array. This becomes "missing lines". - return { - lines: "nope", - } - } - - if (mode === "line_not_object") { - // Host expects each line to be an object. This becomes "invalid line at index N". - return { - plan: plan, - lines: [ - ...hintLines, - "definitely not an object", - ], - } - } - - if (mode === "progress_missing_format") { - // v2 validation: missing format -> error badge line - return { - plan: plan, - lines: [ - ...hintLines, - lineBadge({ label: "Case", text: "progress.format missing", color: "#000000" }), - { type: "progress", label: "Percent", used: 42, limit: 100, color: "#ef4444" }, - ], - } - } - - if (mode === "progress_used_negative") { - // v2 validation: used must be >= 0 - return { - plan: plan, - lines: [ - ...hintLines, - lineBadge({ label: "Case", text: "progress.used = -1", color: "#000000" }), - lineProgress({ label: "Percent", used: -1, limit: 100, format: { kind: "percent" }, color: "#ef4444" }), - ], - } - } - - if (mode === "progress_limit_zero") { - // v2 validation: limit must be > 0 - return { - plan: plan, - lines: [ - ...hintLines, - lineBadge({ label: "Case", text: "progress.limit = 0", color: "#000000" }), - lineProgress({ label: "Percent", used: 1, limit: 0, format: { kind: "percent" }, color: "#ef4444" }), - ], - } - } - - if (mode === "progress_percent_limit_not_100") { - // v2 validation: percent requires limit=100 - return { - plan: plan, - lines: [ - ...hintLines, - lineBadge({ label: "Case", text: "percent format with limit != 100", color: "#000000" }), - lineProgress({ label: "Percent", used: 42, limit: 99, format: { kind: "percent" }, color: "#ef4444" }), - ], - } - } - - if (mode === "progress_count_missing_suffix") { - // v2 validation: count requires suffix - return { - plan: plan, - lines: [ - ...hintLines, - lineBadge({ label: "Case", text: "count format missing suffix", color: "#000000" }), - lineProgress({ label: "Credits", used: 10, limit: 1000, format: { kind: "count" }, color: "#ef4444" }), - ], - } - } - - if (mode === "progress_resetsAt_invalid") { - // v2 validation: invalid resetsAt -> warn + omit - return { - plan: plan, - lines: [ - ...hintLines, - lineBadge({ label: "Case", text: "progress.resetsAt = not-a-date", color: "#000000" }), - lineProgress({ - label: "Session", - used: 42, - limit: 100, - format: { kind: "percent" }, - resetsAt: "not-a-date", - color: "#ef4444", - }), - ], - } - } - - if (mode === "badge_text_number") { - // Common plugin bug: badge.text isn't a string. Host reads empty string. - return { - plan: plan, - lines: [ - ...hintLines, - lineBadge({ label: "Case", text: "badge.text = 123 (number)", color: "#000000" }), - { type: "badge", label: "Status", text: 123, color: "#ef4444" }, - ], - } - } - - if (mode === "fs_throw") { - // Uncaught host FS exception -> host should report "probe() failed". - ctx.host.fs.readText("/definitely/not/a/real/path-" + String(Date.now())) - return { plan: plan, lines: hintLines } - } - - if (mode === "http_throw") { - // Invalid HTTP method -> host throws -> host should report "probe() failed". - ctx.host.http.request({ - method: "NOPE_METHOD", - url: "https://example.com/", - timeoutMs: 1000, - }) - return { plan: plan, lines: hintLines } - } - - if (mode === "sqlite_throw") { - // Dot-commands are blocked by host -> uncaught -> host should report "probe() failed". - ctx.host.sqlite.query(ctx.app.appDataDir + "/does-not-matter.db", ".schema") - return { plan: plan, lines: hintLines } - } + function probe() { + var _15d = 15 * 24 * 60 * 60 * 1000 + var _30d = _15d * 2 + var _resets = new Date(Date.now() + _15d).toISOString() + var _pastReset = new Date(Date.now() - 60000).toISOString() - // Unknown mode: don't throw; make it obvious. return { - plan: plan, + plan: "stress-test", lines: [ - ...hintLines, - lineBadge({ label: "Warning", text: "unknown mode: " + safeString(mode), color: "#f59e0b" }), + // Pace statuses + lineProgress({ label: "Ahead pace", used: 30, limit: 100, format: { kind: "percent" }, resetsAt: _resets, periodDurationMs: _30d }), + lineProgress({ label: "On Track pace", used: 45, limit: 100, format: { kind: "percent" }, resetsAt: _resets, periodDurationMs: _30d }), + lineProgress({ label: "Behind pace", used: 65, limit: 100, format: { kind: "percent" }, resetsAt: _resets, periodDurationMs: _30d }), + // Edge cases + lineProgress({ label: "Empty bar", used: 0, limit: 500, format: { kind: "dollars" } }), + lineProgress({ label: "Exactly full", used: 1000, limit: 1000, format: { kind: "count", suffix: "tokens" } }), + lineProgress({ label: "Over limit!", used: 1337, limit: 1000, format: { kind: "count", suffix: "requests" } }), + lineProgress({ label: "Huge numbers", used: 8429301, limit: 10000000, format: { kind: "count", suffix: "tokens" } }), + lineProgress({ label: "Tiny sliver", used: 1, limit: 10000, format: { kind: "percent" } }), + lineProgress({ label: "Almost full", used: 9999, limit: 10000, format: { kind: "percent" } }), + lineProgress({ label: "Expired reset", used: 42, limit: 100, format: { kind: "percent" }, resetsAt: _pastReset, periodDurationMs: _30d }), + // Text lines + lineText({ label: "Status", value: "Active" }), + lineText({ label: "Very long value", value: "This is an extremely long value string that should test text overflow and wrapping behavior in the card layout" }), + lineText({ label: "", value: "Empty label" }), + // Badge lines + lineBadge({ label: "Tier", text: "Enterprise", color: "#8B5CF6" }), + lineBadge({ label: "Alert", text: "Rate limited", color: "#ef4444" }), + lineBadge({ label: "Region", text: "us-east-1" }), ], } } diff --git a/plugins/mock/plugin.json b/plugins/mock/plugin.json index dfc906e5..aab922f0 100644 --- a/plugins/mock/plugin.json +++ b/plugins/mock/plugin.json @@ -7,11 +7,21 @@ "icon": "icon.svg", "brandColor": "#EF4444", "lines": [ - { "type": "text", "label": "Config", "scope": "overview" }, - { "type": "badge", "label": "Case", "scope": "overview" }, - { "type": "progress", "label": "Percent", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Dollars", "scope": "detail" }, - { "type": "text", "label": "Now", "scope": "detail" }, - { "type": "badge", "label": "Warning", "scope": "detail" } + { "type": "progress", "label": "Ahead pace", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "On Track pace", "scope": "overview", "primaryOrder": 2 }, + { "type": "progress", "label": "Behind pace", "scope": "overview", "primaryOrder": 3 }, + { "type": "progress", "label": "Empty bar", "scope": "overview", "primaryOrder": 4 }, + { "type": "progress", "label": "Exactly full", "scope": "overview", "primaryOrder": 5 }, + { "type": "progress", "label": "Over limit!", "scope": "overview", "primaryOrder": 6 }, + { "type": "progress", "label": "Huge numbers", "scope": "overview", "primaryOrder": 7 }, + { "type": "progress", "label": "Tiny sliver", "scope": "overview", "primaryOrder": 8 }, + { "type": "progress", "label": "Almost full", "scope": "overview", "primaryOrder": 9 }, + { "type": "progress", "label": "Expired reset", "scope": "overview", "primaryOrder": 10 }, + { "type": "text", "label": "Status", "scope": "overview" }, + { "type": "text", "label": "Very long value", "scope": "overview" }, + { "type": "text", "label": "", "scope": "overview" }, + { "type": "badge", "label": "Tier", "scope": "overview" }, + { "type": "badge", "label": "Alert", "scope": "overview" }, + { "type": "badge", "label": "Region", "scope": "overview" } ] } diff --git a/plugins/mock/plugin.test.js b/plugins/mock/plugin.test.js index 24cefb36..08ee9ca5 100644 --- a/plugins/mock/plugin.test.js +++ b/plugins/mock/plugin.test.js @@ -8,135 +8,44 @@ const loadPlugin = async () => { const createCtx = (overrides) => makePluginTestContext(overrides, vi) -const setConfig = (ctx, value) => { - ctx.host.fs.writeText(ctx.app.pluginDataDir + "/config.json", JSON.stringify(value)) -} - describe("mock plugin", () => { beforeEach(() => { delete globalThis.__openusage_plugin if (vi.resetModules) vi.resetModules() }) - it("initializes config and returns ok case", async () => { - const ctx = createCtx() - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - expect(result.lines.find((line) => line.label === "Percent")).toBeTruthy() - }) - - it("auto-migrates legacy ok config", async () => { - const plugin = await loadPlugin() - const ctx = createCtx() - setConfig(ctx, { mode: "ok" }) - const result = plugin.probe(ctx) - expect(result.plan).toBe("chaos") - expect(result.lines.find((line) => line.label === "Case")).toBeTruthy() - }) - - it("renders ok mode when pinned", async () => { + it("returns stress-test lines", async () => { const plugin = await loadPlugin() - const ctx = createCtx() - setConfig(ctx, { pinned: true, mode: "ok" }) - const result = plugin.probe(ctx) - expect(result.lines.find((line) => line.label === "Percent")).toBeTruthy() + const result = plugin.probe(createCtx()) + expect(result.plan).toBe("stress-test") + expect(result.lines.length).toBeGreaterThanOrEqual(16) }) - it("stringifies non-string modes for warnings", async () => { + it("includes progress lines with all edge cases", async () => { const plugin = await loadPlugin() - const ctx = createCtx() - setConfig(ctx, { pinned: true, mode: { kind: "weird" } }) - const result = plugin.probe(ctx) - const warning = result.lines.find((line) => line.label === "Warning") - expect(warning?.text).toContain("\"kind\":\"weird\"") - }) - - it("falls back to default when config json is invalid", async () => { - const plugin = await loadPlugin() - const ctx = createCtx() - ctx.host.fs.writeText(ctx.app.pluginDataDir + "/config.json", "{bad") - const result = plugin.probe(ctx) - expect(result.plan).toBeTruthy() - }) - - it("handles several pinned modes", async () => { + const result = plugin.probe(createCtx()) + const progressLabels = result.lines + .filter((l) => l.type === "progress") + .map((l) => l.label) + expect(progressLabels).toContain("Ahead pace") + expect(progressLabels).toContain("Empty bar") + expect(progressLabels).toContain("Over limit!") + expect(progressLabels).toContain("Huge numbers") + expect(progressLabels).toContain("Expired reset") + }) + + it("includes text and badge lines", async () => { const plugin = await loadPlugin() - const ctx = createCtx() - - setConfig(ctx, { pinned: true, mode: "progress_missing_format" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Case")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "progress_used_negative" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Case")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "progress_limit_zero" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Case")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "progress_percent_limit_not_100" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Case")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "progress_count_missing_suffix" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Case")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "progress_resetsAt_invalid" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Case")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "badge_text_number" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Case")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "lines_not_array" }) - expect(plugin.probe(ctx).lines).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "line_not_object" }) - expect(plugin.probe(ctx).lines).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "non_object" }) - expect(plugin.probe(ctx)).toBe("not an object") - - setConfig(ctx, { pinned: true, mode: "missing_lines" }) - expect(plugin.probe(ctx).lines).toBeUndefined() - - setConfig(ctx, { pinned: true, mode: "unknown_line_type" }) - expect(plugin.probe(ctx).lines.find((line) => line.type === "nope")).toBeTruthy() - - setConfig(ctx, { pinned: true, mode: "unknown_mode" }) - expect(plugin.probe(ctx).lines.find((line) => line.label === "Warning")).toBeTruthy() + const result = plugin.probe(createCtx()) + expect(result.lines.find((l) => l.type === "text" && l.label === "Status")).toBeTruthy() + expect(result.lines.find((l) => l.type === "badge" && l.label === "Tier")).toBeTruthy() }) - it("throws or rejects for failure modes", async () => { + it("sets resetsAt and periodDurationMs on pace lines", async () => { const plugin = await loadPlugin() - const ctx = createCtx() - - setConfig(ctx, { pinned: true, mode: "auth_required_cli" }) - expect(() => plugin.probe(ctx)).toThrow("Not logged in") - - setConfig(ctx, { pinned: true, mode: "token_expired_cli" }) - await expect(plugin.probe(ctx)).rejects.toMatch("Token expired") - - setConfig(ctx, { pinned: true, mode: "unresolved_promise" }) - const unresolved = plugin.probe(ctx) - expect(unresolved).toBeInstanceOf(Promise) - - setConfig(ctx, { pinned: true, mode: "http_throw" }) - ctx.host.http.request.mockImplementationOnce(() => { - throw new Error("boom") - }) - expect(() => plugin.probe(ctx)).toThrow() - - setConfig(ctx, { pinned: true, mode: "sqlite_throw" }) - ctx.host.sqlite.query.mockImplementationOnce(() => { - throw new Error("boom") - }) - expect(() => plugin.probe(ctx)).toThrow() - - setConfig(ctx, { pinned: true, mode: "fs_throw" }) - const originalReadText = ctx.host.fs.readText - ctx.host.fs.readText = (path) => { - if (String(path).includes("definitely/not")) { - throw new Error("boom") - } - return originalReadText(path) - } - expect(() => plugin.probe(ctx)).toThrow() + const result = plugin.probe(createCtx()) + const ahead = result.lines.find((l) => l.label === "Ahead pace") + expect(ahead.resetsAt).toBeTruthy() + expect(ahead.periodDurationMs).toBeGreaterThan(0) }) }) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6f5f6065..5474f66c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "openusage" version = "0.4.2" description = "OpenUsage is an open source AI subscription limit tracker" authors = ["Robin Ebers"] -edition = "2026" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/App.test.tsx b/src/App.test.tsx index 7b97bd47..1db4d5ea 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -160,7 +160,7 @@ vi.mock("@/lib/settings", async () => { } }) -import App from "@/App" +import { App } from "@/App" describe("App", () => { beforeEach(() => { diff --git a/src/App.tsx b/src/App.tsx index 9adeee7e..650eb8c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,8 @@ type PluginState = { function App() { const [activeView, setActiveView] = useState("home"); const containerRef = useRef(null); + const scrollRef = useRef(null); + const [canScrollDown, setCanScrollDown] = useState(false); const [pluginStates, setPluginStates] = useState>({}) const [pluginsMeta, setPluginsMeta] = useState([]) const [pluginSettings, setPluginSettings] = useState(null) @@ -759,6 +761,27 @@ function App() { [pluginSettings, setLoadingForPlugins, setErrorForPlugins, startBatch, scheduleTrayIconUpdate] ) + // Detect whether the scroll area has overflow below + useEffect(() => { + const el = scrollRef.current + if (!el) return + const check = () => { + setCanScrollDown(el.scrollHeight - el.scrollTop - el.clientHeight > 1) + } + check() + el.addEventListener("scroll", check, { passive: true }) + const ro = new ResizeObserver(check) + ro.observe(el) + // Re-check when child content changes (async data loads) + const mo = new MutationObserver(check) + mo.observe(el, { childList: true, subtree: true }) + return () => { + el.removeEventListener("scroll", check) + ro.disconnect() + mo.disconnect() + } + }, [activeView]) + // Render content based on active view const renderContent = () => { if (activeView === "home") { @@ -807,18 +830,21 @@ function App() {
-
+
-
-
- {renderContent()} +
+
+
+ {renderContent()} +
+
{ expect(icon).toHaveStyle({ backgroundColor: "#ff0000" }) }) - it("falls back to currentColor for extremely light/dark brand colors", () => { + it("falls back to currentColor (light) or white (dark) for low-contrast brand colors", () => { const onViewChange = vi.fn() // Light mode + very light color => currentColor @@ -58,7 +58,7 @@ describe("SideNav", () => { const pStyle = screen.getByRole("img", { name: "P" }).getAttribute("style") ?? "" expect(pStyle).toMatch(/background-color:\s*currentcolor/i) - // Dark mode + very dark color => currentColor + // Dark mode + very dark color => white darkModeState.useDarkModeMock.mockReturnValueOnce(true) rerender( { /> ) const p2Style = screen.getByRole("img", { name: "P2" }).getAttribute("style") ?? "" - expect(p2Style).toMatch(/background-color:\s*currentcolor/i) + expect(p2Style).toContain("rgb(255, 255, 255)") }) }) diff --git a/src/components/side-nav.tsx b/src/components/side-nav.tsx index 148e87bf..b3b0a021 100644 --- a/src/components/side-nav.tsx +++ b/src/components/side-nav.tsx @@ -45,7 +45,7 @@ function NavButton({ isActive, onClick, children, "aria-label": ariaLabel }: Nav "relative flex items-center justify-center w-full p-2.5 transition-colors", "hover:bg-accent", isActive - ? "text-foreground before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:bg-primary before:rounded-full" + ? "text-foreground before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:bg-primary dark:before:bg-page-accent before:rounded-full" : "text-muted-foreground" )} > @@ -57,7 +57,7 @@ function NavButton({ isActive, onClick, children, "aria-label": ariaLabel }: Nav function getIconColor(brandColor: string | undefined, isDark: boolean): string { if (!brandColor) return "currentColor" const luminance = getRelativeLuminance(brandColor) - if (isDark && luminance < 0.15) return "currentColor" + if (isDark && luminance < 0.15) return "#ffffff" if (!isDark && luminance > 0.85) return "currentColor" return brandColor } @@ -66,7 +66,7 @@ export function SideNav({ activeView, onViewChange, plugins }: SideNavProps) { const isDark = useDarkMode() return ( -