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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- The Pulls screen opens on an index of the project's pull requests across the whole screen, and a pull request opens in its place instead of beside the list. A back button over its title returns to the index as you left it; beside it, the number opens a menu of the other pull requests, and previous and next walk the index's filter. On the Files tab only the file tree sits beside the diff, which gains the width the list took. The Overview puts the description beside a column with the reviewers, checks, mergeability and the checkout the branch sits in; the tab strip ends with the pull request's size. Open in Session and Merge stay in the header, and the hand-off, Approve, Refresh and Open on GitHub move to the ⋯ menu. On the index, j and k or the arrows move, Enter opens and / goes to the filter; on a pull request, Escape goes back and Alt with an arrow steps to the previous or next one.
- The Pulls screen opens on an index of the project's pull requests across the whole screen, and a pull request opens in its place instead of beside the list. A back button over its title returns to the index as you left it; beside it, the number opens a menu of the other pull requests, and previous and next walk the index's filter. On the Files tab only the file tree sits beside the diff, which gains the width the list took. The Overview puts the description beside a column with the reviewers, checks, mergeability and the checkout the branch sits in; the tab strip ends with the pull request's size. Open in Session and Merge stay in the header, with Resolve conflicts or Fix checks beside them when the pull request needs one, and Approve, Refresh and Open on GitHub move to the ⋯ menu. The screen lines up on the sidebar's spacing, its buttons are the size of the rest of the window's, and the code a review thread is about sits in a block of its own, apart from the comments. On the index, j and k or the arrows move, Enter opens and / goes to the filter; on a pull request, Escape goes back and Alt with an arrow steps to the previous or next one.
- A session's browser is no longer closed without asking. When no pane shows it and nobody has used it for five minutes, a toast asks whether to close it: Close closes it, and Keep open, or swiping the toast away, keeps it for another five minutes. Unanswered, the toast stays up and the browser stays open, so a page left open over lunch is still there when you come back. Using the browser, or the agent using it, takes the toast down. Before, the browser was closed after five minutes away from its session, and its page, tabs and login were lost.

### Fixed

- Code quoted in a review thread on the Pulls screen shows its indentation instead of a box for every tab.
- A file input in the session browser, such as a profile picture upload, opens the system's file chooser when you click it, and takes several files when the input allows it. Before, the click did nothing.
- Leave on a page in the session browser that asks before you go no longer reloads the page instead on a busy machine; the browser goes where it was going.
- An agent clicking a point in the session browser no longer misses it after you move lich to another monitor, resize the browser pane or change its zoom. Its screenshots are now in the same pixels as its clicks, which a HiDPI monitor or a page zoom had made up to 1.5 times larger, and a click at a point read off a screenshot taken before the pane changed size is refused, telling the agent to take a new one.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here.
- **Translations**: two independent languages, the interface and the prompt language (what lich and
lich-plugin hand agents). `.claude/skills/lich-i18n/` maps where a string lands and every point a new
language touches, and the rules for writing a message.
- **How the window looks**: `native/DESIGN.md`, the tokens, button sizes, spacing and screen layout. Read it
before drawing or changing UI; a pattern corrected in review lands there in the same PR.
- **User-facing feature history**: `CHANGELOG.md`.

## Rules of the codebase
Expand Down
174 changes: 174 additions & 0 deletions native/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# lich: design language

How the native window looks and behaves. The tokens live in `ui/theme.go`, the components in `ui/`, and
`ui-gallery` shows every one of them. When this file and the code disagree, the code is a holdout to migrate,
unless a line here says the code is the exception.

Read it before drawing a new screen or changing an old one. A pattern the maintainer corrected in review
belongs here, in the same PR as the fix, so the next screen starts right.

## The one idea: open surfaces, not boxes

Elements are defined by space, hover and islands, not by enclosure.

- **Rows, not cards.** A list item has no border and no fill of its own. Hover and selection paint it.
- **Panels are islands.** The window's regions (sidebar, stage, dock) are rounded surfaces floating on
`Canvas`, separated by the `gutter` (6px, `cmd/lich-native/main.go`) instead of a hairline. The top bar
and the footer sit on the canvas itself, unboxed.
- **Hairlines are seams inside an island.** `th.Separator` splits a panel's own parts (a screen's header
from its body, two panes). It never wraps a list item, a badge or a button at rest. Inputs are the one
exception: a field needs an edge to read as editable.
- **Badges are text and glyph**, never a filled pill.
- **State is fill, not chrome.** Selection is a flat `Accent` fill: no side bar, no ring, no shadow.

A change that adds a border, a pill or a nested surface is probably wrong. Reach for spacing or a hover state
first.

## Foundations

### Units

- Every length is `ui.Space(n)`, Tailwind's spacing scale (`Space(2.5)` is `p-2.5`, 10dp). A `unit.Dp`
literal is for a measure off the scale (a 1px border, a mockup's odd value), named in a constant.
- Text sizes are `ui.TextXS` (12), `ui.TextSM` (14) and `ui.TextBase` (16). A size off the scale is a named
`unit.Sp` constant beside the screen that uses it (`pullsTitleSize`, `settingsTitle`).
- Radius is `ui.RadiusSM` (4), `ui.RadiusMD` (6), `ui.RadiusLG` (8); dialogs alone use 12. Controls and
rows are `RadiusMD`; islands are `RadiusLG`.
- A screen's geometry lives in one `var` block at the top of its file, each value named and, when it comes
from a mockup or a Tailwind class, saying which. Never a bare number inside a layout.

### Color

Never a hex or OKLCH value in a widget: always a `Theme` token. Themes are JSON (`docs/themes.md`).

| Token | Role |
| --- | --- |
| `Canvas` | the ground the islands float on |
| `Background` | full screens inside the stage (Settings, Pulls) and code blocks set inside a raised surface |
| `Sidebar`, `Card`, `Popover` | islands and raised parts: sidebar, dock, a thread or comment box, menus, dialogs |
| `Accent` | hover and selection fill |
| `MutedForeground` | secondary text, icons at rest, paths, meta |
| `Border` | seams and input edges |
| `Primary` | the one high-emphasis button of a view |
| `Foreground` | primary text |

A translucent token goes through `th.Over` before it is painted, and a widget that paints a background
hands its children `th.On(background)`. Gio blends in linear light; `Over` composites the way a browser
does. A tint that must read on a known surface is an opaque `ui.MixOKLab(surface, tone, weight)`.

Interaction fills: rest transparent, hover `Alpha(Accent, 0.5)` to `0.6`, active or selected `Accent`.

### Semantic color

The only chromatic color. It encodes state and never decorates.

| Token | Meaning |
| --- | --- |
| `TonePass` | done, passed, added lines |
| `ToneWait` | waiting on the user, running, outdated |
| `Destructive` | destructive action, failure, removed lines |

Added and removed lines in a code excerpt tint their row with `MixOKLab(Background, tone, 0.12)` and color
their text with the tone itself.

### Typography

- **Onest** for all interface text, **the system monospace** for code, paths, diffs and dense meta.
- Body is `TextSM`, meta and paths `TextXS`, headings named per screen.
- Text a person reads goes through `i18n.T`; nothing literal (see the `lich-i18n` skill).
- Text from the outside world (a diff hunk, a PR body, a commit message) is cleaned before it is shaped:
tabs become four spaces, because the fonts have no glyph for a tab and draw a missing-glyph box.

### Icons

Lucide through `icons.Lucide`, at `Space(3.5)` or `Space(4)`; `Space(3)` inside dense meta. Provider marks
and file-type logos have their own sets in `ui/icons`.

## Controls

### Buttons

`th.Button` with a variant and a size; never a hand-drawn clickable for something that is a button.

| Size | Height | Use |
| --- | --- | --- |
| `SizeSM` / `SizeIconSM` | 32 | **the default for actions on a screen**: headers, thread and comment actions, dialogs |
| `SizeDefault` / `SizeIcon` | 36 | a form's main row, beside a 36px input |
| `SizeXS` / `SizeIconXS` | 24 | inline inside dense rows only (a file row, a binary preview) |
| `SizeLG` | 40 | rare; a lone call to action |

Buttons side by side share one size. A row that mixes 24px and 32px buttons is a bug.

Variants: `VariantDefault` (primary fill) for the one action a view exists for, `VariantSecondary` for the
next most likely, `VariantOutline` for an action that should stand out from ghost ones, `VariantGhost` for
the rest, `VariantDestructive` for what cannot be undone. A disabled button says why in a tooltip.

### What goes where

- **The action a state calls for is a button, in reach.** When something is wrong and lich can hand it to an
agent (Resolve conflicts, Fix checks), the button sits in the header beside the main actions, not in an
overflow menu.
- **The `⋯` menu holds the routine and the rare**: Approve, Refresh, Open on GitHub. Never the action the
current state calls for.
- A menu never opens or ends on a separator, and never shows two in a row.

### Tooltips

`th.Tooltip`, in a few words: what an icon-only control does, or why a control is disabled. Several lines
about the thing under the pointer use the card variant.

## Screens

### The island's edge

Every island keeps its content `Space(2)` (8px) from its rounded edge, on all four sides: the sidebar
(`sidebar.go`, `UniformInset(Space(2))`), the Settings nav (`settings_nav.go`) and the Pulls header strip
(`pullsToolbarPad`). Controls and rows sit flush on that edge, box first: the sidebar's New session button,
the Settings search field, a nav row, the Pulls back button and tree toggle all start at 8px.

Text inside a row is indented by the row's own padding, not by the island: `Space(2.5)` (10px) on a sidebar
card (`sidebar_card.go`) and a Settings nav row (`navRowPadX`), so a row's label lands at 18px from the
island's edge and its hover fill spans the 8px edge to edge.

Rows in a list stand `Space(0.5)` to `Space(1.5)` apart; groups of rows `Space(2)`.

The geometry is shared; the look is not. Every screen lays out on the sidebar's edge, padding and hover
shape, and keeps what makes it itself: the Pulls list keeps its seams between rows, the Settings pane its
reading column. Matching a screen to the sidebar means its spacing, never its decoration.

### Header and strips

A screen's header and every strip under it (a toolbar, a file list's top bar) sit on the island's 8px edge.
The first control's box, not its glyph, sits on that edge, so an icon button's glyph lands at its own
padding inside it.

Each header line is a flex row: what the screen is about on the left, flexing; its actions on the right,
`Rigid`, separated by `ui.Gap(2)`. When the actions and the title no longer fit on one line, the actions take
their own line above the title rather than squeezing it.

A `th.Separator` closes the header off from the body.

### Body

A reading pane, a column of prose and settings rows under a title, is the one place that leaves the 8px
edge: the Settings pane sits at `settingsPanePadX` / `settingsPanePadY` (40px / 32px), its close button in
the corner at `settingsCloseInset` (14px). Every other screen body follows the sidebar: its content at
18px from the island's edge, the 8px edge plus a row's 10px (`pullsPadX` in Pulls).

### Boxes inside a screen

A thread, a pending comment or a composer is a box on `Sidebar` with `RadiusMD` and `Space(3)` padding. Its
children draw on `th.On(th.Sidebar)`.

Code quoted inside such a box (a diff hunk) never shares the box's fill: it sits in its own block on
`Background`, `RadiusSM`, `Space(2)` padding, so code and prose read as two things.

Vertical rhythm inside a box: `Space(2)` above a code block, `Space(2.5)` between comments and before the
action row.

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

- **The bundled themes stay achromatic.** A colored palette is a custom theme, not a widget's color.
- **The layout**: top tabs, left sidebar, terminal, right dock, footer.
- **Semantic color and the diff language badges**: they carry meaning.
- **Buttons do not move when pressed**; only their color changes.
48 changes: 36 additions & 12 deletions native/cmd/lich-native/pulls_conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,21 +325,45 @@ func lineAnchor(start, line int) string {
// the thread is about.
const hunkLines = 4

// hunkTint is how strongly an added or removed line tints its row, and
// hunkTab what a tab expands to: the mono font has no glyph for one.
const (
hunkTint = 0.12
hunkTab = " "
)

// hunkTail sits the code on the window's background, apart from the
// comments on the thread's surface.
func hunkTail(gtx C, th *ui.Theme, hunk string) D {
lines := strings.Split(strings.TrimRight(hunk, "\n"), "\n")
lines = lines[max(0, len(lines)-hunkLines):]
var children []layout.FlexChild
for _, line := range lines {
fg := th.MutedForeground
switch {
case strings.HasPrefix(line, "+"):
fg = th.TonePass
case strings.HasPrefix(line, "-"):
fg = th.Destructive
}
children = append(children, layout.Rigid(func(gtx C) D { return truncated(gtx, th.Text(ui.TextXS, line).Mono().In(fg)) }))
children = append(children, layout.Rigid(func(gtx C) D { return hunkLine(gtx, th, line) }))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
return layout.Background{}.Layout(gtx,
func(gtx C) D { return ui.Fill(gtx, th.Background, ui.RadiusSM) },
func(gtx C) D {
return layout.UniformInset(ui.Space(2)).Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
})
}

func hunkLine(gtx C, th *ui.Theme, line string) D {
fg, tint := th.MutedForeground, th.Background
switch {
case strings.HasPrefix(line, "+"):
fg, tint = th.TonePass, ui.MixOKLab(th.Background, th.TonePass, hunkTint)
case strings.HasPrefix(line, "-"):
fg, tint = th.Destructive, ui.MixOKLab(th.Background, th.Destructive, hunkTint)
}
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Background{}.Layout(gtx,
func(gtx C) D { return ui.Fill(gtx, tint, 0) },
func(gtx C) D {
return truncated(gtx, th.Text(ui.TextXS, strings.ReplaceAll(line, "\t", hunkTab)).Mono().In(fg))
})
}

// threadActions is Reply and Resolve, with Send to session at the end, or
Expand All @@ -366,9 +390,9 @@ func (v *pullView) threadActions(gtx C, th *ui.Theme, p *pullsScreen, d backend.
label = i18n.T("pulls.reviewThread.reopen")
}
replyB := th.Button(reply, i18n.T("pulls.reviewThread.reply"))
replyB.Variant, replyB.Size = ui.VariantOutline, ui.SizeXS
replyB.Variant, replyB.Size = ui.VariantOutline, ui.SizeSM
resolveB := th.Button(resolve, label)
resolveB.Variant, resolveB.Size, resolveB.Disabled = ui.VariantGhost, ui.SizeXS, busy == "resolve:"+t.ID
resolveB.Variant, resolveB.Size, resolveB.Disabled = ui.VariantGhost, ui.SizeSM, busy == "resolve:"+t.ID
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(replyB.Layout), ui.Gap(2), layout.Rigid(resolveB.Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Min.X, 0)} }),
Expand All @@ -391,7 +415,7 @@ func (v *pullView) toSessionButton(gtx C, th *ui.Theme, p *pullsScreen, d backen
label, tip = i18n.T("pulls.threadSend.send"), i18n.T("pulls.threadSend.sendTip", i18n.Args{"name": p.sessionLabel(id)})
}
b := th.Button(click, label)
b.Icon, b.Variant, b.Size, b.Disabled = icPullSession, ui.VariantGhost, ui.SizeXS, busy
b.Icon, b.Variant, b.Size, b.Disabled = icPullSession, ui.VariantGhost, ui.SizeSM, busy
return v.withTip(gtx, th, p, "toSession:"+t.ID, tip, b.Layout)
}

Expand Down
39 changes: 21 additions & 18 deletions native/cmd/lich-native/pulls_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,30 +210,33 @@ func (p *pullsScreen) row(gtx C, th *ui.Theme, pr backend.PRSummary, now time.Ti
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Background{}.Layout(gtx,
func(gtx C) D { return ui.Fill(gtx, bg, 0) },
func(gtx C) D {
on := th.On(bg)
return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(2.5), Bottom: ui.Space(2.5)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { return rowTitle(gtx, on, pr) }),
ui.VGap(1),
layout.Rigid(func(gtx C) D { return rowMeta(gtx, on, pr, now) }),
)
}),
ui.Gap(4),
layout.Rigid(func(gtx C) D { return rowSide(gtx, on, pr, checkoutName(checkout)) }),
)
})
})
return layout.Inset{Left: pullsRowEdge, Right: pullsRowEdge}.Layout(gtx, func(gtx C) D {
return layout.Background{}.Layout(gtx,
func(gtx C) D { return ui.Fill(gtx, bg, ui.RadiusMD) },
func(gtx C) D { return rowContent(gtx, th.On(bg), pr, checkoutName(checkout), now) })
})
}),
layout.Rigid(th.Separator),
)
})
}

func rowContent(gtx C, th *ui.Theme, pr backend.PRSummary, checkout string, now time.Time) D {
return layout.Inset{Left: pullsRowPadX, Right: pullsRowPadX, Top: ui.Space(2.5), Bottom: ui.Space(2.5)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { return rowTitle(gtx, th, pr) }),
ui.VGap(1),
layout.Rigid(func(gtx C) D { return rowMeta(gtx, th, pr, now) }),
)
}),
ui.Gap(4),
layout.Rigid(func(gtx C) D { return rowSide(gtx, th, pr, checkout) }),
)
})
}

func rowTitle(gtx C, th *ui.Theme, pr backend.PRSummary) D {
return layout.Flex{}.Layout(gtx,
layout.Rigid(th.Text(ui.TextSM, fmt.Sprintf("#%d", pr.Number)).Mono().Muted().Layout),
Expand Down
Loading
Loading