@trendr/core 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -1,27 +1,31 @@
1
- <p align="center">
2
- <img src=".github/trend.png" alt="trend" width="400" />
3
- </p>
1
+ <pre align="center">
2
+ ▗ ▌
3
+ ▜▘▛▘█▌▛▌▛▌
4
+ ▐▖▌ ▙▖▌▌▙▌
4
5
 
5
- <p align="center">4-16x faster than ink and neo-blessed - <a href="bench/README.md">benchmarks</a></p>
6
+ </pre>
6
7
 
7
- Direct-mode TUI renderer with JSX, signals, and per-cell diffing.
8
8
 
9
- ESM only. Requires esbuild (or similar) for JSX transformation.
9
+ JSX components, signals, per-cell diffing and flexbox without React and [Yoga](https://github.com/facebook/yoga/issues/1859). Terminals are character grids, not DOM trees. Why reconcile a virtual DOM to write escape sequences?
10
10
 
11
- ```json
12
- { "jsx": "automatic", "jsxImportSource": "trend" }
13
- ```
11
+ 4-16x faster frame times and 580x less I/O per render than popular TUI frameworks. No dependencies. [benchmarks](bench/README.md)
14
12
 
15
- https://github.com/user-attachments/assets/d307ba1e-2b21-4f7d-8b1b-56252820db6c
13
+ https://github.com/user-attachments/assets/6e84bb4b-a99e-46f2-a235-1ee4be62c0ae
16
14
 
17
- https://github.com/user-attachments/assets/a5984747-f365-4fe3-a161-93101682ca42
15
+ ## Usage
18
16
 
19
- https://github.com/user-attachments/assets/9c357c93-0299-4480-a969-61a54b49ec33
17
+ ```bash
18
+ npm i @trendr/core
19
+ ```
20
20
 
21
- ## Usage
21
+ Requires esbuild (or similar) for JSX transformation.
22
+
23
+ ```json
24
+ { "jsx": "automatic", "jsxImportSource": "trend" }
25
+ ```
22
26
 
23
27
  ```jsx
24
- import { mount, createSignal, useInput } from 'trend'
28
+ import { mount, createSignal, useInput } from '@trendr/core'
25
29
 
26
30
  function App() {
27
31
  const [count, setCount] = createSignal(0)
@@ -42,20 +46,31 @@ function App() {
42
46
  mount(App)
43
47
  ```
44
48
 
45
- `mount(Component, { stream?, stdin?, theme? })` enters alt screen, starts a 60fps render loop, and returns `{ unmount }`. Components are plain functions that return JSX. State is managed with signals - call the getter to read, the setter to write. The framework re-renders automatically when signals change.
49
+ `mount(Component, { stream?, stdin?, title?, theme? })` enters alt screen and returns `{ unmount, repaint }`. Renders on demand when signals change, capped at 60fps.
46
50
 
47
51
  ### Theming
48
52
 
49
- All built-in components use `accent` as the focus/highlight color (default: `'cyan'`). Override it globally via mount:
53
+ Pass a theme object to `mount` to configure global defaults:
50
54
 
51
55
  ```jsx
52
- mount(App, { theme: { accent: 'green' } })
56
+ mount(App, {
57
+ theme: {
58
+ accent: 'green', // focus/highlight color, default 'cyan'
59
+ cursor: {
60
+ blink: true, // default false
61
+ rate: 530, // blink interval ms, default 530
62
+ style: 'block', // default 'block'
63
+ bg: 'cyan', // cursor background color
64
+ color: 'black', // cursor text color
65
+ },
66
+ },
67
+ })
53
68
  ```
54
69
 
55
- Components read the accent with `useTheme()`. Use this in your own components to stay consistent:
70
+ Components read the theme with `useTheme()`:
56
71
 
57
72
  ```jsx
58
- import { useTheme } from 'trend'
73
+ import { useTheme } from '@trendr/core'
59
74
 
60
75
  const { accent } = useTheme()
61
76
  ```
@@ -65,7 +80,7 @@ Individual components still accept explicit color props (e.g. `<Spinner color="m
65
80
  ## Signals
66
81
 
67
82
  ```js
68
- import { createSignal, createEffect, createMemo, batch, untrack, onCleanup } from 'trend'
83
+ import { createSignal, createEffect, createMemo, batch, untrack, onCleanup } from '@trendr/core'
69
84
 
70
85
  const [value, setValue] = createSignal(0)
71
86
  value() // read (tracks dependency)
@@ -109,26 +124,58 @@ Two element types: `box` (container) and `text` (leaf).
109
124
  margin: 1, // same variants as padding
110
125
  border: 'round', // 'single' | 'double' | 'round' | 'bold'
111
126
  borderColor: 'cyan',
127
+ borderEdges: { bottom: true, left: true }, // render only specific sides
112
128
  bg: 'blue', // background color
129
+ texture: 'dots', // background texture (see below)
130
+ textureColor: '#333', // color for texture characters
131
+ position: 'absolute', // remove from flow, position with top/left/right/bottom
132
+ top: 0, left: 0, right: 0, bottom: 0,
133
+ overflow: 'scroll', // scrollable container (see ScrollBox)
134
+ scrollOffset: 0, // scroll position (rows from top)
113
135
  }}>
136
+ ```
114
137
 
138
+ ```jsx
115
139
  <text style={{
116
140
  color: 'cyan', // named, hex (#ff0000), or 256-color index
117
141
  bg: 'black',
118
- bold: true,
119
- dim: true,
120
- italic: true,
121
- underline: true,
122
- inverse: true,
123
- strikethrough: true,
142
+ bold: true, dim: true, italic: true,
143
+ underline: true, inverse: true, strikethrough: true,
124
144
  overflow: 'wrap', // 'wrap' (default) | 'truncate' | 'nowrap'
125
145
  }}>
126
146
  ```
127
147
 
148
+ ### Background Textures
149
+
150
+ Repeating character fill for box backgrounds. Works with or without `bg`.
151
+
152
+ ```jsx
153
+ <box style={{ bg: '#1a1a2e', texture: 'dots', textureColor: '#2a2a4e' }}>
154
+ ```
155
+
156
+ Presets: `'shade-light'` (░), `'shade-medium'` (▒), `'shade-heavy'` (▓), `'dots'` (·), `'cross'` (╳), `'grid'` (┼), `'dash'` (╌). Or pass any single character: `texture: '~'`.
157
+
158
+ Texture characters show through spaces in text rendered on top (unless the text has an explicit `bg`, which claims the cell).
159
+
160
+ ### Absolute Positioning
161
+
162
+ Position relative to parent, removed from flex flow.
163
+
164
+ ```jsx
165
+ <box style={{ border: 'round', height: 5, flexDirection: 'column' }}>
166
+ <text>content here</text>
167
+ <box style={{ position: 'absolute', top: 0, right: 1 }}>
168
+ <text style={{ color: 'green', bold: true }}>ONLINE</text>
169
+ </box>
170
+ </box>
171
+ ```
172
+
173
+ If both `left` and `right` are set, width is derived (same for `top`/`bottom`).
174
+
128
175
  `Box`, `Text`, and `Spacer` are convenience wrappers:
129
176
 
130
177
  ```jsx
131
- import { Box, Text, Spacer } from 'trend'
178
+ import { Box, Text, Spacer } from '@trendr/core'
132
179
  <Box style={{ flexDirection: 'row' }}><Text>hello</Text><Spacer /><Text>right</Text></Box>
133
180
  ```
134
181
 
@@ -157,7 +204,7 @@ Handlers fire in reverse registration order (innermost component first). Call `s
157
204
  Declarative key binding. Parses `'ctrl+s'`, `'alt+enter'`, etc.
158
205
 
159
206
  ```jsx
160
- import { useHotkey } from 'trend'
207
+ import { useHotkey } from '@trendr/core'
161
208
 
162
209
  useHotkey('ctrl+s', () => save())
163
210
  useHotkey('alt+enter', () => submit(), { when: () => isFocused })
@@ -165,10 +212,11 @@ useHotkey('alt+enter', () => submit(), { when: () => isFocused })
165
212
 
166
213
  ### useLayout
167
214
 
168
- Returns the component's computed layout rectangle.
215
+ Returns a live reference to the component's computed layout rectangle. Values update in-place each frame, including after terminal resize.
169
216
 
170
217
  ```jsx
171
- const { x, y, width, height } = useLayout()
218
+ const layout = useLayout()
219
+ // layout.x, layout.y, layout.width, layout.height, layout.contentHeight
172
220
  ```
173
221
 
174
222
  ### useResize
@@ -185,12 +233,75 @@ Used in [dashboard](examples/dashboard.jsx)
185
233
  useInterval(() => tick(), 1000) // auto-cleaned on unmount
186
234
  ```
187
235
 
236
+ ### useTimeout
237
+
238
+ Used in [timeout](examples/timeout.jsx)
239
+
240
+ Single-shot timer. Auto-cleaned on unmount.
241
+
242
+ ```jsx
243
+ useTimeout(() => hide(), 3000)
244
+ ```
245
+
246
+ ### useAsync
247
+
248
+ Used in [async](examples/async.jsx)
249
+
250
+ Async function to reactive signals.
251
+
252
+ ```jsx
253
+ import { useAsync } from '@trendr/core'
254
+
255
+ const { status, data, error, run } = useAsync(fetchUsers)
256
+
257
+ // status(): 'idle' | 'loading' | 'success' | 'error'
258
+ // data(): resolved value (null until success)
259
+ // error(): rejected error (null until error)
260
+ // run(): trigger the async function. forwards args: run(userId)
261
+ ```
262
+
263
+ Stale calls are discarded. Use `{ immediate: true }` to fire on mount:
264
+
265
+ ```jsx
266
+ const { status, data } = useAsync(fetchUsers, { immediate: true })
267
+ ```
268
+
269
+ ### useMouse
270
+
271
+ ```jsx
272
+ useMouse((event) => {
273
+ // event.action: 'press' | 'release' | 'drag' | 'scroll'
274
+ // event.button: 'left' | 'middle' | 'right' (press/release only)
275
+ // event.direction: 'up' | 'down' (scroll only)
276
+ // event.x, event.y: 0-based terminal coordinates
277
+ // event.stopPropagation(): prevent other handlers from receiving this event
278
+ })
279
+ ```
280
+
281
+ Mouse is enabled automatically. Built-in components support click, scroll wheel, and scrollbar dragging.
282
+
188
283
  ### useStdout
189
284
 
190
285
  ```jsx
191
286
  const stream = useStdout() // the output stream (process.stdout or custom)
192
287
  ```
193
288
 
289
+ ### useRepaint
290
+
291
+ Forces a full repaint. Useful after spawning an external process (e.g. `$EDITOR`).
292
+
293
+ ```jsx
294
+ import { useRepaint, useStdout, exitAltScreen, showCursor, altScreen, hideCursor } from '@trendr/core'
295
+
296
+ const repaint = useRepaint()
297
+ const stdout = useStdout()
298
+
299
+ stdout.write(exitAltScreen + showCursor)
300
+ execSync(`${process.env.EDITOR} ${file}`, { stdio: 'inherit' })
301
+ stdout.write(altScreen + hideCursor)
302
+ repaint()
303
+ ```
304
+
194
305
  ### useTheme
195
306
 
196
307
  Returns the current theme object. See [Theming](#theming).
@@ -203,10 +314,10 @@ const { accent } = useTheme()
203
314
 
204
315
  Used in [explorer](examples/explorer.jsx), [chat](examples/chat.jsx), [modal-form](examples/modal-form.jsx), [components](examples/components.jsx), [focus-demo](examples/focus-demo.jsx), [layout](examples/layout.jsx)
205
316
 
206
- Manages focus across multiple interactive regions. You register named items in the order you want tab to cycle through them. The focus manager tracks which name is currently active - it doesn't know anything about your components or layout.
317
+ Register named items in tab order. The focus manager tracks which is active.
207
318
 
208
319
  ```jsx
209
- import { useFocus } from 'trend'
320
+ import { useFocus } from '@trendr/core'
210
321
 
211
322
  const fm = useFocus({ initial: 'input' })
212
323
 
@@ -216,30 +327,29 @@ fm.item('list') // tab stop 1
216
327
  fm.item('sidebar') // tab stop 2
217
328
  ```
218
329
 
219
- Then wire each component's `focused` prop to `fm.is()`, which returns true when that name is the active one:
330
+ Wire `fm.is()` to each component's `focused` prop. Tab/shift-tab cycles through items.
220
331
 
221
332
  ```jsx
222
333
  <TextInput focused={fm.is('input')} />
223
334
  <List focused={fm.is('list')} />
224
335
  <Select focused={fm.is('sidebar')} />
225
- ```
226
-
227
- Tab and shift-tab cycle through the registered names. The focus manager handles the index - you just query it.
228
336
 
229
- ```jsx
230
- fm.is('input') // boolean - is 'input' the active name?
231
- fm.focus('list') // jump to 'list' programmatically
232
- fm.current() // the currently active name
337
+ fm.focus('list') // jump programmatically
338
+ fm.current() // the active name
233
339
  ```
234
340
 
235
- Groups let you nest multiple items under one tab stop, navigable with j/k or up/down within the group:
341
+ Groups nest multiple items under one tab stop:
236
342
 
237
343
  ```jsx
238
- fm.group('settings', { items: ['theme', 'autosave', 'format'], navigate: 'updown', wrap: true })
344
+ fm.group('settings', { items: ['theme', 'autosave', 'format'] })
239
345
  // fm.is('theme'), fm.is('autosave'), etc. work within the group
240
346
  ```
241
347
 
242
- Stack-based focus for modals and drills - push saves the current focus and switches, pop restores it:
348
+ Options:
349
+ - `navigate` - which keys move between group items: `'both'` (default, j/k and up/down), `'jk'`, or `'updown'`
350
+ - `wrap` - wrap around at ends (default `false`)
351
+
352
+ Stack-based focus for modals - push saves current focus, pop restores it:
243
353
 
244
354
  ```jsx
245
355
  fm.push('modal') // save current focus, switch to 'modal'
@@ -251,12 +361,17 @@ fm.pop() // restore previous focus
251
361
  Used in [chat](examples/chat.jsx), [modal-form](examples/modal-form.jsx), [components](examples/components.jsx)
252
362
 
253
363
  ```jsx
254
- import { useToast } from 'trend'
364
+ import { useToast } from '@trendr/core'
255
365
 
256
366
  const toast = useToast({
257
367
  duration: 2000, // ms, default 2000
258
368
  position: 'bottom-right', // see positions below
259
369
  margin: 1, // padding from screen edge, default 1
370
+ render: (message) => ( // optional custom render
371
+ <box style={{ bg: '#1E1E1E', paddingX: 1 }}>
372
+ <text style={{ color: '#9A9EA3' }}>{message}</text>
373
+ </box>
374
+ ),
260
375
  })
261
376
 
262
377
  toast('saved')
@@ -268,7 +383,7 @@ toast('saved')
268
383
 
269
384
  ## Components
270
385
 
271
- All interactive components accept a `focused` prop that controls whether they respond to keyboard input. When multiple components are on screen, only the focused one should capture keys - otherwise a keypress meant for a text input would also scroll a list. In practice you wire this to a focus manager:
386
+ All interactive components accept a `focused` prop. Wire it to a focus manager so only one component captures keys at a time:
272
387
 
273
388
  ```jsx
274
389
  const fm = useFocus({ initial: 'search' })
@@ -328,19 +443,79 @@ Scrollable list with keyboard navigation.
328
443
  selected={selectedIndex} // controlled, or omit for internal state
329
444
  onSelect={setIndex}
330
445
  focused={fm.is('list')}
331
- height={10} // defaults to layout height
446
+ scrollbar={true} // default false
447
+ scrolloff={2} // items of margin from edges when scrolling (default 2)
448
+ interactive={true} // handle keyboard input (default: same as focused)
332
449
  header={<text>title</text>}
450
+ headerHeight={1} // default 1, rows the header occupies
333
451
  renderItem={(item, { selected, index, focused }) => (
334
452
  <text style={{ bg: selected ? (focused ? accent : 'gray') : null }}>{item.name}</text>
335
453
  )}
336
454
  />
337
455
  ```
338
456
 
457
+ `itemHeight` enables multi-row items (tells scroll math how many rows each item occupies):
458
+
459
+ ```jsx
460
+ <List
461
+ items={data}
462
+ itemHeight={3}
463
+ renderItem={(item, { selected, focused }) => (
464
+ <box style={{ flexDirection: 'column', bg: selected ? accent : null }}>
465
+ <text style={{ bold: true }}>{item.name}</text>
466
+ <text style={{ color: 'gray' }}>{item.description}</text>
467
+ <text style={{ color: 'green' }}>{item.status}</text>
468
+ </box>
469
+ )}
470
+ />
471
+ ```
472
+
339
473
  Keys: j/k or up/down, g/G for top/bottom, ctrl-d/u half page, ctrl-f/b full page, pageup/pagedown.
340
474
 
475
+ ### PickList
476
+
477
+ Used in [pick-list](examples/pick-list.jsx)
478
+
479
+ Filterable list with live search. Text input at the top filters a scrollable list below. Navigate the list with up/down or ctrl-n/ctrl-p while typing.
480
+
481
+ ```jsx
482
+ <PickList
483
+ items={data}
484
+ focused={fm.is('search')}
485
+ placeholder="search..."
486
+ onSelect={item => {}} // Enter on highlighted item
487
+ onCancel={() => {}} // Escape
488
+ onChange={query => {}} // every keystroke in the filter
489
+ clearOnSelect={false} // reset filter on select (default false)
490
+ scrollbar={true} // default false
491
+ scrolloff={2} // items of margin from edges (default 2, inherited from List)
492
+ gap={1} // space between input and list (default 0)
493
+ filter={(query, item) => {}} // custom filter (default: case-insensitive includes)
494
+ />
495
+ ```
496
+
497
+ Multi-row items with `renderItem`, `itemHeight`, and `itemGap`:
498
+
499
+ ```jsx
500
+ <PickList
501
+ items={packages}
502
+ itemHeight={3}
503
+ itemGap={1}
504
+ renderItem={(pkg, { selected, focused }) => (
505
+ <box style={{ flexDirection: 'column', bg: selected ? accent : null, paddingX: 1 }}>
506
+ <text style={{ bold: true }}>{pkg.name}</text>
507
+ <text style={{ color: 'gray' }}>{pkg.desc}</text>
508
+ <text style={{ color: 'yellow' }}>{pkg.downloads}</text>
509
+ </box>
510
+ )}
511
+ />
512
+ ```
513
+
514
+ Keys: type to filter, up/down or ctrl-n/ctrl-p to navigate, enter to select, escape to cancel. All bash-style editing keys work (ctrl-a/e/u/k/w).
515
+
341
516
  ### Table
342
517
 
343
- Used in [components](examples/components.jsx)
518
+ Used in [components](examples/components.jsx), [custom-table](examples/custom-table.jsx)
344
519
 
345
520
  Column-based data table. Uses List internally.
346
521
 
@@ -355,6 +530,25 @@ Column-based data table. Uses List internally.
355
530
  selected={selectedRow}
356
531
  onSelect={setRow}
357
532
  focused={fm.is('table')}
533
+ separator={true} // horizontal rule below header
534
+ separatorChars={{ left: '', fill: '─', right: '' }} // customizable
535
+ />
536
+ ```
537
+
538
+ `renderItem` gives full control over row rendering while keeping column-aligned headers:
539
+
540
+ ```jsx
541
+ <Table
542
+ columns={columns}
543
+ data={rows}
544
+ selected={idx()}
545
+ onSelect={setIdx}
546
+ renderItem={(row, { selected, focused }) => (
547
+ <box style={{ flexDirection: 'row', bg: selected ? accent : null, paddingX: 1 }}>
548
+ <text style={{ color: selected ? 'black' : null, flexGrow: 1 }}>{row.name}</text>
549
+ <text style={{ color: selected ? 'black' : row.stale ? 'yellow' : 'gray' }}>{row.age}</text>
550
+ </box>
551
+ )}
358
552
  />
359
553
  ```
360
554
 
@@ -387,6 +581,8 @@ Dropdown selector. Can render inline or as overlay.
387
581
  focused={fm.is('color')}
388
582
  overlay={false} // true renders as floating overlay
389
583
  placeholder="pick one..."
584
+ openIcon="▲" // default ▲
585
+ closedIcon="▼" // default ▼
390
586
  renderItem={(item, { selected, index }) => <text>{item}</text>}
391
587
  style={{
392
588
  border: 'single', borderColor: 'green', bg: 'black',
@@ -408,10 +604,12 @@ Used in [modal-form](examples/modal-form.jsx), [components](examples/components.
408
604
  label="Enable feature"
409
605
  onChange={setChecked} // (newState: boolean) => void
410
606
  focused={fm.is('feature')}
607
+ checkedIcon="[✓]" // default '[x]'
608
+ uncheckedIcon="[ ]" // default '[ ]'
411
609
  />
412
610
  ```
413
611
 
414
- Keys: space or enter to toggle. Renders `[x]` / `[ ]`.
612
+ Keys: space or enter to toggle.
415
613
 
416
614
  ### Radio
417
615
 
@@ -426,21 +624,34 @@ Used in [modal-form](examples/modal-form.jsx), [components](examples/components.
426
624
  />
427
625
  ```
428
626
 
429
- Keys: j/k or up/down, enter/space to select. Renders circle symbols.
627
+ Keys: j/k or up/down, enter/space to select. Renders `●` / `○`.
430
628
 
431
629
  ### ProgressBar
432
630
 
433
- Used in [dashboard](examples/dashboard.jsx), [components](examples/components.jsx)
631
+ Used in [progress](examples/progress.jsx), [components](examples/components.jsx)
434
632
 
435
633
  ```jsx
436
634
  <ProgressBar
437
- value={0.65} // 0 to 1
438
- width={20} // characters, default 20
439
- color="red" // overrides theme accent
440
- label="65%" // optional text after bar
635
+ value={0.65} // 0 to 1
636
+ variant="thin" // 'thin' (default), 'block', 'ascii', 'braille'
637
+ color="red" // overrides theme accent
638
+ label="Installing" // optional label before bar
639
+ count="8/12" // optional count after percentage
640
+ percentage={true} // show percentage (default true)
641
+ width={30} // override bar width (default: fills available space)
441
642
  />
442
643
  ```
443
644
 
645
+ Variants:
646
+ - `thin` - clean `━` bar (default)
647
+ - `block` - thick `█░` blocks
648
+ - `ascii` - plain `[###---]`, works in any terminal
649
+ - `braille` - smooth `⣿` fill
650
+
651
+ ```
652
+ Installing ━━━━━━━━━━━━━━━━━━━━━━━━ 67% (8/12)
653
+ ```
654
+
444
655
  ### Spinner
445
656
 
446
657
  Used in [components](examples/components.jsx)
@@ -448,11 +659,59 @@ Used in [components](examples/components.jsx)
448
659
  ```jsx
449
660
  <Spinner
450
661
  label="loading..."
662
+ variant="dots" // 'dots' (default), 'line', 'circle', 'bounce', 'arrow', 'square', 'star'
451
663
  color="magenta" // overrides theme accent
452
664
  interval={80} // ms, default 80
665
+ frames={['a','b']} // custom frames (overrides variant)
666
+ />
667
+ ```
668
+
669
+ ### Task
670
+
671
+ Used in [task](examples/task.jsx)
672
+
673
+ Spinner while loading, checkmark on success, x on error. Built on `useAsync`.
674
+
675
+ ```jsx
676
+ <Task
677
+ run={() => fetchData()} // async function
678
+ label="Fetching data..." // shown while loading
679
+ successLabel="Done" // optional, shown on success (defaults to label)
680
+ errorLabel="Failed" // optional, shown on error (defaults to error message)
681
+ immediate={true} // fire on mount (default true)
682
+ icon={{ success: '+' }} // override icons per status
683
+ color="cyan" // override color (defaults vary by status)
453
684
  />
454
685
  ```
455
686
 
687
+ Multiple tasks render as a step list:
688
+
689
+ ```jsx
690
+ <Task run={() => install()} label="Installing..." successLabel="Installed" />
691
+ <Task run={() => build()} label="Building..." successLabel="Built" />
692
+ <Task run={() => test()} label="Testing..." successLabel="Tests passed" />
693
+ ```
694
+
695
+ ### Shimmer
696
+
697
+ Used in [shimmer](examples/shimmer.jsx)
698
+
699
+ Sliding highlight effect with gradient falloff.
700
+
701
+ ```jsx
702
+ <Shimmer
703
+ color="gray" // base text color (default 'gray')
704
+ highlight="cyan" // shimmer color (default: theme accent)
705
+ size={3} // width of bright center in chars (default 3)
706
+ gradient={3} // gradient tail length each side (default 3, 0 for hard edge)
707
+ duration={1000} // ms for one pass across the text (default 1000)
708
+ delay={500} // ms pause between passes (default 500)
709
+ reverse={false} // slide right to left (default false)
710
+ >
711
+ Loading resources...
712
+ </Shimmer>
713
+ ```
714
+
456
715
  ### Button
457
716
 
458
717
  Used in [modal-form](examples/modal-form.jsx)
@@ -492,7 +751,7 @@ Keys: escape to close.
492
751
 
493
752
  Used in [explorer](examples/explorer.jsx), [reader](examples/reader.jsx), [highlight](examples/highlight.jsx)
494
753
 
495
- Scrollable text viewer with optional scrollbar. Content can include ANSI escape sequences (SGR) - colors, bold, dim, etc. are parsed and rendered correctly. This means you can pipe output from any syntax highlighter (shiki, cli-highlight, etc.) directly into `content`.
754
+ Scrollable text viewer. ANSI escape sequences are parsed and rendered, so syntax highlighter output (shiki, cli-highlight, etc.) works directly.
496
755
 
497
756
  ```jsx
498
757
  <ScrollableText
@@ -501,12 +760,117 @@ Scrollable text viewer with optional scrollbar. Content can include ANSI escape
501
760
  scrollOffset={offset} // controlled, or omit for internal state
502
761
  onScroll={setOffset}
503
762
  scrollbar={true} // default false
504
- wrap={false} // default true, set false for horizontal scroll
763
+ wrap={false} // default true, false truncates long lines
764
+ thumbChar="█" // default █
765
+ trackChar="│" // default │
505
766
  />
506
767
  ```
507
768
 
508
769
  Keys: same as List (j/k, g/G, ctrl-d/u, ctrl-f/b, pageup/pagedown).
509
770
 
771
+ ### ScrollBox
772
+
773
+ Scrollable container for JSX children (vs ScrollableText which takes a string).
774
+
775
+ ```jsx
776
+ <ScrollBox
777
+ focused={fm.is('list')}
778
+ scrollbar={true} // default false
779
+ scrollOffset={offset} // controlled, or omit for internal state
780
+ onScroll={setOffset}
781
+ thumbChar="█" // default █
782
+ trackChar="│" // default │
783
+ style={{ flexGrow: 1 }} // pass-through style for the scroll container
784
+ >
785
+ {items.map(item => (
786
+ <text key={item.id}>{item.name}</text>
787
+ ))}
788
+ </ScrollBox>
789
+ ```
790
+
791
+ Keys: same as List and ScrollableText.
792
+
793
+ ### SplitPane
794
+
795
+ Paneled layout with shared borders and junction characters. Sizes use `fr` units or fixed values.
796
+
797
+ ```jsx
798
+ import { SplitPane } from '@trendr/core'
799
+
800
+ <SplitPane direction="row" sizes={[20, '2fr', '1fr']} border="round" borderColor="gray">
801
+ <box style={{ paddingX: 1 }}>
802
+ <text>sidebar</text>
803
+ </box>
804
+ <box style={{ paddingX: 1 }}>
805
+ <text>main content</text>
806
+ </box>
807
+ <box style={{ paddingX: 1 }}>
808
+ <text>detail</text>
809
+ </box>
810
+ </SplitPane>
811
+ ```
812
+
813
+ Props:
814
+ - `direction` - `'row'` (vertical dividers) or `'column'` (horizontal dividers)
815
+ - `sizes` - array of fixed numbers or `'Nfr'` strings. `[20, '1fr']` = 20 cols fixed + rest. `['1fr', '1fr']` = even split. Defaults to equal fractions.
816
+ - `border` - `'single'` | `'double'` | `'round'` | `'bold'`
817
+ - `borderColor` - color for border and dividers
818
+ - `borderEdges` - object with `top`, `right`, `bottom`, `left` booleans to render only specific sides. Omitted keys default to false.
819
+
820
+ Nesting works:
821
+
822
+ ```jsx
823
+ <SplitPane direction="column" sizes={['1fr', 8]} border="round">
824
+ <SplitPane direction="row" sizes={[20, '1fr']} border="round">
825
+ <box>nav</box>
826
+ <box>main</box>
827
+ </SplitPane>
828
+ <box>status</box>
829
+ </SplitPane>
830
+ ```
831
+
832
+ ## Animation
833
+
834
+ Physics-based animation. Animated values are signals that trigger re-renders.
835
+
836
+ ```jsx
837
+ import { useAnimated, spring, ease, decay } from '@trendr/core'
838
+
839
+ const x = useAnimated(0, spring()) // spring physics
840
+ x.set(100) // animate to 100
841
+ x() // read current value (tracks as signal)
842
+ x.snap(50) // jump instantly, no animation
843
+ ```
844
+
845
+ `useAnimated` is the hook version (auto-cleanup on unmount). `animated` is the standalone version for use outside components.
846
+
847
+ ### Interpolators
848
+
849
+ ```js
850
+ spring({ frequency: 2, damping: 0.3 }) // underdamped spring (bouncy)
851
+ spring({ damping: 1 }) // critically damped (no bounce)
852
+ ease(300) // 300ms ease-out-cubic
853
+ ease(500, linear) // 500ms linear
854
+ decay({ deceleration: 0.998 }) // momentum-based decay
855
+ ```
856
+
857
+ Switch interpolator mid-animation:
858
+
859
+ ```js
860
+ x.setInterpolator(ease(200))
861
+ x.set(newTarget)
862
+ ```
863
+
864
+ ### Easing functions
865
+
866
+ `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeOutElastic()`, `easeOutBounce()`
867
+
868
+ ### Tick callback
869
+
870
+ ```js
871
+ x.onTick((value) => { /* called each frame while animating */ })
872
+ ```
873
+
510
874
  ## Build
511
875
 
512
876
  Uses esbuild. JSX configured with `jsxImportSource: 'trend'`.