Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
A project whose directory has been moved or deleted says so and leaves the
list for good. With nothing closed yet the button is the picker it always was.

- **Save a ready-to-edit template for a custom theme.** Appearance now puts a
template beside the theme importer, so making a theme can start from a valid
JSON file instead of the documentation alone. The example names every
supported app and terminal color, uses valid values throughout, and is ready
to rename, recolor and import back into lich. Both sides go through the same
native dialogs the rest of lich uses, so the template lands where you choose
it instead of somewhere you have to go find afterwards.
- **Merge past a rule that is holding a pull request back.** Once GitHub answers
that a rule on the base branch stands in the way, gh refuses the merge without
ever calling GitHub — so there was nothing lich could do with that pull request
Expand Down Expand Up @@ -74,6 +81,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
whole exchange in the order it happened — verdicts, comments on the pull
request itself, and the threads whose lines the branch has since rewritten,
which no diff can show. Settled threads fold themselves away behind a count.
- **Appearance can import custom color themes.** lich still ships the bundled
Light and Dark themes, but Settings › Appearance now accepts theme JSON files
saved under the app config directory. Imported themes can recolor both the
interface tokens and the terminal palette, can be selected independently for
the app and terminal, and can be removed without touching the bundled themes.
Re-importing an existing id asks before replacing it, and a deleted selected
theme returns both pickers to their automatic system behavior.
- **Review comments, handed to the session as one prompt.** A diff could give
the session a *reference* — a file, a line range — but never an instruction,
so anything you actually wanted changed had to be typed out by hand while
Expand Down
100 changes: 100 additions & 0 deletions docs/themes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Theme JSON

lich ships bundled `light` and `dark` themes and accepts user-imported JSON themes.
The Appearance settings can save a valid dark starter template that uses only hex
colors; it is `themes/template.json`, embedded in the binary, and it names every
supported color.
Imported themes are stored under the user config directory:

- Production: `<config-dir>/lich/themes/<id>.json`
- Dev mode (`LICH_DEV`): `<config-dir>/lich/themes-dev/<id>.json`

The file name is managed by lich on import. A stored custom theme whose file name
does not match `<id>.json` is ignored.

## Import Shape

```json
{
"id": "tokyo-night",
"name": "Tokyo Night",
"scheme": "dark",
"app": {
"background": "oklch(0.18 0.05 276)",
"foreground": "oklch(0.92 0.03 270)",
"card": "oklch(0.22 0.05 276)",
"card-foreground": "oklch(0.92 0.03 270)",
"popover": "oklch(0.22 0.05 276)",
"popover-foreground": "oklch(0.92 0.03 270)",
"primary": "oklch(0.75 0.13 255)",
"primary-foreground": "oklch(0.14 0.04 276)",
"secondary": "oklch(0.27 0.05 276)",
"secondary-foreground": "oklch(0.88 0.04 270)",
"muted": "oklch(0.25 0.04 276)",
"muted-foreground": "oklch(0.65 0.05 270)",
"accent": "oklch(0.32 0.07 276)",
"accent-foreground": "oklch(0.92 0.03 270)",
"destructive": "oklch(0.7 0.18 25)",
"border": "oklch(0.95 0.02 270 / 12%)",
"input": "oklch(0.95 0.02 270 / 16%)",
"ring": "oklch(0.75 0.13 255)",
"chart-1": "oklch(0.75 0.13 255)",
"chart-2": "oklch(0.78 0.13 150)",
"chart-3": "oklch(0.82 0.13 85)",
"chart-4": "oklch(0.73 0.15 320)",
"chart-5": "oklch(0.76 0.14 25)",
"sidebar": "oklch(0.16 0.04 276)",
"sidebar-foreground": "oklch(0.92 0.03 270)",
"sidebar-primary": "oklch(0.75 0.13 255)",
"sidebar-primary-foreground": "oklch(0.14 0.04 276)",
"sidebar-accent": "oklch(0.27 0.05 276)",
"sidebar-accent-foreground": "oklch(0.92 0.03 270)",
"sidebar-border": "oklch(0.95 0.02 270 / 12%)",
"sidebar-ring": "oklch(0.75 0.13 255)"
},
"terminal": {
"background": "#1a1b26",
"foreground": "#c0caf5",
"cursor": "#c0caf5",
"selectionBackground": "#33467c"
}
}
```

`origin` is optional in imported JSON. The backend overwrites it with `custom`
before saving.

## Validation Rules

- `id` is required, must match `^[a-z0-9][a-z0-9._-]{0,63}$`, and cannot be
`light`, `dark`, `system`, or `match`. Windows device names (`con`, `prn`,
`aux`, `nul`, `com1`-`com9`, `lpt1`-`lpt9`) are rejected too: a theme id names
a file, and those still address a device even with an extension.
- `name` is required, cannot be blank, and cannot exceed 128 characters.
- `scheme` must be `light` or `dark`.
- `app` is required and must include every token shown in the example.
- `terminal.background` and `terminal.foreground` are required.
- Optional terminal tokens are: `cursor`, `cursorAccent`, `selectionBackground`,
`selectionForeground`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`,
`cyan`, `white`, `brightBlack`, `brightRed`, `brightGreen`, `brightYellow`,
`brightBlue`, `brightMagenta`, `brightCyan`, `brightWhite`.
- Unknown app or terminal tokens are rejected.
- No color value can be blank or exceed 128 characters.

Each color block takes what its consumer actually parses:

- **`app`** values become CSS custom properties, so they accept hex
(`#rgb` through `#rrggbbaa`), a CSS color name, or a color function —
`rgb()`, `rgba()`, `hsl()`, `hsla()`, `oklch()`, `oklab()`, `lab()`, `lch()`,
`color()`. Anything that could name a resource or defer a value — `url()`,
`image-set()`, `var()`, `attr()`, `color-mix()` — is rejected.
- **`terminal`** values must be hex. xterm parses hex directly; every other form
goes through a round-trip that fails on translucency and is swallowed into a
silent fallback, which would apply a theme only halfway with no error shown.

Import uses lich's native file picker. Re-importing a theme whose `id` already
exists asks for confirmation before replacing the stored file; cancelling the
confirmation leaves the existing theme unchanged.

The template download uses the native save dialog, so the file lands where you
choose it; the dialog confirms replacing an existing file.
10 changes: 6 additions & 4 deletions frontend/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ state first.

### Color

Zinc, achromatic, by decision — there is **no brand accent hue**. The palette is defined once in
`src/index.css` (`@theme` + `:root`/`.dark`, oklch). Never hardcode a hex or oklch value in a component; always
go through a token.
The bundled light/dark themes are zinc and achromatic — there is **no brand accent hue** by default. Palettes are
theme JSON data applied to the tokens mapped in `src/index.css` (`@theme` + CSS variables). Never hardcode a hex or
oklch value in a component; always go through a token.
The importable theme JSON contract lives in `docs/themes.md`.

Surface roles (dark values shown; light is the mirror):

Expand Down Expand Up @@ -120,7 +121,8 @@ Short specs; the code is the detail. All follow the idiom above.

## Fixed by decision — do not "fix" these

- **Zinc, no accent hue.** Adding a brand color is a design change, not a tweak.
- **Bundled themes stay zinc, no accent hue.** A chromatic palette belongs in a custom theme JSON, not in component
classes.
- **The layout.** Top tabs · left session sidebar · terminal · right dock · footer. The reskin is skin, not
structure.
- **Semantic color** and the **diff language badges** — they carry meaning.
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/FooterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function FooterBar({ dock, onDock }: FooterBarProps) {

const attachFile = async () => {
try {
const file = await ProjectService.PickFile()
const file = await ProjectService.PickFile("Attach File")
if (file && sessionId) {
void TerminalService.Write(sessionId, `${file} `)
}
Expand Down
19 changes: 6 additions & 13 deletions frontend/src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { copyToastMessage, COPY_TOAST_DURATION_MS } from "@/lib/terminal/copy-to
import { computeGrid } from "@/lib/terminal/term-fit"
import { mouseEncodingSequence } from "@/lib/terminal/term-modes"
import { useSettings } from "@/providers/settings"
import type { ResolvedTheme } from "@/providers/settings"
import type { SessionKind } from "@/lib/session/sessions"
import "@xterm/xterm/css/xterm.css"

Expand Down Expand Up @@ -106,13 +105,6 @@ function fitTerminal(term: Terminal, container: HTMLElement): void {
}
}

// Light matches the app's light-gray canvas (--background) rather than pure
// white, which glared; the dark foreground keeps CLI output high-contrast.
const TERMINAL_COLORS: Record<ResolvedTheme, { background: string; foreground: string }> = {
dark: { background: "#06070f", foreground: "#e5e7eb" },
light: { background: "#e8e8ea", foreground: "#1f2328" },
}

// ensureFontLoaded blocks until a font is available. The renderer measures
// cell metrics at open, so bundled fonts (@font-face) must be loaded first;
// system fonts resolve as no-ops and failures fall back to monospace.
Expand Down Expand Up @@ -176,6 +168,7 @@ export function TerminalView({
visible,
}: TerminalViewProps) {
const { font, terminalFontSize, resolvedTerminalTheme } = useSettings()
const terminalColors = resolvedTerminalTheme.terminal
const containerRef = useRef<HTMLDivElement | null>(null)
const liveRef = useRef<LiveTerminal | null>(null)
// False until the mount effect's async setup builds the first terminal.
Expand All @@ -195,11 +188,11 @@ export function TerminalView({
const visibleRef = useRef(visible)
const fontRef = useRef(font)
const fontSizeRef = useRef(terminalFontSize)
const themeRef = useRef(resolvedTerminalTheme)
const themeRef = useRef(terminalColors)
visibleRef.current = visible
fontRef.current = font
fontSizeRef.current = terminalFontSize
themeRef.current = resolvedTerminalTheme
themeRef.current = terminalColors

// In-terminal search (Ctrl+F). The open flag mirrors into a ref so the
// terminal's key handler — wired once at creation — reads the live value.
Expand Down Expand Up @@ -247,7 +240,7 @@ export function TerminalView({
cursorBlink: true,
scrollback: SCROLLBACK_LINES,
allowProposedApi: true,
theme: TERMINAL_COLORS[themeRef.current],
theme: themeRef.current,
})
const serialize = new SerializeAddon()
term.loadAddon(serialize)
Expand Down Expand Up @@ -600,7 +593,7 @@ export function TerminalView({
useEffect(() => {
const live = liveRef.current
if (live) {
live.term.options.theme = TERMINAL_COLORS[resolvedTerminalTheme]
live.term.options.theme = resolvedTerminalTheme.terminal
}
}, [resolvedTerminalTheme])

Expand All @@ -612,7 +605,7 @@ export function TerminalView({
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: TERMINAL_COLORS[resolvedTerminalTheme].background }}
style={{ backgroundColor: terminalColors.background }}
/>
{searchOpen && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-1 rounded-md border bg-popover p-1 text-popover-foreground shadow-lg">
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/components/pulls/PullsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,8 @@ export function PullsList({
<PanelLeftClose className="size-3.5" />
</IconAction>
</div>
{/* The settings screen's SegmentedControl is built for a full-width
pane; four options with counts wrap in a column this narrow, so this
is the same idiom at list density — one bordered track, the chosen
option filled. */}
{/* Four counted filters need a dense local track so they stay on one
line in this narrow column. */}
<div
role="radiogroup"
aria-label="Pull request filter"
Expand Down
Loading