@carlesandres/house 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Browser.tsx CHANGED
@@ -22,7 +22,7 @@ import { CommandPalette } from "./CommandPalette.tsx"
22
22
  import { filterFiles } from "./discovery/filter.ts"
23
23
  import { type FileEntry } from "./discovery/walk.ts"
24
24
  import { BRAND, BRAND_NAME } from "./brand.ts"
25
- import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
25
+ import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
26
26
  import { Header, HEADER_HEIGHT } from "./Header.tsx"
27
27
  import { HelpOverlay } from "./HelpOverlay.tsx"
28
28
  import { openInEditor, resolveEditor } from "./io/editor.ts"
@@ -37,6 +37,7 @@ import {
37
37
  } from "./layout/resolve.ts"
38
38
  import { formatSidebarRow } from "./layout/sidebarRow.ts"
39
39
  import { PromptRow } from "./PromptRow.tsx"
40
+ import { buildReaderEmptyStateTips, pickTipByRotation } from "./tips.ts"
40
41
  import { openInBrowser } from "./serve/openBrowser.ts"
41
42
  import { startServer, type ServerHandle } from "./serve/server.ts"
42
43
  import { colors, setActiveTheme } from "./theme/colors.ts"
@@ -44,6 +45,7 @@ import { themeAtom } from "./theme/atom.ts"
44
45
  import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
45
46
 
46
47
  export type SidebarMode = "auto" | "on" | "off"
48
+ export type StartupFocus = "sidebar" | "reader" | "filter"
47
49
 
48
50
  export interface BrowserProps {
49
51
  readonly files: readonly FileEntry[]
@@ -53,6 +55,17 @@ export interface BrowserProps {
53
55
  /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
54
56
  * when discovery has finished; the indicator clears. */
55
57
  readonly discoveryStatus?: string | null
58
+ /** Test seam: override the footer discovery spinner speed. */
59
+ readonly discoverySpinnerIntervalMs?: number
60
+ readonly discoverySpinnerInitialFrameIndex?: number
61
+ /** Test seam: deterministic footer spinner driver. */
62
+ readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
63
+ /** Test seam: override filter debounce timing. */
64
+ readonly filterDebounceMs?: number
65
+ /** Test seam: override rendered-path debounce timing. */
66
+ readonly renderedPathDebounceMs?: number
67
+ /** Test seam: disable reader-empty-state tip rotation effect. */
68
+ readonly disableReaderEmptyStateRotation?: boolean
56
69
  /** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
57
70
  * launch viewport bucket once; subsequent visibility goes through `s`. */
58
71
  readonly sidebarMode?: SidebarMode
@@ -67,15 +80,16 @@ export interface BrowserProps {
67
80
  /** TTL (ms) for the update-notice toast. Exposed so tests can use a small
68
81
  * value instead of sleeping for the production 10s window. */
69
82
  readonly updateNoticeTtlMs?: number
83
+ /** Test seam: disable footer-notice auto-clear timers. */
84
+ readonly disableFooterNoticeAutoClear?: boolean
70
85
  /** Flip the parent's discovery vocabulary (#145). Browser doesn't need
71
86
  * to know which categories are currently on — the toggle is opaque
72
87
  * from this side; we just snapshot the selected path so it can be
73
88
  * restored across the re-walk the parent triggers. */
74
89
  readonly onToggleAll?: () => void
75
- /** Open the sidebar filter prompt on mount so the user can type
76
- * immediately. Esc closes it through the normal close path no
77
- * special "first close" behavior. */
78
- readonly startInFilter?: boolean
90
+ /** Startup pane/input target. `filter` opens the sidebar filter prompt on
91
+ * mount so the user can type immediately. */
92
+ readonly startupFocus?: StartupFocus | null
79
93
  }
80
94
 
81
95
  const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
@@ -94,18 +108,38 @@ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
94
108
  "palette.open",
95
109
  ])
96
110
 
111
+ let nextReaderEmptyStateTipRotation = 0
112
+
113
+ export const resetReaderEmptyStateTipRotationForTests = () => {
114
+ nextReaderEmptyStateTipRotation = 0
115
+ }
116
+
117
+ export const setReaderEmptyStateTipRotationForTests = (next: number) => {
118
+ nextReaderEmptyStateTipRotation = next
119
+ }
120
+
121
+ const FILTER_DEBOUNCE_MS = 50
122
+ const RENDERED_PATH_DEBOUNCE_MS = 80
123
+
97
124
  export const Browser = ({
98
125
  files,
99
126
  initialIndex = 0,
100
127
  maxWidth = null,
101
128
  discoveryStatus = null,
129
+ discoverySpinnerIntervalMs,
130
+ discoverySpinnerInitialFrameIndex,
131
+ discoverySpinnerRegisterTick = null,
132
+ filterDebounceMs = FILTER_DEBOUNCE_MS,
133
+ renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
134
+ disableReaderEmptyStateRotation = false,
102
135
  sidebarMode = "auto",
103
136
  onQuit,
104
137
  readFile = defaultReadFile,
105
138
  updateNotice = null,
106
139
  updateNoticeTtlMs = 10000,
140
+ disableFooterNoticeAutoClear = false,
107
141
  onToggleAll,
108
- startInFilter = false,
142
+ startupFocus = null,
109
143
  }: BrowserProps) => {
110
144
  const renderer = useRenderer()
111
145
  const { width, height } = useTerminalDimensions()
@@ -134,17 +168,29 @@ export const Browser = ({
134
168
  return initialShownForAuto(width)
135
169
  }
136
170
  })
137
- // startInFilter mirrors `openFilter`'s focus rule: the filter input lives
138
- // in the sidebar, so opening it on mount also forces sidebar focus
139
- // regardless of `--sidebar=off` (§7.1's visibility derivation surfaces
140
- // the sidebar via focus even when `shown` is false).
171
+ const startInFilter = startupFocus === "filter"
172
+ const initialFocus: "sidebar" | "reader" =
173
+ startupFocus === null
174
+ ? shown
175
+ ? "sidebar"
176
+ : "reader"
177
+ : startupFocus === "reader"
178
+ ? "reader"
179
+ : "sidebar"
180
+ // `filter` mirrors `openFilter`'s focus rule: the filter input lives in
181
+ // the sidebar, so opening it on mount also forces sidebar focus regardless
182
+ // of `--sidebar=off` (§7.1's visibility derivation surfaces the sidebar via
183
+ // focus even when `shown` is false). Plain `sidebar` startup shares the
184
+ // same pane focus without opening the prompt. When omitted, preserve the
185
+ // legacy Browser behavior: initial focus follows visibility.
141
186
  const [focus, setFocus] = useState<"sidebar" | "reader">(() =>
142
- shown || startInFilter ? "sidebar" : "reader",
187
+ shown || initialFocus === "sidebar" ? "sidebar" : "reader",
143
188
  )
144
189
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
145
190
  const [helpVisible, setHelpVisible] = useState<boolean>(false)
146
191
  const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
147
- const [filterQuery, setFilterQuery] = useState<string>("")
192
+ const [filterInput, setFilterInput] = useState<string>("")
193
+ const [filterApplied, setFilterApplied] = useState<string>("")
148
194
  const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
149
195
  const [paletteQuery, setPaletteQuery] = useState<string>("")
150
196
  const [paletteIndex, setPaletteIndex] = useState<number>(0)
@@ -155,12 +201,20 @@ export const Browser = ({
155
201
  const paletteOpenRef = useRef(false)
156
202
  const paletteQueryRef = useRef("")
157
203
  const paletteIndexRef = useRef(0)
204
+ const [readerEmptyStateTipRotation, setReaderEmptyStateTipRotation] = useState(
205
+ () => nextReaderEmptyStateTipRotation,
206
+ )
207
+ const readerEmptyStateVisibleRef = useRef(false)
158
208
  // Mirror filter state into refs so the keyboard handler sees synchronous
159
209
  // updates even when multiple keys arrive in a single React batch (the
160
210
  // first key opens the filter; subsequent keys in the same tick would
161
211
  // otherwise still observe filterOpen=false through closure).
162
212
  const filterOpenRef = useRef(startInFilter)
163
- const filterQueryRef = useRef("")
213
+ const filterInputRef = useRef("")
214
+ const filterAppliedRef = useRef("")
215
+ const autoSelectForAppliedFilterRef = useRef(true)
216
+ const focusRef = useRef<"sidebar" | "reader">(focus)
217
+ const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
164
218
  const [footerNotice, setFooterNoticeState] = useState<{
165
219
  readonly text: string
166
220
  readonly ttlMs: number
@@ -192,15 +246,20 @@ export const Browser = ({
192
246
  // the other's display window.
193
247
  useEffect(() => {
194
248
  if (footerNotice === null) return
249
+ if (disableFooterNoticeAutoClear) return
195
250
  const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
196
251
  return () => clearTimeout(timer)
197
- }, [footerNotice])
252
+ }, [disableFooterNoticeAutoClear, footerNotice])
198
253
 
199
254
  // Push the update-available nudge once, when it arrives from the parent
200
255
  // (the registry probe resolves asynchronously after boot). 10s gives the
201
256
  // user time to read it before it auto-clears; the quit-time stderr print
202
257
  // is the durable record they can copy from scrollback.
203
258
  const updateNoticeSeenRef = useRef<string | null>(null)
259
+ useEffect(() => {
260
+ focusRef.current = focus
261
+ }, [focus])
262
+
204
263
  useEffect(() => {
205
264
  if (!updateNotice) return
206
265
  if (updateNoticeSeenRef.current === updateNotice) return
@@ -225,7 +284,29 @@ export const Browser = ({
225
284
  pushFooterNotice(`tone: ${nextTone}`)
226
285
  }
227
286
 
228
- const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
287
+ useEffect(() => {
288
+ if (filterInput === filterApplied) return
289
+ const timer = setTimeout(() => {
290
+ filterAppliedRef.current = filterInput
291
+ setFilterApplied(filterInput)
292
+ }, filterDebounceMs)
293
+ return () => clearTimeout(timer)
294
+ }, [filterApplied, filterDebounceMs, filterInput])
295
+
296
+ useEffect(() => {
297
+ autoSelectForAppliedFilterRef.current = true
298
+ }, [filterApplied])
299
+
300
+ const displayedFiles = useMemo(() => filterFiles(files, filterApplied), [files, filterApplied])
301
+ const filterHasNoMatches = filterInput.length > 0 && displayedFiles.length === 0
302
+
303
+ useEffect(() => {
304
+ if (filterApplied.length === 0) return
305
+ if (!autoSelectForAppliedFilterRef.current) return
306
+ if (displayedFiles.length === 0) return
307
+ setSelectedIndex(0)
308
+ autoSelectForAppliedFilterRef.current = false
309
+ }, [displayedFiles, filterApplied])
229
310
  // When the filtered list shrinks, keep selectedIndex valid. The reset to 0
230
311
  // on every query change happens in the keystroke handler, not here, so a
231
312
  // no-op rerender doesn't snap the cursor back to the top.
@@ -263,9 +344,9 @@ export const Browser = ({
263
344
  useEffect(() => {
264
345
  const target = selected?.path ?? null
265
346
  if (target === renderedPath) return
266
- const timer = setTimeout(() => setRenderedPath(target), 80)
347
+ const timer = setTimeout(() => setRenderedPath(target), renderedPathDebounceMs)
267
348
  return () => clearTimeout(timer)
268
- }, [selected?.path, renderedPath])
349
+ }, [selected?.path, renderedPath, renderedPathDebounceMs])
269
350
 
270
351
  useEffect(() => {
271
352
  if (!renderedPath) {
@@ -306,7 +387,8 @@ export const Browser = ({
306
387
  sidebarShown: shown,
307
388
  helpVisible,
308
389
  filterOpen,
309
- filterQuery,
390
+ restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
391
+ filterQuery: filterInput,
310
392
  paletteOpen,
311
393
  setFocus,
312
394
  // Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
@@ -316,6 +398,7 @@ export const Browser = ({
316
398
  // itself) deliberately use the raw `setSelectedIndex` setter.
317
399
  setSelectedIndex: (updater) => {
318
400
  pendingSelectionPathRef.current = null
401
+ autoSelectForAppliedFilterRef.current = false
319
402
  setSelectedIndex(updater)
320
403
  },
321
404
  toggleShown: () => {
@@ -349,7 +432,9 @@ export const Browser = ({
349
432
  // the inline sidebar back on screen if it was hidden. In narrow,
350
433
  // focusing the sidebar swaps to the sidebar screen. Either way
351
434
  // no need to mutate `shown`.
435
+ focusRef.current = "sidebar"
352
436
  if (focus !== "sidebar") setFocus("sidebar")
437
+ restoreFilterOnSidebarFocusRef.current = true
353
438
  filterOpenRef.current = true
354
439
  setFilterOpen(true)
355
440
  },
@@ -357,10 +442,14 @@ export const Browser = ({
357
442
  // Reset both the ref and the state so the freshly-opened modal
358
443
  // shows an empty input and selection lands on the first file in
359
444
  // the (now unfiltered) list.
360
- filterQueryRef.current = ""
361
- setFilterQuery("")
445
+ filterInputRef.current = ""
446
+ filterAppliedRef.current = ""
447
+ setFilterInput("")
448
+ setFilterApplied("")
362
449
  setSelectedIndex(() => 0)
450
+ focusRef.current = "sidebar"
363
451
  if (focus !== "sidebar") setFocus("sidebar")
452
+ restoreFilterOnSidebarFocusRef.current = true
364
453
  filterOpenRef.current = true
365
454
  setFilterOpen(true)
366
455
  },
@@ -429,7 +518,7 @@ export const Browser = ({
429
518
  if (!file) return
430
519
  const editor = resolveEditor(process.env)
431
520
  if (!editor) {
432
- pushFooterNotice("set $EDITOR or $VISUAL to use e")
521
+ pushFooterNotice("set $EDITOR or $VISUAL to use E")
433
522
  return
434
523
  }
435
524
  if (!renderer) {
@@ -500,13 +589,16 @@ export const Browser = ({
500
589
  // so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
501
590
  // the user is typing. This sits outside the data-driven keymap
502
591
  // for the same reason the help branch does — see DESIGN.md §12.
503
- if (filterOpenRef.current) {
592
+ if (filterOpenRef.current && focusRef.current === "sidebar") {
504
593
  // One close path used by both Esc and Return. `commit=true` is
505
594
  // the Return semantic (open the match in the reader); false is
506
595
  // Esc (stop typing, keep the applied filter, stay in sidebar).
507
596
  const closeFilter = (commit: boolean) => {
597
+ filterAppliedRef.current = filterInputRef.current
598
+ setFilterApplied(filterInputRef.current)
508
599
  const picked = displayedFiles[selectedIndex] ?? null
509
600
  const effectiveCommit = commit && picked !== null
601
+ restoreFilterOnSidebarFocusRef.current = false
510
602
  filterOpenRef.current = false
511
603
  setFilterOpen(false)
512
604
  // Where focus lands after the filter closes:
@@ -515,9 +607,12 @@ export const Browser = ({
515
607
  // otherwise → sidebar if it's up so j/k keeps walking the
516
608
  // filtered list; reader if the sidebar was hidden.
517
609
  if (effectiveCommit) {
610
+ focusRef.current = "reader"
518
611
  setFocus("reader")
519
612
  } else {
520
- setFocus(shown ? "sidebar" : "reader")
613
+ const nextFocus = shown ? "sidebar" : "reader"
614
+ focusRef.current = nextFocus
615
+ setFocus(nextFocus)
521
616
  }
522
617
  }
523
618
  if (key.name === "escape") {
@@ -528,25 +623,35 @@ export const Browser = ({
528
623
  closeFilter(true)
529
624
  return
530
625
  }
626
+ if (key.name === "tab" || (key.ctrl && key.name === "i" && !key.shift && !key.meta)) {
627
+ focusRef.current = "reader"
628
+ restoreFilterOnSidebarFocusRef.current = true
629
+ filterOpenRef.current = false
630
+ setFilterOpen(false)
631
+ setFocus("reader")
632
+ return
633
+ }
531
634
  if (key.ctrl && key.name === "\\") {
532
635
  // Same action as the `filter.clearOrOpen` binding fires from
533
636
  // outside the modal: clear the query, reset selection. The
534
637
  // keymap doesn't see keys in filter mode, so this branch is
535
638
  // the in-modal half of that single chord.
536
- filterQueryRef.current = ""
537
- setFilterQuery("")
639
+ filterInputRef.current = ""
640
+ filterAppliedRef.current = ""
641
+ setFilterInput("")
642
+ setFilterApplied("")
538
643
  setSelectedIndex(() => 0)
539
644
  return
540
645
  }
541
646
  if (key.name === "backspace" || key.name === "delete") {
542
647
  // Backspace on empty input closes the modal — the leading `/`
543
648
  // chevron is the last thing left to "delete."
544
- if (filterQueryRef.current.length === 0) {
649
+ if (filterInputRef.current.length === 0) {
545
650
  closeFilter(false)
546
651
  return
547
652
  }
548
- filterQueryRef.current = filterQueryRef.current.slice(0, -1)
549
- setFilterQuery(filterQueryRef.current)
653
+ filterInputRef.current = filterInputRef.current.slice(0, -1)
654
+ setFilterInput(filterInputRef.current)
550
655
  setSelectedIndex(() => 0)
551
656
  return
552
657
  }
@@ -565,8 +670,8 @@ export const Browser = ({
565
670
  char = key.shift ? key.name.toUpperCase() : key.name
566
671
  }
567
672
  if (char !== null) {
568
- filterQueryRef.current = filterQueryRef.current + char
569
- setFilterQuery(filterQueryRef.current)
673
+ filterInputRef.current = filterInputRef.current + char
674
+ setFilterInput(filterInputRef.current)
570
675
  setSelectedIndex(() => 0)
571
676
  }
572
677
  return
@@ -682,6 +787,22 @@ export const Browser = ({
682
787
  // per-pane border title that used to carry this information).
683
788
  const currentFile = selected?.relativePath ?? null
684
789
  const content = loaded?.path === renderedPath ? loaded.content : ""
790
+ const readerEmptyStateTitle = filterHasNoMatches
791
+ ? `No files match: ${filterInput}`
792
+ : `${BRAND} ${BRAND_NAME}`
793
+ const readerEmptyStateVisible = error == null && renderedPath == null
794
+
795
+ useEffect(() => {
796
+ if (disableReaderEmptyStateRotation) return
797
+ if (readerEmptyStateVisible) {
798
+ if (!readerEmptyStateVisibleRef.current) {
799
+ readerEmptyStateVisibleRef.current = true
800
+ setReaderEmptyStateTipRotation(nextReaderEmptyStateTipRotation++)
801
+ }
802
+ return
803
+ }
804
+ readerEmptyStateVisibleRef.current = false
805
+ }, [disableReaderEmptyStateRotation, readerEmptyStateVisible])
685
806
 
686
807
  // Sidebar virtualization: render only the visible window. Without this,
687
808
  // every keystroke re-renders all N file rows even though only the bg of
@@ -732,6 +853,24 @@ export const Browser = ({
732
853
  : browserBindings,
733
854
  [helpVisible],
734
855
  )
856
+ const footerProps = {
857
+ bindings: footerBindings,
858
+ ctx,
859
+ width,
860
+ notice: footerNotice?.text ?? null,
861
+ discoveryStatus,
862
+ filterQuery: !filterOpen && filterInput.length > 0 ? filterInput : null,
863
+ ...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
864
+ ...(discoverySpinnerInitialFrameIndex === undefined
865
+ ? {}
866
+ : { discoverySpinnerInitialFrameIndex }),
867
+ ...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
868
+ } satisfies FooterProps<BrowserCtx>
869
+ const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
870
+ const readerEmptyStateTip = useMemo(
871
+ () => pickTipByRotation(readerEmptyStateTips, readerEmptyStateTipRotation),
872
+ [readerEmptyStateTipRotation, readerEmptyStateTips],
873
+ )
735
874
 
736
875
  // One sidebar body for both wide-inline and narrow-stack rendering; only
737
876
  // the wrapper differs (fixed-width sibling vs flex-grow full-pane).
@@ -739,7 +878,7 @@ export const Browser = ({
739
878
  <>
740
879
  {filterRowVisible && (
741
880
  <PromptRow
742
- query={filterQuery}
881
+ query={filterInput}
743
882
  editing={filterOpen}
744
883
  placeholder="/ to filter…"
745
884
  width={sidebarTextWidth}
@@ -761,9 +900,17 @@ export const Browser = ({
761
900
  const realIdx = desiredScroll + idx
762
901
  const isSelected = realIdx === selectedIndex
763
902
  const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
764
- const basenameFg = isSelected ? colors.textStrong : colors.text
903
+ const selectedFg =
904
+ colors.selectedListItemText === colors.background
905
+ ? colors.primary
906
+ : colors.selectedListItemText
907
+ const basenameFg = isSelected
908
+ ? sidebarActive
909
+ ? selectedFg
910
+ : colors.primary
911
+ : colors.text
765
912
  const rowStyle = isSelected
766
- ? { bg: sidebarActive ? colors.selectedBg : colors.selectedBgInactive }
913
+ ? { bg: sidebarActive ? colors.backgroundElement : colors.borderSubtle }
767
914
  : {}
768
915
  return (
769
916
  <text key={file.path} wrapMode="none" style={rowStyle}>
@@ -801,14 +948,16 @@ export const Browser = ({
801
948
  } as const
802
949
 
803
950
  return (
804
- <box style={{ width, height, flexDirection: "column", backgroundColor: colors.surface }}>
951
+ <box
952
+ style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
953
+ >
805
954
  <Header width={width} currentFile={currentFile} />
806
955
  <box
807
956
  style={{
808
957
  flexDirection: "row",
809
958
  flexGrow: 1,
810
959
  flexShrink: 1,
811
- backgroundColor: colors.surface,
960
+ backgroundColor: colors.backgroundPanel,
812
961
  }}
813
962
  >
814
963
  {sidebarInline && (
@@ -825,7 +974,7 @@ export const Browser = ({
825
974
  // Dim by default. Borders/separators ride on this so they read
826
975
  // as a single connected frame regardless of focus; only the
827
976
  // active pane's inner body overrides to the raised tint below.
828
- backgroundColor: colors.surface,
977
+ backgroundColor: colors.backgroundPanel,
829
978
  }}
830
979
  {...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
831
980
  >
@@ -835,7 +984,7 @@ export const Browser = ({
835
984
  flexShrink: 1,
836
985
  flexDirection: "column",
837
986
  paddingLeft: 1,
838
- backgroundColor: sidebarActive ? colors.background : colors.surface,
987
+ backgroundColor: sidebarActive ? colors.background : colors.backgroundPanel,
839
988
  }}
840
989
  >
841
990
  {sidebarBody}
@@ -851,7 +1000,7 @@ export const Browser = ({
851
1000
  flexShrink: 1,
852
1001
  flexDirection: "column",
853
1002
  // Dim by default (see sidebar note); inner body overrides when active.
854
- backgroundColor: colors.surface,
1003
+ backgroundColor: colors.backgroundPanel,
855
1004
  }}
856
1005
  >
857
1006
  <box
@@ -860,28 +1009,38 @@ export const Browser = ({
860
1009
  flexShrink: 1,
861
1010
  flexDirection: "column",
862
1011
  padding: 1,
863
- backgroundColor: readerActive ? colors.background : colors.surface,
1012
+ backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
864
1013
  }}
865
1014
  >
866
1015
  {error ? (
867
1016
  <text content={error} style={{ fg: colors.error }} />
868
1017
  ) : !renderedPath ? (
869
1018
  // Reader empty state — no file selected. Brand mark centered as a
870
- // welcome anchor; in-app tips (#47) will live here too.
1019
+ // welcome anchor; reusable tips live here too.
871
1020
  <box
872
1021
  style={{
873
1022
  flexGrow: 1,
874
1023
  flexShrink: 1,
875
1024
  alignItems: "center",
876
1025
  justifyContent: "center",
877
- backgroundColor: readerActive ? colors.background : colors.surface,
1026
+ backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
878
1027
  }}
879
1028
  >
880
- <text
881
- content={`${BRAND} ${BRAND_NAME}`}
882
- wrapMode="none"
883
- style={{ fg: colors.textMuted }}
884
- />
1029
+ <box style={{ flexDirection: "column", gap: 1, alignItems: "center" }}>
1030
+ <text
1031
+ content={readerEmptyStateTitle}
1032
+ wrapMode="none"
1033
+ style={{ fg: colors.textMuted }}
1034
+ />
1035
+ {readerEmptyStateTip && (
1036
+ <text
1037
+ key={readerEmptyStateTip.id}
1038
+ content={readerEmptyStateTip.text}
1039
+ wrapMode="none"
1040
+ style={{ fg: colors.textMuted }}
1041
+ />
1042
+ )}
1043
+ </box>
885
1044
  </box>
886
1045
  ) : (
887
1046
  <scrollbox
@@ -890,7 +1049,7 @@ export const Browser = ({
890
1049
  scrollX: false,
891
1050
  flexGrow: 1,
892
1051
  flexShrink: 1,
893
- backgroundColor: readerActive ? colors.background : colors.surface,
1052
+ backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
894
1053
  }}
895
1054
  // opentui's scrollbox consumes arrow keys at the focused-element
896
1055
  // level *before* useKeyboard fires, so a modal that handles
@@ -907,7 +1066,7 @@ export const Browser = ({
907
1066
  content={content}
908
1067
  syntaxStyle={syntaxStyle}
909
1068
  fg={colors.text}
910
- bg={readerActive ? colors.background : colors.surface}
1069
+ bg={readerActive ? colors.background : colors.backgroundPanel}
911
1070
  conceal
912
1071
  style={{ width: maxWidth ?? "100%" }}
913
1072
  />
@@ -917,14 +1076,7 @@ export const Browser = ({
917
1076
  </box>
918
1077
  )}
919
1078
  </box>
920
- <Footer
921
- bindings={footerBindings}
922
- ctx={ctx}
923
- width={width}
924
- notice={footerNotice?.text ?? null}
925
- discoveryStatus={discoveryStatus}
926
- filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
927
- />
1079
+ <Footer {...footerProps} />
928
1080
  {helpVisible && (
929
1081
  <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
930
1082
  )}
@@ -102,7 +102,7 @@ export const CommandPalette = ({
102
102
  border: true,
103
103
  borderColor: colors.textMuted,
104
104
  flexDirection: "column",
105
- backgroundColor: colors.surface,
105
+ backgroundColor: colors.backgroundPanel,
106
106
  }}
107
107
  >
108
108
  <PromptRow query={query} editing={true} width={rowWidth} />
@@ -119,15 +119,15 @@ export const CommandPalette = ({
119
119
  ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
120
120
  : " ".repeat(SHORTCUT_WIDTH)
121
121
  // Title and shortcut render as separate spans so the shortcut
122
- // can use `textMuted` while the title uses `text`/`textStrong`.
122
+ // can use `textMuted` while the title uses `text`/`primary`.
123
123
  // Same trick opencode pulls with `--text-weak` — the theme
124
124
  // guarantees the contrast, we just pick the right role.
125
- const titleFg = isSelected ? colors.textStrong : colors.text
125
+ const titleFg = isSelected ? colors.primary : colors.text
126
126
  return (
127
127
  <text
128
128
  key={cmd.id}
129
129
  wrapMode="none"
130
- style={isSelected ? { bg: colors.selectedBg } : {}}
130
+ style={isSelected ? { bg: colors.backgroundElement } : {}}
131
131
  >
132
132
  <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
133
133
  <span style={{ fg: colors.textMuted }}>{shortcutText}</span>