From ebe16a77a497ef055ff6e3dcb1eab4522accb772 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 7 Oct 2026 06:48:22 +0000 Subject: [PATCH 1/5] fix(image-viewer): open fitted to the screen, with Fit and 1:1 zoom The lightbox opened at 100% on every device and zoomed with a transform that did not grow the scroll area. Open fitted to the viewport (never upscaling small images), show zoom relative to real pixels, step by x1.25 down to min(10%, fit), size the image by layout so every edge scrolls into view, and replace Reset zoom with Fit to screen and 1:1. --- CHANGELOG.md | 1 + app/globals.css | 16 ++++++- components/ImageLightbox.test.mjs | 17 ++++++- components/ImageLightbox.tsx | 80 ++++++++++++++++++++++++------- lib/i18n/locales/en.json | 3 +- lib/i18n/locales/ja.json | 3 +- lib/i18n/locales/zh-CN.json | 3 +- 7 files changed, 101 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1739611..87271c85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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**. A zoomed-in image can now be scrolled to every edge; before, the parts outside the starting size were cut off. - 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. diff --git a/app/globals.css b/app/globals.css index 403544de2..a5ce08e15 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1365,6 +1365,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); @@ -1400,10 +1412,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) { diff --git a/components/ImageLightbox.test.mjs b/components/ImageLightbox.test.mjs index b75047344..199b6ceea 100644 --- a/components/ImageLightbox.test.mjs +++ b/components/ImageLightbox.test.mjs @@ -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, { @@ -113,3 +113,18 @@ 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("the viewer opens big images fitted to the screen 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); +}); + +test("zoom steps go below 25% down to the fit, and never past 10% for images that fit higher", () => { + 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); +}); diff --git a/components/ImageLightbox.tsx b/components/ImageLightbox.tsx index 1eeb6bcba..b586720ce 100644 --- a/components/ImageLightbox.tsx +++ b/components/ImageLightbox.tsx @@ -2,11 +2,27 @@ import { useEffect, 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, "src"> { /** Image source: string URL (data:, http(s):, blob:, /api/files/...) or Blob. */ @@ -74,9 +90,28 @@ export function ClickableImage({ src, alt, ...imgProps }: ClickableImageProps) { function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; alt: string; onClose: () => void }) { const dialogRef = useRef(null); - const [zoom, setZoom] = useState(1); + const viewportRef = useRef(null); + // "fit" follows the window as it resizes; a number is a zoom the user chose. + const [zoom, setZoom] = useState("fit"); + const [natural, setNatural] = useState(null); + const [box, setBox] = useState(null); const { t } = useI18n(); + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + // contentRect excludes the viewport padding, so "fit" leaves the margin visible. + const observer = new ResizeObserver(([entry]) => { + setBox({ width: entry.contentRect.width, height: entry.contentRect.height }); + }); + observer.observe(viewport); + return () => observer.disconnect(); + }, []); + + const fit = natural && box ? fitZoom(natural, box) : 1; + const scale = zoom === "fit" ? fit : zoom; + const minZoom = Math.min(fit, ZOOM_FLOOR); + useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -123,8 +158,8 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"];
- {Math.round(zoom * 100)}% + {natural ? `${Math.round(scale * 100)}%` : "…"} +
{ if (event.target === event.currentTarget) onClose(); @@ -180,9 +225,12 @@ 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) => setNatural({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })} + // Real layout size, not transform: scale, so a zoomed-in image + // scrolls to every edge. Hidden until its size is known. + style={natural + ? { width: natural.width * scale, height: natural.height * scale } + : { visibility: "hidden" }} />
diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 074c3e095..3ae7cdcb4 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -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", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 8408e7047..b3c31deac 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -768,7 +768,8 @@ "imagePreview.imageTitle": "画像", "imagePreview.open": "画像プレビューを開く", "imagePreview.openWithAlt": "画像プレビューを開く: {alt}", - "imagePreview.resetZoom": "ズームをリセット", + "imagePreview.actualSize": "実寸(100%)", + "imagePreview.fitToScreen": "画面に合わせる", "imagePreview.viewerLabel": "画像プレビュー", "imagePreview.zoomIn": "拡大", "imagePreview.zoomOut": "縮小", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index b6de27b5f..ccb24e865 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -768,7 +768,8 @@ "imagePreview.imageTitle": "图片", "imagePreview.open": "打开图片预览", "imagePreview.openWithAlt": "打开图片预览:{alt}", - "imagePreview.resetZoom": "重置缩放", + "imagePreview.actualSize": "实际大小(100%)", + "imagePreview.fitToScreen": "适应屏幕", "imagePreview.viewerLabel": "图片预览", "imagePreview.zoomIn": "放大", "imagePreview.zoomOut": "缩小", From 467d96da5a2e00405a227106984af2c18d703081 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 7 Oct 2026 06:56:27 +0000 Subject: [PATCH 2/5] fix(image-viewer): keep the view centred on zoom; show broken images Keep the centre of the view across zoom changes, show an image that fails to load (or has no intrinsic size) instead of leaving it hidden, size only once both the image and the viewport are measured, and give the viewer buttons 44px targets under the Touch Targets setting. --- CHANGELOG.md | 2 +- app/globals.css | 6 ++++ components/ImageLightbox.test.mjs | 9 +++-- components/ImageLightbox.tsx | 57 ++++++++++++++++++++++++------- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87271c85c..97e76997a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +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. 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**. A zoomed-in image can now be scrolled to every edge; before, the parts outside the starting size were cut off. +- 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. 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's buttons follow the **Touch Targets** setting. - 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. diff --git a/app/globals.css b/app/globals.css index a5ce08e15..4a6b2bf66 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1431,6 +1431,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; +} /* File viewer toolbar */ .file-viewer-toolbar { diff --git a/components/ImageLightbox.test.mjs b/components/ImageLightbox.test.mjs index 199b6ceea..3d78e4f27 100644 --- a/components/ImageLightbox.test.mjs +++ b/components/ImageLightbox.test.mjs @@ -114,15 +114,20 @@ test("missing image sources render nothing", () => { assert.equal(renderToStaticMarkup(React.createElement(ClickableImage, { src: "" })), ""); }); -test("the viewer opens big images fitted to the screen and keeps small ones at real size", () => { +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("zoom steps go below 25% down to the fit, and never past 10% for images that fit higher", () => { +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); diff --git a/components/ImageLightbox.tsx b/components/ImageLightbox.tsx index b586720ce..a6fdeb47b 100644 --- a/components/ImageLightbox.tsx +++ b/components/ImageLightbox.tsx @@ -1,6 +1,6 @@ "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"; @@ -94,7 +94,11 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; // "fit" follows the window as it resizes; a number is a zoom the user chose. const [zoom, setZoom] = useState("fit"); const [natural, setNatural] = useState(null); + // Load finished either way: a broken image is shown as the browser renders it. + const [settled, setSettled] = useState(false); const [box, setBox] = useState(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 { t } = useI18n(); useEffect(() => { @@ -108,10 +112,31 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; return () => observer.disconnect(); }, []); - const fit = natural && box ? fitZoom(natural, box) : 1; + 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; @@ -158,8 +183,8 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"];
- {natural ? `${Math.round(scale * 100)}%` : "…"} + {ready ? `${Math.round(scale * 100)}%` : "…"}
From 93d2d96a33c2834321d586926588a0a024a6ae48 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 7 Oct 2026 07:41:10 +0000 Subject: [PATCH 3/5] fix(image-viewer): cover the window at every Interface Scale Viewport units ignore the root zoom, so the viewer was sized past the window (Comfortable/Large) or short of it (Compact) and fit to that. --- app/globals.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/globals.css b/app/globals.css index 4a6b2bf66..f83d78936 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; From 63bb4ded387ac8139944e0376a05b9baf1d38ad0 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 7 Oct 2026 07:50:35 +0000 Subject: [PATCH 4/5] fix(image-viewer): real-pixel zoom at every Interface Scale; wrap toolbar Measure fit and size the image in screen pixels so 1:1 and every percentage ignore the root zoom, and let the toolbar wrap so large touch targets stay reachable on narrow screens. --- app/globals.css | 6 +++++- components/ImageLightbox.tsx | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/globals.css b/app/globals.css index f83d78936..2eeaf535e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1309,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); @@ -1328,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; diff --git a/components/ImageLightbox.tsx b/components/ImageLightbox.tsx index a6fdeb47b..de3fd59e4 100644 --- a/components/ImageLightbox.tsx +++ b/components/ImageLightbox.tsx @@ -99,14 +99,19 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; const [box, setBox] = useState(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, height: entry.contentRect.height }); + setBox({ width: entry.contentRect.width * uiScale, height: entry.contentRect.height * uiScale }); }); observer.observe(viewport); return () => observer.disconnect(); @@ -260,7 +265,7 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; // 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, height: natural.height * scale } + ? { width: natural.width * scale / uiScale, height: natural.height * scale / uiScale } : settled ? undefined : { visibility: "hidden" }} /> From 2a5f0e1587683fccbe027b7c25a300a132ad7c62 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 7 Oct 2026 07:58:50 +0000 Subject: [PATCH 5/5] docs: describe the image viewer's Interface Scale behaviour --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e76997a..a016bdeea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +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. 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's buttons follow the **Touch Targets** setting. +- 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.