Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

### Fixes & Improvements

- The image viewer now opens with the whole image fitted to the screen on every device, instead of at 100%; images smaller than the screen stay at their real size. The zoom percentage is always relative to the image's real pixels, at every Interface Scale. Zoom out goes down to 10%, or further to the fitted size for very large images, in ×1.25 steps. **Fit to screen** and **1:1** (actual size) replace **Reset zoom**. Zooming keeps the centre of the view in place, and a zoomed-in image can now be scrolled to every edge; before, the parts outside the starting size were cut off. The viewer covers exactly the window at Compact, Comfortable and Large Interface Scale, and its buttons follow the **Touch Targets** setting, wrapping on narrow screens instead of being clipped.
- Balance the composer icons on phones and narrow toolbars with a slightly smaller effort-level icon and a larger Slow-mode snail, while keeping menu icon sizes unchanged.
- Keep the model picker on the left and reasoning effort, Fast, Slow, microphone, and Send/Stop controls in a right-aligned composer group, including wrapped mobile rows.
- The Provider Usage panel now shows each account's email instead of omp's redacted form (`an*`); accounts without an email show as **Account N**. Only the email, plan and limits reach the browser.
Expand Down
34 changes: 29 additions & 5 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1287,9 +1287,11 @@ pre, code {
}

.image-lightbox-dialog {
width: 100vw;
/* Viewport units ignore the root zoom (Interface Scale): divide so the
viewer covers exactly the window and "fit" fits the visible area. */
width: calc(100vw / var(--ui-scale));
max-width: none;
height: 100dvh;
height: calc(100dvh / var(--ui-scale));
max-height: none;
margin: 0;
padding: 0;
Expand All @@ -1307,8 +1309,9 @@ pre, code {
}
.image-lightbox-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
gap: 6px 10px;
min-height: 40px;
padding: 7px 10px;
background: var(--bg-panel);
Expand All @@ -1326,12 +1329,15 @@ pre, code {
}
.image-lightbox-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 6px;
margin-left: auto;
}
.image-lightbox-stepper {
display: flex;
flex: none;
align-items: center;
height: 24px;
overflow: hidden;
Expand Down Expand Up @@ -1365,6 +1371,18 @@ pre, code {
border: 1px solid var(--border);
border-radius: 5px;
}
.image-lightbox-actual-size {
flex-basis: auto;
width: auto;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
}
.image-lightbox-icon-button[aria-pressed="true"] {
background: var(--bg-selected);
color: var(--text);
}
.image-lightbox-stepper button:hover:not(:disabled),
.image-lightbox-icon-button:hover {
background: var(--bg-hover);
Expand Down Expand Up @@ -1400,10 +1418,10 @@ pre, code {
}
.image-lightbox-dialog .image-lightbox-img {
display: block;
flex: none;
margin: auto;
max-width: none;
height: auto;
transition: transform var(--dur-fast) var(--ease-out-warm);
max-height: none;
}

@media (max-width: 480px) {
Expand All @@ -1419,6 +1437,12 @@ pre, code {
padding: 12px;
}
}
html[data-touch-targets="accessible"] .image-lightbox-stepper,
html[data-touch-targets="accessible"] :is(.image-lightbox-stepper button, .image-lightbox-icon-button, .image-lightbox-zoom-value) {
min-width: 44px;
height: 44px;
line-height: 44px;
Comment on lines +1440 to +1444

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reflow accessible controls on narrow scaled viewports

On a 360px-wide phone with Touch Targets set to Accessible and Interface Scale set to Large, the dialog has only about 300 unzoomed CSS pixels and the toolbar content area about 280, but these rules require roughly 288 pixels for the stepper, Fit, 1:1, Close, and their gaps even before the title. Since the stepper also has overflow: hidden and is shrinkable, its zoom controls are clipped; add a narrow responsive layout such as wrapping or a compact toolbar so all controls remain reachable.

AGENTS.md reference: AGENTS.md:L599-L607

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63bb4de: the toolbar and its action group now wrap, and the stepper no longer shrinks. On a 360×760 phone at Large scale with Accessible targets, all five buttons are fully inside the dialog at 44px.

}

/* File viewer toolbar */
.file-viewer-toolbar {
Expand Down
22 changes: 21 additions & 1 deletion components/ImageLightbox.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const jiti = createJiti(import.meta.url, {
});
const { MessageView } = await jiti.import("./MessageView.tsx");
const { MarkdownBody } = await jiti.import("./MarkdownBody.tsx");
const { ClickableImage } = await jiti.import("./ImageLightbox.tsx");
const { ClickableImage, fitZoom, stepZoom } = await jiti.import("./ImageLightbox.tsx");

test("user image blocks render as click-to-preview thumbnails", () => {
const html = renderToStaticMarkup(React.createElement(MessageView, {
Expand Down Expand Up @@ -113,3 +113,23 @@ test("whitespace-only text around an image link still unwraps the anchor", () =>
test("missing image sources render nothing", () => {
assert.equal(renderToStaticMarkup(React.createElement(ClickableImage, { src: "" })), "");
});

test("fitZoom fits big images to the viewport and keeps small ones at real size", () => {
// 6000×4000 in a 366×700 phone viewport: limited by width.
assert.equal(fitZoom({ width: 6000, height: 4000 }, { width: 366, height: 700 }), 0.061);
// Limited by height on a wide desktop.
assert.equal(fitZoom({ width: 1000, height: 2000 }, { width: 1800, height: 1000 }), 0.5);
assert.equal(fitZoom({ width: 200, height: 100 }, { width: 1800, height: 1000 }), 1);
// Not yet laid out or no intrinsic size: real size rather than 0 or Infinity.
assert.equal(fitZoom({ width: 6000, height: 4000 }, { width: 0, height: 0 }), 1);
assert.equal(fitZoom({ width: 0, height: 0 }, { width: 366, height: 700 }), 1);
});

test("stepZoom moves by ×1.25 down to min(fit, 10%) and up to 800%", () => {
assert.equal(stepZoom(1, 1, 1), 1.25);
assert.equal(stepZoom(1, -1, 1), 0.8);
assert.equal(stepZoom(0.061 * 1.25, -1, 0.061), 0.061);
assert.equal(stepZoom(0.061, -1, 0.061), 0.061, "cannot zoom out past the fitted size of a huge image");
assert.equal(stepZoom(0.11, -1, 0.5), 0.1);
assert.equal(stepZoom(7, 1, 0.5), 8);
});
120 changes: 102 additions & 18 deletions components/ImageLightbox.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
"use client";

import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type ImgHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { Maximize } from "lucide-react";
import { useI18n } from "@/lib/i18n";

const ZOOM_MIN = 0.25;
const ZOOM_MAX = 8;
const ZOOM_STEP = 0.25;
/** Lowest zoom when the fitted size is larger; big images can go below it down to their fit. */
const ZOOM_FLOOR = 0.1;
const ZOOM_FACTOR = 1.25;

interface Size { width: number; height: number }

/** Scale that fits `image` inside `box`; never above 1, so small images keep their real size. */
export function fitZoom(image: Size, box: Size): number {
if (image.width <= 0 || image.height <= 0 || box.width <= 0 || box.height <= 0) return 1;
return Math.min(1, box.width / image.width, box.height / image.height);
}

/** One zoom step in or out, clamped to [min(fit, 10%), 800%]. */
export function stepZoom(zoom: number, direction: 1 | -1, fit: number): number {
const next = direction > 0 ? zoom * ZOOM_FACTOR : zoom / ZOOM_FACTOR;
return Math.min(ZOOM_MAX, Math.max(Math.min(fit, ZOOM_FLOOR), next));
}

interface ClickableImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> {
/** Image source: string URL (data:, http(s):, blob:, /api/files/...) or Blob. */
Expand Down Expand Up @@ -74,9 +90,58 @@ export function ClickableImage({ src, alt, ...imgProps }: ClickableImageProps) {

function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; alt: string; onClose: () => void }) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [zoom, setZoom] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null);
// "fit" follows the window as it resizes; a number is a zoom the user chose.
const [zoom, setZoom] = useState<number | "fit">("fit");
const [natural, setNatural] = useState<Size | null>(null);
// Load finished either way: a broken image is shown as the browser renders it.
const [settled, setSettled] = useState(false);
const [box, setBox] = useState<Size | null>(null);
// Centre of the view as a fraction of the content, kept across a zoom change.
const anchorRef = useRef<{ x: number; y: number } | null>(null);
const [uiScale, setUiScale] = useState(1);
const { t } = useI18n();

useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
// Interface Scale zooms the root, so layout pixels are uiScale screen
// pixels. Measure in screen pixels so 100% is one image pixel per pixel.
const uiScale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--ui-scale")) || 1;
setUiScale(uiScale);
// contentRect excludes the viewport padding, so "fit" leaves the margin visible.
const observer = new ResizeObserver(([entry]) => {
setBox({ width: entry.contentRect.width * uiScale, height: entry.contentRect.height * uiScale });
});
observer.observe(viewport);
return () => observer.disconnect();
}, []);

const ready = natural !== null && box !== null;
const fit = ready ? fitZoom(natural, box) : 1;
const scale = zoom === "fit" ? fit : zoom;
const minZoom = Math.min(fit, ZOOM_FLOOR);

const zoomTo = (next: number | "fit") => {
const viewport = viewportRef.current;
if (viewport) {
anchorRef.current = {
x: (viewport.scrollLeft + viewport.clientWidth / 2) / viewport.scrollWidth,
y: (viewport.scrollTop + viewport.clientHeight / 2) / viewport.scrollHeight,
};
}
setZoom(next);
};

useLayoutEffect(() => {
const viewport = viewportRef.current;
const anchor = anchorRef.current;
if (!viewport || !anchor) return;
anchorRef.current = null;
viewport.scrollLeft = anchor.x * viewport.scrollWidth - viewport.clientWidth / 2;
viewport.scrollTop = anchor.y * viewport.scrollHeight - viewport.clientHeight / 2;
}, [scale]);

useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
Expand Down Expand Up @@ -123,20 +188,20 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"];
<div className="image-lightbox-stepper">
<button
type="button"
onClick={() => setZoom((value) => Math.max(ZOOM_MIN, value - ZOOM_STEP))}
disabled={zoom <= ZOOM_MIN}
onClick={() => zoomTo(stepZoom(scale, -1, fit))}
disabled={!ready || scale <= minZoom}
title={t("imagePreview.zoomOut")}
aria-label={t("imagePreview.zoomOut")}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<span className="image-lightbox-zoom-value">{Math.round(zoom * 100)}%</span>
<span className="image-lightbox-zoom-value" aria-hidden={!ready}>{ready ? `${Math.round(scale * 100)}%` : "…"}</span>
<button
type="button"
onClick={() => setZoom((value) => Math.min(ZOOM_MAX, value + ZOOM_STEP))}
disabled={zoom >= ZOOM_MAX}
onClick={() => zoomTo(stepZoom(scale, 1, fit))}
disabled={!ready || scale >= ZOOM_MAX}
title={t("imagePreview.zoomIn")}
aria-label={t("imagePreview.zoomIn")}
>
Expand All @@ -148,13 +213,22 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"];
<button
type="button"
className="image-lightbox-icon-button"
onClick={() => setZoom(1)}
title={t("imagePreview.resetZoom")}
aria-label={t("imagePreview.resetZoom")}
onClick={() => zoomTo("fit")}
aria-pressed={zoom === "fit"}
title={t("imagePreview.fitToScreen")}
aria-label={t("imagePreview.fitToScreen")}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" />
</svg>
<Maximize size={13} aria-hidden="true" />
</button>
<button
type="button"
className="image-lightbox-icon-button image-lightbox-actual-size"
onClick={() => zoomTo(1)}
aria-pressed={zoom === 1}
title={t("imagePreview.actualSize")}
aria-label={t("imagePreview.actualSize")}
>
1:1
</button>
<button
type="button"
Expand All @@ -170,6 +244,7 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"];
</div>
</div>
<div
ref={viewportRef}
className="image-lightbox-viewport"
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
Expand All @@ -180,9 +255,18 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"];
src={src}
alt={alt}
className="image-lightbox-img"
// `zoom` is non-standard (a no-op in Firefox < 126); transform
// scale is composited and supported everywhere.
style={{ transform: `scale(${zoom})` }}
onLoad={(event) => {
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
// Some engines report 0×0 for SVGs without an intrinsic size: show those unsized.
if (width > 0 && height > 0) setNatural({ width, height });
setSettled(true);
}}
onError={() => setSettled(true)}
// Real layout size, not transform: scale, so a zoomed-in image
// scrolls to every edge. Hidden until it can be sized to fit.
style={ready
? { width: natural.width * scale / uiScale, height: natural.height * scale / uiScale }
: settled ? undefined : { visibility: "hidden" }}
/>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,8 @@
"imagePreview.imageTitle": "Image",
"imagePreview.open": "Open image preview",
"imagePreview.openWithAlt": "Open image preview: {alt}",
"imagePreview.resetZoom": "Reset zoom",
"imagePreview.actualSize": "Actual size (100%)",
"imagePreview.fitToScreen": "Fit to screen",
"imagePreview.viewerLabel": "Image preview",
"imagePreview.zoomIn": "Zoom in",
"imagePreview.zoomOut": "Zoom out",
Expand Down
3 changes: 2 additions & 1 deletion lib/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,8 @@
"imagePreview.imageTitle": "画像",
"imagePreview.open": "画像プレビューを開く",
"imagePreview.openWithAlt": "画像プレビューを開く: {alt}",
"imagePreview.resetZoom": "ズームをリセット",
"imagePreview.actualSize": "実寸(100%)",
"imagePreview.fitToScreen": "画面に合わせる",
"imagePreview.viewerLabel": "画像プレビュー",
"imagePreview.zoomIn": "拡大",
"imagePreview.zoomOut": "縮小",
Expand Down
3 changes: 2 additions & 1 deletion lib/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,8 @@
"imagePreview.imageTitle": "图片",
"imagePreview.open": "打开图片预览",
"imagePreview.openWithAlt": "打开图片预览:{alt}",
"imagePreview.resetZoom": "重置缩放",
"imagePreview.actualSize": "实际大小(100%)",
"imagePreview.fitToScreen": "适应屏幕",
"imagePreview.viewerLabel": "图片预览",
"imagePreview.zoomIn": "放大",
"imagePreview.zoomOut": "缩小",
Expand Down