@stevecastaneda/paseo-progress 1.6.0 → 1.7.0

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
@@ -145,6 +145,11 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
145
145
 
146
146
  ## Changelog
147
147
 
148
+ ### 1.7.0
149
+
150
+ - [New] **See screenshots full screen.** In an image preview, press **Full screen** or click the image to fill the window with it. Esc takes you back to the preview, still open. The arrow keys still move between images. A long screenshot also gets **Fit to width**, which fills the width so you can scroll down it.
151
+ - [Fixed] **A preview that can't load says why right away.** A missing file, one too large to preview, or one outside the worktree showed a spinner for several seconds before the reason.
152
+
148
153
  ### 1.6.0
149
154
 
150
155
  - [New] **Read the skill in Settings.** Progress settings has an Agent Skills section. **View skill** shows the instructions agents get, and **Install skill** appears there when the skill isn't installed.
@@ -62,7 +62,7 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
62
62
  ))}
63
63
  <ShowMoreRow colors={colors} total={deliverables.length} paged={paged} />
64
64
  <StackedDialog colors={colors} now={now} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory, navigation }}
65
- root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)) } : null} />
65
+ root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)), fullScreen: false } : null} />
66
66
  </View>
67
67
  );
68
68
  }
@@ -5,6 +5,7 @@ import type { Dashboard, Question } from "../shared/dashboard";
5
5
  import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
6
6
  import { NarrowProvider } from "./narrow";
7
7
  import { BackButton, galleryFiles, PreviewContent } from "./preview";
8
+ import { previewKind } from "../shared/attachments";
8
9
  import { QuestionView, useCopy } from "./questions";
9
10
  import { TicketStoryView } from "./ticket-dialog";
10
11
  import { ProgressLanguageProvider } from "./language";
@@ -12,11 +13,13 @@ import { ProgressLanguageProvider } from "./language";
12
13
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
13
14
  type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
14
15
 
15
- // What the dialog is showing. A preview keeps the files beside it for Previous and Next.
16
+ // What the dialog is showing. A preview keeps the files beside it for Previous and Next,
17
+ // and whether its image fills the window. `leftFullScreen` puts focus back on the
18
+ // Full screen button; it lives here because leaving can remount the preview.
16
19
  export type Step =
17
20
  | { kind: "ticket"; id: string }
18
21
  | { kind: "question"; id: string }
19
- | { kind: "preview"; attachment: Attachment; group: Attachment[] };
22
+ | { kind: "preview"; attachment: Attachment; group: Attachment[]; fullScreen: boolean; leftFullScreen?: boolean };
20
23
 
21
24
  // Paseo gives each dialog its own backdrop, so opening a second one darkens
22
25
  // the screen while the first fades out; steps swap content inside this one instead.
@@ -43,7 +46,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
43
46
  const group = useRef<Attachment[]>([]);
44
47
  const push = (step: Step) => setSteps((current) => [...current, step]);
45
48
  const replaceTop = (step: Step) => setSteps((current) => [...current.slice(0, -1), step]);
46
- const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current) }) });
49
+ const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current), fullScreen: false }) });
47
50
  const openAttachment = (attachment: Attachment, from: Attachment[]) => {
48
51
  group.current = from;
49
52
  // A link opens elsewhere in Paseo; close first so it isn't left behind the dialog.
@@ -62,6 +65,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
62
65
  const ticket = top?.kind === "ticket" ? dashboard?.tickets.find((candidate) => candidate.id === top.id) : undefined;
63
66
  const question = top?.kind === "question" ? questions.find((candidate) => candidate.id === top.id) : undefined;
64
67
  const preview = top?.kind === "preview" ? top : undefined;
68
+ const setFullScreen = (on: boolean) => preview && replaceTop({ ...preview, fullScreen: on, leftFullScreen: !on });
65
69
  // The ticket or question it opened on went away (answered elsewhere, or a new run): close rather than show a blank dialog.
66
70
  const rootMissing = root !== null && (root.kind === "ticket" ? !dashboard?.tickets.some((candidate) => candidate.id === root.id)
67
71
  : root.kind === "question" ? !questions.some((candidate) => candidate.id === root.id) : false);
@@ -74,9 +78,16 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
74
78
 
75
79
  return (
76
80
  <Modal title={title} icon={<Icon name={icon} size={16} color={colors.foregroundMuted} />}
77
- open={Boolean(root)} onOpenChange={(next) => { if (!next) onClose(); }}>
78
- {/* One Modal.Content for every step: replacing it would re-present the sheet on phones. */}
79
- <Modal.Content scrollable={!(preview && previewLong)}>
81
+ open={Boolean(root)} onOpenChange={(next) => {
82
+ if (next) return;
83
+ // Esc leaves full screen first; the dialog stays open under it.
84
+ if (preview?.fullScreen) setFullScreen(false);
85
+ else onClose();
86
+ }}>
87
+ {/* One Modal.Content for every step: replacing it would re-present the sheet on phones.
88
+ Full screen turns its scrolling off. The image's fixed position reaches the window only
89
+ when no ancestor has a transform, and a scroll view has one. */}
90
+ <Modal.Content scrollable={!(preview && (previewLong || preview.fullScreen))}>
80
91
  <ProgressLanguageProvider>
81
92
  {/* A dialog has room for clock times, however narrow the panel that opened it. */}
82
93
  <NarrowProvider value={false}>
@@ -84,7 +95,8 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
84
95
  <PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
85
96
  onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }}
86
97
  onCopyPath={() => void opener.copyPath(preview.attachment)} onBack={back}
87
- gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong}
98
+ gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment, fullScreen: preview.fullScreen && previewKind(attachment.path ?? "") === "image", leftFullScreen: false }) }}
99
+ onLong={setPreviewLong} fullScreen={preview.fullScreen} leftFullScreen={preview.leftFullScreen === true} onFullScreen={setFullScreen}
88
100
  onOpenEntry={openAttachment} />
89
101
  ) : (
90
102
  <>
@@ -96,6 +96,10 @@ export const messages: ProgressMessages = {
96
96
  back: "Back",
97
97
  previousFile: "Previous file",
98
98
  nextFile: "Next file",
99
+ fullScreen: "Full screen",
100
+ exitFullScreen: "Exit full screen",
101
+ fitToWidth: "Fit to width",
102
+ fitToScreen: "Fit to screen",
99
103
  videoChanged: "the video changed while it was loading. Try again.",
100
104
  localHistory: "Run history stays on this computer.",
101
105
  historyStopped: "Runs will stay on this computer. Files already saved stay in the repo.",
@@ -96,6 +96,10 @@ export const messages: ProgressMessages = {
96
96
  back: "返回",
97
97
  previousFile: "上一个文件",
98
98
  nextFile: "下一个文件",
99
+ fullScreen: "全屏",
100
+ exitFullScreen: "退出全屏",
101
+ fitToWidth: "适应宽度",
102
+ fitToScreen: "适应屏幕",
99
103
  videoChanged: "视频在加载过程中发生了变化。请重试。",
100
104
  localHistory: "运行记录保存在这台电脑上。",
101
105
  historyStopped: "运行记录将保存在这台电脑上。已保存的文件会保留在仓库中。",
@@ -78,6 +78,10 @@ export interface ProgressMessages {
78
78
  back: string;
79
79
  previousFile: string;
80
80
  nextFile: string;
81
+ fullScreen: string;
82
+ exitFullScreen: string;
83
+ fitToWidth: string;
84
+ fitToScreen: string;
81
85
  videoChanged: string;
82
86
  localHistory: string;
83
87
  historyStopped: string;
@@ -59,7 +59,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
59
59
  </Text>
60
60
  )}
61
61
  <StackedDialog colors={colors} now={Date.now()} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory: root }}
62
- root={previewing ? { kind: "preview", ...previewing } : null} />
62
+ root={previewing ? { kind: "preview", ...previewing, fullScreen: false } : null} />
63
63
  <View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
64
64
  {/* A full 44pt touch target on phones; desktop keeps the compact button. */}
65
65
  <PressScale accessibilityRole="button" onPress={() => { close(); openPanel(); }}
@@ -1,8 +1,8 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
2
  import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
- import React, { useEffect, useRef } from "react";
5
- import { ActivityIndicator, Image, Platform, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
4
+ import React, { useEffect, useRef, useState } from "react";
5
+ import { ActivityIndicator, Image, Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
6
6
  import { ATTACHMENT_ROW, type Attachment, AttachmentList, isPreviewable } from "./attachments";
7
7
  import { errorText } from "./errors";
8
8
  import { folderAttachments, isFolder, previewKind } from "../shared/attachments";
@@ -39,7 +39,12 @@ type PreviewProps = {
39
39
 
40
40
  // `onLong` says when the file is too tall for the dialog, so the host turns off
41
41
  // its own scrolling and the file scrolls with the actions pinned below.
42
- export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong }: PreviewProps & { onLong(long: boolean): void }) {
42
+ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong, fullScreen, leftFullScreen, onFullScreen }: PreviewProps & {
43
+ onLong(long: boolean): void;
44
+ fullScreen: boolean;
45
+ leftFullScreen: boolean;
46
+ onFullScreen(on: boolean): void;
47
+ }) {
43
48
  const { messages: m } = useProgressLanguage();
44
49
  const fetchPreview = useRpc(previewDeliverable);
45
50
  const readPiece = useRpc(readDeliverablePiece);
@@ -60,7 +65,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
60
65
  // and so on, so a tall image made the dialog jump between two sizes until closed.
61
66
  queryFn: async () => {
62
67
  const data = await fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref });
63
- if (data.kind === "image") return { ...data, ratio: await imageRatio(data.dataUri) };
68
+ if (data.kind === "image") return { ...data, ...await imageSize(data.dataUri) };
64
69
  if (data.kind !== "video") return data;
65
70
  queryClient.setQueryData(loadingKey(target), 0);
66
71
  const video = await loadVideo({
@@ -76,6 +81,9 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
76
81
  // Images arrive as data URIs up to ~13 MB and videos run to 200 MB; let them
77
82
  // go soon after the dialog moves on.
78
83
  gcTime: 30_000,
84
+ // A file that's missing, too large or outside the worktree fails the same way
85
+ // every time, so the reason shows at once instead of after seconds of retries.
86
+ retry: false,
79
87
  });
80
88
  // Stepping to another file keeps this one on screen (dimmed) until the next loads,
81
89
  // so the dialog doesn't collapse to a spinner and back.
@@ -102,15 +110,39 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
102
110
  useArrowKeys(canStep ? step : null);
103
111
  // Next has focus when the preview opens, so Enter or Space moves on too.
104
112
  const nextButton = useRef<View>(null);
113
+ const fullScreenButton = useRef<View>(null);
114
+ // Leaving full screen puts focus back on the Full screen button without the ring
115
+ // Esc would draw, which read as if the arrows now acted on it. Set during render so
116
+ // the ring never paints, and from `leftFullScreen` because leaving can remount this.
117
+ const [quiet, setQuiet] = useState(leftFullScreen && !fullScreen);
118
+ const [wasFullScreen, setWasFullScreen] = useState(fullScreen);
119
+ if (wasFullScreen !== fullScreen) {
120
+ setWasFullScreen(fullScreen);
121
+ setQuiet(!fullScreen && leftFullScreen);
122
+ }
105
123
  useEffect(() => {
106
- (nextButton.current as { focus?(): void } | null)?.focus?.();
107
- }, []);
124
+ if (fullScreen) return;
125
+ const restore = leftFullScreen && fullScreenButton.current !== null;
126
+ if (!restore) setQuiet(false);
127
+ focus(restore ? fullScreenButton : nextButton);
128
+ }, [fullScreen]);
108
129
  // Screenshots and recordings vary from phone-tall to full-width; size to the real
109
130
  // file. While stepping, the dimmed one on screen keeps its shape until the next arrives.
110
131
  const aspectRatio = preview.data && "ratio" in preview.data && preview.data.ratio ? preview.data.ratio : 16 / 10;
111
132
  // A video fits the dialog whole, so the controls along its bottom stay in view
112
133
  // without scrolling. A tall image scrolls instead, to keep it readable.
113
- const { height: windowHeight } = useWindowDimensions();
134
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
135
+ // Phones and narrow windows get Paseo's bottom sheet, whose transforms would
136
+ // keep the full-screen layer inside it.
137
+ const fullScreenFits = Platform.OS === "web" && windowWidth >= COMPACT_BELOW;
138
+ // By the file, not the data: while stepping, the data is still the last file's.
139
+ const canFullScreen = fullScreenFits && previewKind(attachment.path ?? "") === "image" && preview.data?.kind === "image";
140
+ // While stepping, the last image fills the layer until the next arrives. A file
141
+ // that fails to load has no data at all, so full screen ends with it.
142
+ const showsLayer = fullScreenFits && preview.data?.kind === "image";
143
+ useEffect(() => {
144
+ if (fullScreen && !preview.isPending && !showsLayer) onFullScreen(false);
145
+ }, [fullScreen, preview.isPending, showsLayer]);
114
146
  const room = roomForContent(windowHeight);
115
147
  const maxMediaHeight = preview.data?.kind === "video" ? Math.min(640, Math.max(200, room)) : 640;
116
148
  const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
@@ -152,7 +184,11 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
152
184
  dimmed={stepping} onOpen={onOpenEntry} />
153
185
  ) : null}
154
186
  {preview.data?.kind === "image" ? (
155
- <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
187
+ // The Full screen button is the keyboard's way in, so the image isn't a second tab stop.
188
+ <Pressable disabled={!canFullScreen} focusable={false} onPress={() => onFullScreen(true)}
189
+ style={{ width: "100%", cursor: canFullScreen ? "zoom-in" as unknown as ViewStyle["cursor"] : undefined }}>
190
+ <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
191
+ </Pressable>
156
192
  ) : null}
157
193
  {preview.data?.kind === "video" ? <VideoPlayer video={preview.data.video} label={attachment.title} style={mediaStyle} /> : null}
158
194
  </>
@@ -182,28 +218,136 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
182
218
  {onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
183
219
  </View>
184
220
  {canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
185
- <View style={{ flex: 1, alignItems: "flex-end" }}>
221
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
222
+ {canFullScreen ? (
223
+ // An inline outline beats Paseo's :focus-visible ring.
224
+ <DialogButton ref={fullScreenButton} colors={colors} icon="Maximize2" accessibilityLabel={m.fullScreen} onPress={() => onFullScreen(true)}
225
+ outerStyle={quiet ? { outlineWidth: 0 } : undefined} onBlur={() => setQuiet(false)} />
226
+ ) : null}
186
227
  {canOpen ? <DialogButton colors={colors} icon="ExternalLink" label={m.open} accessibilityLabel={m.openDefault} onPress={onOpenOnHost} /> : null}
187
228
  </View>
188
229
  </View>
189
230
  );
190
- return long ? (
231
+ const layerShown = fullScreen && showsLayer;
232
+ // An SVG stays sharp at any size, so only a bitmap stops at its own pixels. An
233
+ // icon's SVG can say it's 24px wide.
234
+ const fullScreenLayer = layerShown && preview.data?.kind === "image" ? (
235
+ <FullScreenImage title={attachment.title} uri={preview.data.dataUri} ratio={aspectRatio} dimmed={stepping} onExit={() => onFullScreen(false)}
236
+ naturalWidth={preview.data.dataUri.startsWith("data:image/svg+xml") ? null : preview.data.width} />
237
+ ) : null;
238
+ // Paseo's focus trap counts the controls under the layer, so Shift+Tab would
239
+ // reach a hidden Open. Inert takes them out until full screen ends.
240
+ const body = useRef<View>(null);
241
+ useEffect(() => {
242
+ if (!layerShown) return;
243
+ const element = body.current as unknown as { setAttribute?(name: string, value: string): void; removeAttribute?(name: string): void } | null;
244
+ element?.setAttribute?.("inert", "");
245
+ return () => element?.removeAttribute?.("inert");
246
+ }, [layerShown]);
247
+ return (
191
248
  <>
192
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
193
- <View style={{ marginTop: 8 }}>{path}</View>
194
- {actions}
249
+ {long ? (
250
+ <View ref={body} style={{ flex: 1 }}>
251
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
252
+ <View style={{ marginTop: 8 }}>{path}</View>
253
+ {actions}
254
+ </View>
255
+ ) : (
256
+ <View ref={body} style={{ gap: 20 }}>
257
+ <View style={{ gap: 8 }}>
258
+ <View style={{ gap: 12 }}>{content}</View>
259
+ {path}
260
+ </View>
261
+ {actions}
262
+ </View>
263
+ )}
264
+ {fullScreenLayer}
195
265
  </>
196
- ) : (
197
- <View style={{ gap: 20 }}>
198
- <View style={{ gap: 8 }}>
199
- <View style={{ gap: 12 }}>{content}</View>
200
- {path}
266
+ );
267
+ }
268
+
269
+ const FULL_SCREEN_MARGIN = 24;
270
+ const FULL_SCREEN_BAR = 52;
271
+
272
+ // The image over the whole window, never past its own pixels. It fits whole, or a
273
+ // long one can fill the width and scroll.
274
+ function FullScreenImage({ title, uri, naturalWidth, ratio, dimmed, onExit }: { title: string; uri: string; naturalWidth: number | null; ratio: number; dimmed: boolean; onExit(): void }) {
275
+ const { messages: m } = useProgressLanguage();
276
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
277
+ const closeButton = useRef<View>(null);
278
+ const fitButton = useRef<View>(null);
279
+ const scrollArea = useRef<React.ComponentRef<typeof ScrollView>>(null);
280
+ useEffect(() => focus(closeButton), []);
281
+ const [fit, setFit] = useState<"screen" | "width">("screen");
282
+ const room = windowHeight - 2 * FULL_SCREEN_MARGIN - FULL_SCREEN_BAR;
283
+ const widthFit = Math.max(0, Math.min(naturalWidth ?? Infinity, windowWidth - 2 * FULL_SCREEN_MARGIN));
284
+ const screenFit = Math.min(widthFit, room * ratio);
285
+ // Only an image that fills the width at least half again as large as it fits
286
+ // whole gets the choice. An ordinary window screenshot gains a little and reads
287
+ // fine whole; a phone screenshot or a long page doesn't.
288
+ const long = widthFit >= screenFit * 1.5;
289
+ const scrolls = long && fit === "width";
290
+ const toggle = () => setFit(scrolls ? "screen" : "width");
291
+ // Fit to width hands the keys to the scroll area, so the arrows, Page Up and
292
+ // Down, Home, End and Space scroll it. When the area goes, focus goes back to the bar.
293
+ const scrolled = useRef(false);
294
+ useEffect(() => {
295
+ if (scrolls) focus(scrollArea);
296
+ else if (scrolled.current) focus(long ? fitButton : closeButton);
297
+ scrolled.current = scrolls;
298
+ }, [scrolls, uri]);
299
+ const light = "rgba(255, 255, 255, 0.85)";
300
+ const barButton = ({ pressed, hovered }: { pressed: boolean; hovered: boolean }) => ({
301
+ height: 36, alignItems: "center", justifyContent: "center", borderRadius: 6,
302
+ backgroundColor: pressed ? "rgba(255, 255, 255, 0.16)" : hovered ? "rgba(255, 255, 255, 0.1)" : "transparent",
303
+ }) as const;
304
+ // A press on the image stops there, so only the black around it exits.
305
+ // The fit button is the keyboard's way to the toggle, so the image isn't a tab stop.
306
+ const area = (
307
+ <Pressable accessible={false} focusable={false} onPress={onExit}
308
+ style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", padding: FULL_SCREEN_MARGIN }}>
309
+ <Pressable accessible={false} focusable={false} onPress={long ? toggle : () => {}}
310
+ style={{ cursor: (long ? (scrolls ? "zoom-out" : "zoom-in") : "default") as unknown as ViewStyle["cursor"] }}>
311
+ <Image accessibilityLabel={title} source={{ uri }} resizeMode="contain"
312
+ style={{ width: scrolls ? widthFit : screenFit, aspectRatio: ratio, borderRadius: 6, borderWidth: 1, borderColor: "rgba(255, 255, 255, 0.1)", opacity: dimmed ? 0.6 : 1 }} />
313
+ </Pressable>
314
+ </Pressable>
315
+ );
316
+ return (
317
+ <View style={{ position: "fixed" as unknown as ViewStyle["position"], top: 0, right: 0, bottom: 0, left: 0, zIndex: 1000, backgroundColor: "black" }}>
318
+ <View style={{ height: FULL_SCREEN_BAR, flexDirection: "row", alignItems: "center", gap: 12, paddingStart: FULL_SCREEN_MARGIN, paddingEnd: 12 }}>
319
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: light, fontSize: 13, lineHeight: 18 }}>{title}</Text>
320
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
321
+ {long ? (
322
+ <PressScale ref={fitButton} accessibilityRole="button" onPress={toggle}
323
+ style={(state) => ({ ...barButton(state), flexDirection: "row", gap: 6, paddingHorizontal: 10 })}>
324
+ <Icon name={scrolls ? "Shrink" : "MoveHorizontal"} size={16} color={light} />
325
+ <Text style={{ color: light, fontSize: 13, lineHeight: 18 }}>{scrolls ? m.fitToScreen : m.fitToWidth}</Text>
326
+ </PressScale>
327
+ ) : null}
328
+ <PressScale ref={closeButton} accessibilityRole="button" accessibilityLabel={m.exitFullScreen} onPress={onExit}
329
+ style={(state) => ({ ...barButton(state), width: 36 })}>
330
+ <Icon name="X" size={18} color={light} />
331
+ </PressScale>
332
+ </View>
201
333
  </View>
202
- {actions}
334
+ {/* Keyed so the next image starts at its top. The fit button stays the visible
335
+ keyboard control, so the scroll area draws no focus ring. */}
336
+ {scrolls ? (
337
+ <ScrollView key={uri} ref={scrollArea} focusable style={{ flex: 1, outlineWidth: 0 }} contentContainerStyle={{ flexGrow: 1 }}>{area}</ScrollView>
338
+ ) : area}
203
339
  </View>
204
340
  );
205
341
  }
206
342
 
343
+ // Only views on web have focus(); elsewhere this does nothing.
344
+ function focus(ref: React.RefObject<unknown>) {
345
+ (ref.current as { focus?(): void } | null)?.focus?.();
346
+ }
347
+
348
+ // Paseo draws dialogs as bottom sheets below this window width.
349
+ const COMPACT_BELOW = 720;
350
+
207
351
  // Width left for content: Paseo's 520px dialog less its 24px padding each side.
208
352
  const CONTENT_WIDTH = 472;
209
353
  // Characters of 12px monospace per line of the text box (10px padding each side).
@@ -232,11 +376,12 @@ function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { co
232
376
  );
233
377
  }
234
378
 
235
- // An image's width over its height, or null if it can't be read. Read from the data
236
- // because onLoad doesn't report a size for data URIs on every platform.
237
- function imageRatio(uri: string): Promise<number | null> {
379
+ // An image's pixel width and its width over its height, or nulls if they can't be read.
380
+ // Read from the data because onLoad doesn't report a size for data URIs on every platform.
381
+ function imageSize(uri: string): Promise<{ width: number | null; ratio: number | null }> {
238
382
  return new Promise((resolve) => {
239
- Image.getSize(uri, (width, height) => resolve(width && height ? width / height : null), () => resolve(null));
383
+ Image.getSize(uri, (width, height) => resolve(width && height ? { width, ratio: width / height } : { width: null, ratio: null }),
384
+ () => resolve({ width: null, ratio: null }));
240
385
  });
241
386
  }
242
387
 
@@ -248,9 +393,9 @@ function roomForContent(windowHeight: number): number {
248
393
  }
249
394
 
250
395
  // A dialog footer button: 13px label and 14px icon, 30px tall. Icon-only is a 30px square.
251
- export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, outerStyle, ref }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; outerStyle?: ViewStyle; ref?: React.Ref<View> }) {
396
+ export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, onBlur, outerStyle, ref }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; onBlur?(): void; outerStyle?: ViewStyle; ref?: React.Ref<View> }) {
252
397
  return (
253
- <PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
398
+ <PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} onBlur={onBlur} outerStyle={outerStyle}
254
399
  style={({ pressed }) => ({
255
400
  flexDirection: "row", alignItems: "center", gap: 6,
256
401
  paddingVertical: label ? 6 : 8, paddingLeft: label ? 8 : 8, paddingRight: label ? 10 : 8,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
5
5
  "license": "MIT",
6
6
  "author": "Steve Castaneda",