Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/components/theme-customizer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from "react"
import { Button } from "./button"
import { Dialog } from "./dialog"
import { Tooltip } from "./tooltip"
import { ThemeColor, THEME_COLORS_MAP, getCurrentTheme, applyTheme, useSaveTheme } from "../hooks/theme"

export function ThemeCustomizer() {
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [currentTheme, setCurrentTheme] = React.useState<ThemeColor>(() => getCurrentTheme())
const saveTheme = useSaveTheme()

const handleThemeChange = (color: ThemeColor) => {
setCurrentTheme(color)
applyTheme(color)
}

const handleSave = async () => {
await saveTheme(currentTheme)
setIsDialogOpen(false)
}

return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Dialog.Trigger asChild>
<Button variant="secondary" size="small">
Customize Colors
</Button>
</Dialog.Trigger>
<Dialog.Content title="Theme Colors">
<div className="grid gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-secondary">Color Theme</label>
<div className="flex gap-2 items-center">
{Object.entries(THEME_COLORS_MAP).map(([color, value]) => (
<Tooltip key={color}>
<Tooltip.Trigger asChild>
<button
onClick={() => handleThemeChange(color as ThemeColor)}
className="w-6 h-6 rounded-full cursor-pointer transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-border-focus relative"
style={{ backgroundColor: value }}
aria-label={`Apply ${color} theme`}
>
{color === currentTheme && (
<div className="absolute inset-0 rounded-full ring-2 ring-border-focus" />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Content side="top">{color}</Tooltip.Content>
</Tooltip>
))}
</div>
</div>
<div className="flex justify-end gap-3 mt-4">
<Button variant="secondary" size="small" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button variant="primary" size="small" onClick={handleSave}>
Save Changes
</Button>
</div>
</div>
</Dialog.Content>
</Dialog>
)
}
139 changes: 139 additions & 0 deletions src/hooks/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useAtomValue, useSetAtom } from "jotai"
import React from "react"
import { globalStateMachineAtom, isRepoClonedAtom } from "../global-state"
import { fs } from "../utils/fs"
import { REPO_DIR } from "../utils/git"
import path from "path"

const THEME_COLORS = {
sand: "#dad9d6",
cyan: "#9ddde7",
yellow: "#f3d768",
amber: "#f3d673",
green: "#adddc0",
red: "#fdbdbe",
purple: "#e0c4f4",
orange: "#ffc182",
} as const

export type ThemeColor = keyof typeof THEME_COLORS
export const THEME_COLORS_MAP = THEME_COLORS

export function getCurrentTheme(): ThemeColor {
// Try to load saved theme from CSS variables
const style = getComputedStyle(document.documentElement)
const neutral6 = style.getPropertyValue("--neutral-6").trim()

// Find which theme matches the current neutral-6 value
const savedTheme = Object.entries(THEME_COLORS).find(([_, value]) => {
return neutral6 === value || neutral6 === `var(--${value}-6)`
})?.[0] as ThemeColor

return savedTheme || "sand"
}

export function applyTheme(color: ThemeColor) {
const cssVars = {} as Record<string, string>

// Update neutral scale variables
for (let i = 1; i <= 12; i++) {
const neutralVar = `--neutral-${i}`
const newColorVar = `--${color}-${i}`
cssVars[neutralVar] = `var(${newColorVar})`
}

// Update alpha variables
for (let i = 1; i <= 12; i++) {
const neutralVar = `--neutral-a${i}`
const newColorVar = `--${color}-a${i}`
cssVars[neutralVar] = `var(${newColorVar})`
}

// Apply colors immediately
Object.entries(cssVars).forEach(([variable, value]) => {
document.documentElement.style.setProperty(variable, value)
})

return cssVars
}

export function generateThemeCSS(cssVars: Record<string, string>) {
return `:root {
${Object.entries(cssVars)
.map(([variable, value]) => ` ${variable}: ${value};`)
.join("\n")}
}
`
}

export function useLoadTheme() {
const isRepoCloned = useAtomValue(isRepoClonedAtom)

React.useEffect(() => {
if (!isRepoCloned) return

async function loadTheme() {
try {
// Try to read theme.css from the .lumen directory
const content = await fs.promises.readFile(`${REPO_DIR}/.lumen/theme.css`, "utf8")

// Create a style element
const style = document.createElement("style")
style.setAttribute("id", "custom-theme")
style.textContent = content.toString()

// Remove any existing custom theme
document.getElementById("custom-theme")?.remove()

// Add the new style element
document.head.appendChild(style)
} catch (error) {
// If theme.css doesn't exist, apply the default theme
console.debug("No custom theme found:", error)
applyTheme("sand")
}
}

loadTheme()
}, [isRepoCloned])
}

export function useSaveTheme() {
const send = useSetAtom(globalStateMachineAtom)

return React.useCallback(
async (color: ThemeColor) => {
const cssVars = applyTheme(color)
const cssContent = generateThemeCSS(cssVars)

// First ensure the .lumen directory exists
try {
const lumenDir = path.join(REPO_DIR, ".lumen")
try {
await fs.promises.stat(lumenDir)
} catch {
await fs.promises.mkdir(lumenDir)
}
} catch (error) {
console.debug("Error creating .lumen directory:", error)
}

// Save theme.css to the .lumen directory using the global state machine
send({
type: "WRITE_FILES",
markdownFiles: { ".lumen/theme.css": cssContent },
commitMessage: `Update theme colors to ${color}`,
})

// Also update the style element
const style = document.getElementById("custom-theme") || document.createElement("style")
style.setAttribute("id", "custom-theme")
style.textContent = cssContent

if (!style.parentElement) {
document.head.appendChild(style)
}
},
[send],
)
}
48 changes: 27 additions & 21 deletions src/routes/_appRoot.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "../global-state"
import { useEditorSettings } from "../hooks/editor-settings"
import { cx } from "../utils/cx"
import { ThemeCustomizer } from "../components/theme-customizer"

export const Route = createFileRoute("/_appRoot/settings")({
component: RouteComponent,
Expand Down Expand Up @@ -158,27 +159,32 @@ function ThemeSection() {

return (
<SettingsSection title="Theme">
<RadioGroup
aria-labelledby="theme-label"
value={theme}
defaultValue="default"
onValueChange={(value) => setTheme(value as "default" | "eink")}
className="flex flex-col gap-3 coarse:gap-4"
name="theme"
>
<div className="flex items-center gap-2">
<RadioGroup.Item id="theme-default" value="default" />
<label htmlFor="theme-default" className="select-none leading-4">
Default
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroup.Item id="theme-eink" value="eink" />
<label htmlFor="theme-eink" className="select-none leading-4">
E-ink
</label>
</div>
</RadioGroup>
<div className="flex flex-col gap-4">
<RadioGroup
aria-labelledby="theme-label"
value={theme}
defaultValue="default"
onValueChange={(value) => setTheme(value as "default" | "eink")}
className="flex flex-col gap-3 coarse:gap-4"
name="theme"
>
<div className="flex items-center gap-2">
<RadioGroup.Item id="theme-default" value="default" />
<label htmlFor="theme-default" className="select-none leading-4">
Default
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroup.Item id="theme-eink" value="eink" />
<label htmlFor="theme-eink" className="select-none leading-4">
E-ink
</label>
</div>
</RadioGroup>

<div className="h-px bg-border-secondary" />
<ThemeCustomizer />
</div>
</SettingsSection>
)
}
Expand Down
5 changes: 5 additions & 0 deletions src/routes/_appRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../global-state"
import { useSearchNotes } from "../hooks/search"
import { useValueRef } from "../hooks/value-ref"
import { useLoadTheme } from "../hooks/theme"

export const Route = createFileRoute("/_appRoot")({
component: RouteComponent,
Expand All @@ -49,6 +50,9 @@ function RouteComponent() {
const { online } = useNetworkState()
const rootRef = React.useRef<HTMLDivElement>(null)

// Load custom theme if available
useLoadTheme()

// Sync when the app becomes visible again
useEvent("visibilitychange", () => {
if (document.visibilityState === "visible" && online) {
Expand Down Expand Up @@ -174,6 +178,7 @@ function RouteComponent() {
]

sendVoiceConversation({ type: "ADD_TOOLS", tools })

return () => {
sendVoiceConversation({ type: "REMOVE_TOOLS", toolNames: tools.map((tool) => tool.name) })
}
Expand Down