@opencode/ui 2.0.30 → 2.0.31

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.
@@ -20,6 +20,7 @@ export declare function useDialogLayer(): {
20
20
  } | undefined;
21
21
  export declare function DialogProvider(props: ParentProps): JSX.Element;
22
22
  export declare function useDialog(): {
23
+ /** The top dialog that is open. One that has started closing no longer counts, though it waits to be disposed. */
23
24
  readonly active: Active | undefined;
24
25
  /**
25
26
  * id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode/ui",
3
- "version": "2.0.30",
3
+ "version": "2.0.31",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,17 +7,23 @@
7
7
 
8
8
  [data-slot="animated-number-value"] {
9
9
  display: inline-flex;
10
- flex-direction: row-reverse;
10
+ /* Digits keep numeric order in RTL documents. */
11
+ direction: ltr;
11
12
  align-items: baseline;
12
13
  justify-content: flex-end;
13
14
  line-height: inherit;
14
15
  width: var(--animated-number-width, 1ch);
15
16
  overflow: hidden;
17
+ }
18
+
19
+ [data-slot="animated-number-value"][data-resizing="true"] {
16
20
  transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
17
21
  }
18
22
 
19
23
  [data-slot="animated-number-digit"] {
20
24
  display: inline-grid;
25
+ /* Digits outside the width are clipped at the start edge, not squeezed. */
26
+ flex-shrink: 0;
21
27
  width: 1ch;
22
28
  height: 1em;
23
29
  line-height: 1em;
@@ -56,6 +62,11 @@
56
62
  line-height: 1em;
57
63
  }
58
64
 
65
+ [data-slot="animated-number-static"]::before,
66
+ [data-slot="animated-number-cell"]::before {
67
+ content: attr(data-digit);
68
+ }
69
+
59
70
  [data-slot="animated-number-digit"][data-animating="true"] [data-slot="animated-number-static"] {
60
71
  visibility: hidden;
61
72
  }
@@ -63,8 +74,10 @@
63
74
  [data-slot="animated-number-strip"] {
64
75
  display: inline-flex;
65
76
  flex-direction: column;
66
- margin-top: calc(var(--animated-number-offset, 10) * -1em);
67
- transition-property: margin-top;
77
+ /* Not transform: a composited roll lands on whole pixels, then the digit shifts as it settles. */
78
+ position: relative;
79
+ top: calc(var(--animated-number-offset, 10) * -1em);
80
+ transition-property: top;
68
81
  transition-duration: var(--animated-number-duration, 560ms);
69
82
  transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
70
83
  }
@@ -76,7 +89,7 @@
76
89
  }
77
90
 
78
91
  @media (prefers-reduced-motion: reduce) {
79
- [data-component="animated-number"] [data-slot="animated-number-value"] {
92
+ [data-component="animated-number"] [data-slot="animated-number-value"][data-resizing] {
80
93
  transition-duration: 0ms;
81
94
  }
82
95
 
@@ -1,4 +1,4 @@
1
- import { For, Index, createEffect, createMemo, on } from "solid-js"
1
+ import { For, createEffect, createMemo, on } from "solid-js"
2
2
  import { createStore } from "solid-js/store"
3
3
 
4
4
  const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
@@ -26,6 +26,7 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
26
26
  const step = () => state.step
27
27
  const animating = () => state.animating
28
28
  let last = props.value
29
+ let strip: HTMLSpanElement | undefined
29
30
 
30
31
  createEffect(
31
32
  on(
@@ -34,15 +35,19 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
34
35
  const delta = spin(last, next, props.direction)
35
36
  last = next
36
37
 
37
- if (!delta) {
38
- setState("animating", false)
39
- setState("step", next + 10)
38
+ // An unchanged digit keeps any roll it is in the middle of.
39
+ if (!delta) return
40
+
41
+ const target = step() + delta
42
+
43
+ // A detached digit never finishes a roll, and rapid changes can run off the track, so both settle at once.
44
+ if (!strip?.isConnected || target < 0 || target >= TRACK.length) {
45
+ setState({ animating: false, step: next + 10 })
40
46
 
41
47
  return
42
48
  }
43
49
 
44
- setState("animating", true)
45
- setState("step", (value) => value + delta)
50
+ setState({ animating: true, step: target })
46
51
  },
47
52
  { defer: true },
48
53
  ),
@@ -50,8 +55,9 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
50
55
 
51
56
  return (
52
57
  <span data-slot="animated-number-digit" data-animating={animating() ? "true" : "false"}>
53
- <span data-slot="animated-number-static">{props.value}</span>
58
+ <span data-slot="animated-number-static" data-digit={props.value} />
54
59
  <span
60
+ ref={strip}
55
61
  data-slot="animated-number-strip"
56
62
  onTransitionEnd={() => {
57
63
  setState("animating", false)
@@ -62,7 +68,7 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
62
68
  "--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
63
69
  }}
64
70
  >
65
- <For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
71
+ <For each={TRACK}>{(value) => <span data-slot="animated-number-cell" data-digit={value} />}</For>
66
72
  </span>
67
73
  </span>
68
74
  )
@@ -75,13 +81,18 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
75
81
  return Math.max(0, Math.round(props.value))
76
82
  })
77
83
 
78
- const [state, setState] = createStore({
84
+ // A spare leading zero waits outside the clipped width to roll in. A digit that leaves stays until the width has
85
+ // shrunk past it, so it rolls to zero instead of vanishing.
86
+ const [state, setState] = createStore<{ value: number; direction: 1 | -1; places: number; resizing: boolean }>({
79
87
  value: target(),
80
- direction: 1 as 1 | -1,
88
+ direction: 1,
89
+ places: String(target()).length + 1,
90
+ resizing: false,
81
91
  })
82
92
 
83
93
  const value = () => state.value
84
94
  const direction = () => state.direction
95
+ let box: HTMLSpanElement | undefined
85
96
 
86
97
  createEffect(
87
98
  on(
@@ -91,8 +102,22 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
91
102
 
92
103
  if (next === current) return
93
104
 
94
- setState("direction", next > current ? 1 : -1)
95
- setState("value", next)
105
+ const digits = String(next).length
106
+
107
+ // A detached number cannot ease its width and would never hear it end, so it settles at once.
108
+ if (!box?.isConnected) {
109
+ setState({ direction: next > current ? 1 : -1, value: next, places: digits + 1, resizing: false })
110
+
111
+ return
112
+ }
113
+
114
+ // Only a change to the digit count eases the width, so a late font swap or zoom never animates it.
115
+ setState({
116
+ direction: next > current ? 1 : -1,
117
+ value: next,
118
+ places: Math.max(state.places, digits + 1),
119
+ resizing: state.resizing || digits !== String(current).length,
120
+ })
96
121
  },
97
122
  { defer: true },
98
123
  ),
@@ -100,22 +125,26 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
100
125
 
101
126
  const label = createMemo(() => value().toString())
102
127
 
103
- const digits = createMemo(() =>
104
- Array.from(label(), (char) => {
105
- const code = char.charCodeAt(0) - 48
106
-
107
- if (code < 0 || code > 9) return 0
108
-
109
- return code
110
- }).reverse(),
111
- )
112
-
113
- const width = createMemo(() => `${digits().length}ch`)
128
+ // Place values, most significant first. Keying by place keeps each digit rolling as digits appear and leave.
129
+ const places = createMemo(() => Array.from({ length: state.places }, (_, index) => state.places - 1 - index))
114
130
 
115
131
  return (
116
- <span data-component="animated-number" class={props.class} aria-label={label()}>
117
- <span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
118
- <Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
132
+ <span data-component="animated-number" class={props.class}>
133
+ {/* The value is the only text: screen readers and copy read it once, in order. CSS draws the digits. */}
134
+ <span class="sr-only">{label()}</span>
135
+ <span
136
+ ref={box}
137
+ data-slot="animated-number-value"
138
+ data-resizing={state.resizing ? "true" : "false"}
139
+ aria-hidden="true"
140
+ style={{ "--animated-number-width": `${label().length}ch` }}
141
+ onTransitionEnd={(event) => {
142
+ if (event.target === event.currentTarget) setState({ places: label().length + 1, resizing: false })
143
+ }}
144
+ >
145
+ <For each={places()}>
146
+ {(place) => <Digit value={Math.floor(value() / 10 ** place) % 10} direction={direction()} />}
147
+ </For>
119
148
  </span>
120
149
  </span>
121
150
  )
@@ -47,6 +47,9 @@ function init() {
47
47
  const closing = new Map<string, ReturnType<typeof setTimeout> | undefined>()
48
48
  // The same ids, reactive, so the top dialog that stays open owns the focus trap during an exit animation.
49
49
  const [exiting, setExiting] = createSignal<ReadonlySet<string>>(new Set())
50
+ // The top dialog that stays open. A dialog without an exit animation leaves the DOM as soon as it starts closing, so
51
+ // it stops being the active dialog then, not when it is disposed.
52
+ const top = () => stack().findLast((item) => !exiting().has(item.id))
50
53
  const lock = { value: false }
51
54
  const state = { disposed: false }
52
55
 
@@ -161,7 +164,7 @@ function init() {
161
164
 
162
165
  return (
163
166
  <Kobalte
164
- modal={stack().findLast((item) => !exiting().has(item.id))?.id === id}
167
+ modal={top()?.id === id}
165
168
  open={!closing()}
166
169
  onOpenChange={(open: boolean) => {
167
170
  if (!open) dismiss(id)
@@ -231,6 +234,7 @@ function init() {
231
234
 
232
235
  return {
233
236
  stack,
237
+ top,
234
238
  close,
235
239
  show,
236
240
  push,
@@ -302,8 +306,9 @@ export function useDialog() {
302
306
  }
303
307
 
304
308
  return {
309
+ /** The top dialog that is open. One that has started closing no longer counts, though it waits to be disposed. */
305
310
  get active() {
306
- return ctx.stack().at(-1)
311
+ return ctx.top()
307
312
  },
308
313
  /**
309
314
  * id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
@@ -271,7 +271,6 @@
271
271
 
272
272
  #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
273
273
  [data-slot="tabs-list"] {
274
- height: 52px;
275
274
  padding-inline-end: 12px;
276
275
  gap: 8px;
277
276
  }
@@ -134,59 +134,93 @@
134
134
  inset: 0;
135
135
  }
136
136
 
137
- [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"] {
138
- box-sizing: border-box;
137
+ /* Every row ends in the change letter or a blank end that sticks inside the pane while long rows
138
+ scroll. It fades the label out over 16px like tab titles and redraws the row's rounded end, so a
139
+ row that runs past the pane edge looks like a row that fits. */
140
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"]) {
141
+ --file-tree-v2-end-inset: 8px;
142
+
139
143
  flex: none;
144
+ align-self: stretch;
140
145
  position: sticky;
141
- inset-inline-end: 8px;
146
+ inset-inline-end: var(--file-tree-v2-end-inset);
142
147
  z-index: 1;
143
- display: flex;
144
- flex-direction: row;
145
- align-items: center;
146
- justify-content: center;
147
- width: 16px;
148
- height: 16px;
149
- font-size: 11px;
150
- font-weight: 530;
151
- line-height: 1;
152
- letter-spacing: -0.04px;
153
- text-align: center;
154
- text-transform: uppercase;
155
- font-variant-numeric: tabular-nums lining-nums;
156
- font-feature-settings:
157
- "tnum" on,
158
- "lnum" on;
159
- background:
160
- linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base);
161
148
  }
162
149
 
163
- [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::before {
150
+ [data-component="file-tree-v2"] [data-slot="file-tree-v2-end"] {
151
+ --file-tree-v2-end-inset: 10px;
152
+ }
153
+
154
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before,
155
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::after {
164
156
  content: "";
165
157
  position: absolute;
166
158
  inset-block: 0;
159
+ pointer-events: none;
160
+ }
161
+
162
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before {
167
163
  inset-inline-end: 100%;
168
164
  width: 16px;
169
- pointer-events: none;
170
165
  background:
171
166
  linear-gradient(to right, transparent, var(--file-tree-v2-row-overlay)),
172
167
  linear-gradient(to right, transparent, var(--v2-background-bg-base));
173
168
  }
174
169
 
175
- [data-component="file-tree-v2"]:dir(rtl) [data-slot="file-tree-v2-change"]::before {
170
+ [data-component="file-tree-v2"]:dir(rtl)
171
+ :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before {
176
172
  background:
177
173
  linear-gradient(to left, transparent, var(--file-tree-v2-row-overlay)),
178
174
  linear-gradient(to left, transparent, var(--v2-background-bg-base));
179
175
  }
180
176
 
181
- [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::after {
182
- content: "";
183
- position: absolute;
184
- inset-block: 0;
185
- inset-inline-start: 100%;
186
- width: 16px;
187
- pointer-events: none;
177
+ /* The row through its rounded end. The shadow paints the pane past it, outside its corners too. */
178
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::after {
179
+ inset-inline: 0 calc(-1 * var(--file-tree-v2-end-inset));
180
+ z-index: -1;
181
+ border-start-end-radius: 6px;
182
+ border-end-end-radius: 6px;
188
183
  background:
189
184
  linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base);
185
+ box-shadow: 8px 0 0 6px var(--v2-background-bg-base);
186
+ }
187
+
188
+ [data-component="file-tree-v2"]:dir(rtl) :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::after {
189
+ box-shadow: -8px 0 0 6px var(--v2-background-bg-base);
190
+ }
191
+
192
+ @keyframes file-tree-v2-end-fade {
193
+ to {
194
+ opacity: 0;
195
+ }
196
+ }
197
+
198
+ /* Scrolled to the end, every label is whole, so the fade clears. */
199
+ @supports (animation-timeline: scroll()) {
200
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before {
201
+ animation: file-tree-v2-end-fade linear both;
202
+ animation-timeline: scroll(nearest inline);
203
+ animation-range: calc(100% - 16px) 100%;
204
+ }
205
+ }
206
+
207
+ [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"] {
208
+ box-sizing: border-box;
209
+ display: flex;
210
+ flex-direction: row;
211
+ align-items: center;
212
+ justify-content: center;
213
+ width: 16px;
214
+ font-size: 11px;
215
+ font-weight: 530;
216
+ line-height: 1;
217
+ letter-spacing: -0.04px;
218
+ text-align: center;
219
+ text-transform: uppercase;
220
+ font-variant-numeric: tabular-nums lining-nums;
221
+ font-feature-settings:
222
+ "tnum" on,
223
+ "lnum" on;
190
224
  }
191
225
 
192
226
  [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] {