From 11fd5739c09b00924a98654b8207ba2eec3548b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:34:51 +0000 Subject: [PATCH] Files: details-only hover card, grouped digits in usage counts - The tile hover card drops the enlarged image and shows the filename, PR, then Page, State, Device and Uploaded rows when the item has them. Every tile gets the card now, not just ones with an image preview. - Usage counts use grouped digits everywhere they render (sidebar, rail fallback text, admin limits): 10,000, not 10000. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FsGRzxzuHgaKiSjXwGmWL6 --- .../web/src/components/admin/LimitsEditor.tsx | 5 +- apps/web/src/components/files/ShotThumb.tsx | 25 ++--- .../src/components/files/useShotPreview.tsx | 97 +++++++++---------- .../web/src/components/shell/SidebarUsage.tsx | 3 +- .../web/src/lib/workspace-screenshots.test.ts | 16 +++ apps/web/src/lib/workspace-screenshots.ts | 37 +++++-- apps/web/src/lib/workspace-ui.ts | 23 +++-- apps/web/src/styles/account-content.css | 45 +++++---- 8 files changed, 144 insertions(+), 107 deletions(-) diff --git a/apps/web/src/components/admin/LimitsEditor.tsx b/apps/web/src/components/admin/LimitsEditor.tsx index ef1f9010b..ba893b177 100644 --- a/apps/web/src/components/admin/LimitsEditor.tsx +++ b/apps/web/src/components/admin/LimitsEditor.tsx @@ -21,6 +21,7 @@ import { multForUnit, splitBytes, } from "../../lib/admin-limits"; +import { formatCount } from "../../lib/workspace-ui"; import { INPUT_NUM, SELECT_SM } from "./field-classes"; import { Muted, SectionHeading, StatusLine } from "./StatusLine"; import { useAdminResource } from "./use-admin-resource"; @@ -183,9 +184,9 @@ export function LimitsEditor({ api, workspace }: { api: AdminApi; workspace: str cap={data.limits.maxStorageBytes} /> void; @@ -79,12 +74,7 @@ export function ShotThumb({ /** Known destination; when null the tile falls back to a button + `onOpen`. */ href: string | null; onOpen: () => void; - onPreviewEnter?: ( - el: HTMLElement, - src: string, - caption: PreviewCaption, - opts?: { immediate?: boolean }, - ) => void; + onPreviewEnter?: PreviewHandlers["onPreviewEnter"]; onPreviewLeave?: () => void; }) { const name = leafName(item.key); @@ -99,12 +89,11 @@ export function ShotThumb({ const base = shotPreviewCaption(item); const caption: PreviewCaption = base.pr ? { ...base, kind: ghKindValue } : base; - const previewSrc = imageSrc ? thumbUrl(imageSrc, 1120) : null; const shared = { className: "wsp-tile", "aria-label": `Open ${name}${stateSuffix}${caption.pr ? ` — ${caption.pr}` : ""}${paired ? " — has before/after pair" : ""}`, onMouseEnter: (event: { currentTarget: HTMLElement }) => { - if (previewSrc) onPreviewEnter?.(event.currentTarget, previewSrc, caption); + onPreviewEnter?.(event.currentTarget, caption); }, onMouseLeave: () => onPreviewLeave?.(), // Keyboard-focus parity for the hover preview: `:focus-visible` gates @@ -113,8 +102,8 @@ export function ShotThumb({ // away from. Escape (handled in useShotPreview) dismisses it either way, // and dismissal never moves focus, so tab order is untouched. onFocus: (event: { currentTarget: HTMLElement }) => { - if (previewSrc && focusIsKeyboardDriven(event.currentTarget)) { - onPreviewEnter?.(event.currentTarget, previewSrc, caption, { immediate: true }); + if (focusIsKeyboardDriven(event.currentTarget)) { + onPreviewEnter?.(event.currentTarget, caption, { immediate: true }); } }, onBlur: () => onPreviewLeave?.(), diff --git a/apps/web/src/components/files/useShotPreview.tsx b/apps/web/src/components/files/useShotPreview.tsx index d4fdf5ae0..0375cd80a 100644 --- a/apps/web/src/components/files/useShotPreview.tsx +++ b/apps/web/src/components/files/useShotPreview.tsx @@ -1,5 +1,6 @@ /** - * Hover preview for media tiles, moved from ScreenshotsByPath. Tiles call + * Hover card for media tiles: details only (name, PR, page, state, device, + * upload time), no enlarged image — the tile is the picture. Tiles call * `handlers.onPreviewEnter/Leave`; the caller renders `layer` once. Escape * and any scroll dismiss it. Placement is measured in a layout effect so the * corrected position lands before paint (shotPreviewPosition, #776). @@ -17,19 +18,13 @@ import { import { GhKindIcon } from "./gh-glyphs"; import type { PreviewCaption, PreviewHandlers } from "./ShotThumb"; -interface PreviewState { - src: string; - /** Trigger tile's viewport box, kept around so a re-measure (image load, - * resolved title) can re-run `shotPreviewPosition` without re-reading a - * DOM node that may have scrolled or unmounted. */ +interface PreviewState extends PreviewCaption { + /** Trigger tile's viewport box, kept around so a re-measure (resolved + * title) can re-run `shotPreviewPosition` without re-reading a DOM node + * that may have scrolled or unmounted. */ thumb: ShotPreviewBox; left: number; top: number; - name: string; - pr?: string; - kind?: string; - ref?: string; - uploadedAt?: string; } export function useShotPreview( @@ -63,13 +58,12 @@ export function useShotPreview( }, []); // Flip-aware placement, measured rather than estimated: `shotPreviewPosition` - // needs the pop-over's real box (the image's intrinsic aspect ratio and the - // meta block's line count are both unknown before mount — a PR title can - // add a line, an error state adds none), so this measures the ACTUAL - // rendered element and only then decides which side/edge to place it on. - // Runs in a layout effect, which lands the corrected position before the - // browser paints — no visible jump — and re-runs whenever the full-size - // image finishes loading or a resolved title changes the box's height. + // needs the pop-over's real box (its line count is unknown before mount — + // a PR title can add a line, an error state adds none), so this measures + // the ACTUAL rendered element and only then decides which side/edge to + // place it on. Runs in a layout effect, which lands the corrected position + // before the browser paints — no visible jump — and re-runs whenever a + // resolved title changes the box's height. const previewRef = useRef(null); const positionPreview = () => { const el = previewRef.current; @@ -104,12 +98,7 @@ export function useShotPreview( if (info) setTitles((prev) => ({ ...prev, [ref]: info })); }); }; - const onPreviewEnter = ( - el: HTMLElement, - src: string, - caption: PreviewCaption, - opts?: { immediate?: boolean }, - ) => { + const onPreviewEnter: PreviewHandlers["onPreviewEnter"] = (el, caption, opts) => { // Keyboard focus (`opts.immediate`) opens regardless of hover capability // — `focusIsKeyboardDriven` at the call site already excludes touch taps. if (!opts?.immediate && !window.matchMedia("(hover: hover) and (pointer: fine)").matches) { @@ -128,17 +117,12 @@ export function useShotPreview( bottom: rect.bottom, }; setPreview({ - src, + ...caption, thumb, // Placeholder until the layout effect above measures the real box // and repositions before paint. left: thumb.right + 12, top: thumb.top, - name: caption.name, - pr: caption.pr, - kind: caption.kind, - ref: caption.ref, - uploadedAt: caption.uploadedAt, }); }; if (opts?.immediate) { @@ -152,6 +136,13 @@ export function useShotPreview( // A caption with a PR but no ref keeps its plain-text label instead of // dropping the line (previewPrDisplay). const previewPr = preview ? previewPrDisplay(preview, titles) : null; + const details: [string, string][] = []; + if (preview?.page) details.push(["Page", preview.page]); + if (preview?.state) details.push(["State", preview.state]); + if (preview?.device) details.push(["Device", preview.device]); + if (preview?.uploadedAt) { + details.push(["Uploaded", lastUpdatedLabel(preview.uploadedAt, new Date())]); + } const layer = preview ? (
- {/* onLoad: the real image height is only known once bytes arrive — - re-clamp so a tall capture doesn't push the meta off-screen. */} - -
-
{preview.name}
- {previewPr && ( -
- {/* PrLabel draws its own state icon; the bare kind glyph is +
{preview.name}
+ {previewPr && ( +
+ {/* PrLabel draws its own state icon; the bare kind glyph is only for the plain-text fallback. */} - {"label" in previewPr ? ( - - ) : ( - <> - {previewPr.text} - - )} -
- )} - {preview.uploadedAt && ( -
- uploaded {lastUpdatedLabel(preview.uploadedAt, new Date())} -
- )} -
+ {"label" in previewPr ? ( + + ) : ( + <> + {previewPr.text} + + )} +
+ )} + {details.length > 0 && ( +
+ {details.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ )}
) : null; diff --git a/apps/web/src/components/shell/SidebarUsage.tsx b/apps/web/src/components/shell/SidebarUsage.tsx index 96a7d3200..181b9fd31 100644 --- a/apps/web/src/components/shell/SidebarUsage.tsx +++ b/apps/web/src/components/shell/SidebarUsage.tsx @@ -16,6 +16,7 @@ import { readWorkspaceSnapshot } from "../../lib/workspace-cache"; import { loadWorkspaceSummary } from "../../lib/workspace-summary-source"; import { BYO_USAGE_NOTE, + objectCountLabel, usageMeters, usagePlainText, type UsageMeter, @@ -144,7 +145,7 @@ export function SidebarUsage({ ))} {open && (

- {`${state.usage.objects} object${state.usage.objects === 1 ? "" : "s"}`} + {objectCountLabel(state.usage.objects)} {state.usage.storageBudgetBasis === "shared" ? ` · ${BYO_USAGE_NOTE}` : ""}

)} diff --git a/apps/web/src/lib/workspace-screenshots.test.ts b/apps/web/src/lib/workspace-screenshots.test.ts index f07e18c77..95d21f72e 100644 --- a/apps/web/src/lib/workspace-screenshots.test.ts +++ b/apps/web/src/lib/workspace-screenshots.test.ts @@ -170,6 +170,22 @@ describe("shotPreviewCaption", () => { expect(cap.ref).toBeUndefined(); expect(cap.pr).toBeUndefined(); }); + + it("adds page, state and device for the hover card", () => { + expect( + shotPreviewCaption({ + key: "shot.png", + state: "after", + metadata: { path: "/settings", "ai.surface": "mobile" }, + }), + ).toEqual({ name: "shot.png", page: "/settings", state: "After", device: "Mobile" }); + expect( + shotPreviewCaption({ + key: "shot.png", + metadata: { screen: "Login", state: "before", "ai.surface": "watch" }, + }), + ).toEqual({ name: "shot.png", page: "Login", state: "Before" }); + }); }); describe("projectLabelFromItemMeta", () => { diff --git a/apps/web/src/lib/workspace-screenshots.ts b/apps/web/src/lib/workspace-screenshots.ts index 3e11909c4..939b92a5e 100644 --- a/apps/web/src/lib/workspace-screenshots.ts +++ b/apps/web/src/lib/workspace-screenshots.ts @@ -12,6 +12,7 @@ import { import { asPrState, type PrLabelInput } from "@uploads/ui/lib/pr-label"; import type { GithubTitleMap } from "./api-client"; import { + FILE_SURFACE_LABELS, filesBasePath, parseFileTypeParam, parseSinceParam, @@ -117,20 +118,36 @@ export function leafName(key: string): string { return (slash === -1 ? trimmed : trimmed.slice(slash + 1)) || key; } +/** Hover-card details for a tile. */ +export interface ShotPreviewCaption { + name: string; + pr?: string; + ref?: string; + uploadedAt?: string; + /** `path`, else `screen`. */ + page?: string; + /** "Before" / "After". */ + state?: string; + /** From the classifier's `ai.surface`: "Mobile" / "Desktop" / "Tablet". */ + device?: string; +} + /** - * Hover-preview caption: filename, plus a compact PR/issue line when the - * file carries GitHub attachment metadata. Mirrors the file page's - * filename + byline split without the native `title` tooltip. + * Hover-card caption: filename, a compact PR/issue line when the file carries + * GitHub attachment metadata, and the page, state and device when known. + * Mirrors the file page's filename + byline split without the native `title` + * tooltip. */ export function shotPreviewCaption(item: { key: string; + state?: string; ghKind?: string; ghNumber?: string; ghRef?: string; updatedAt?: string; uploadedAt?: string; metadata?: Record; -}): { name: string; pr?: string; ref?: string; uploadedAt?: string } { +}): ShotPreviewCaption { const kind = item.ghKind ?? item.metadata?.["gh.kind"]; const number = item.ghNumber ?? item.metadata?.["gh.number"]; const repo = item.metadata?.["gh.repo"]; @@ -144,12 +161,18 @@ export function shotPreviewCaption(item: { if (kind === "pull") pr = `PR #${number}`; else if (kind === "issue" || kind === "issues") pr = `Issue #${number}`; } - const caption: { name: string; pr?: string; ref?: string; uploadedAt?: string } = { - name: leafName(item.key), - }; + const caption: ShotPreviewCaption = { name: leafName(item.key) }; if (pr) caption.pr = pr; if (pr && ref) caption.ref = ref; if (uploadedAt) caption.uploadedAt = uploadedAt; + const page = item.metadata?.path || item.metadata?.screen; + if (page) caption.page = page; + const state = (item.state ?? item.metadata?.state)?.toLowerCase(); + if (state === "before" || state === "after") { + caption.state = state === "before" ? "Before" : "After"; + } + const surface = parseSurfaceParam(item.metadata?.["ai.surface"]); + if (surface) caption.device = FILE_SURFACE_LABELS[surface]; return caption; } diff --git a/apps/web/src/lib/workspace-ui.ts b/apps/web/src/lib/workspace-ui.ts index 99711aca8..929e2f9c4 100644 --- a/apps/web/src/lib/workspace-ui.ts +++ b/apps/web/src/lib/workspace-ui.ts @@ -76,12 +76,21 @@ export type UsageSnapshot = { storageBudgetBasis?: "total" | "shared"; }; +/** Counts with grouped digits: 10,000, not 10000. */ +export function formatCount(n: number): string { + return n.toLocaleString("en-US"); +} + +/** "1 object" / "12,345 objects". */ +export function objectCountLabel(n: number): string { + return `${formatCount(n)} object${n === 1 ? "" : "s"}`; +} + function formatUsagePlain(usage: UsageSnapshot): string { - const parts = [ - formatBytes(usage.bytes), - `${usage.objects} object${usage.objects === 1 ? "" : "s"}`, - ]; - if (usage.uploadsInPeriod > 0) parts.push(`${usage.uploadsInPeriod} uploads this month`); + const parts = [formatBytes(usage.bytes), objectCountLabel(usage.objects)]; + if (usage.uploadsInPeriod > 0) { + parts.push(`${formatCount(usage.uploadsInPeriod)} uploads this month`); + } return parts.join(" · "); } @@ -138,7 +147,7 @@ export function usageMeters(usage: UsageSnapshot): UsageMeter[] { if (uploadsPct !== null && usage.maxUploadsPerPeriod) { meters.push({ label: "Monthly uploads", - detail: `${usage.uploadsInPeriod.toLocaleString("en-US")} of ${usage.maxUploadsPerPeriod.toLocaleString("en-US")}`, + detail: `${formatCount(usage.uploadsInPeriod)} of ${formatCount(usage.maxUploadsPerPeriod)}`, pct: uploadsPct, }); } @@ -162,7 +171,7 @@ export function renderUsageHtml(usage: UsageSnapshot): string { if (!meters.length) { return `
${escapeHtml(formatUsagePlain(usage))}
${byoNote}`; } - const objects = `${usage.objects} object${usage.objects === 1 ? "" : "s"}`; + const objects = objectCountLabel(usage.objects); return `
${meters.map((m) => progressRowHtml(m.label, m.detail, m.pct)).join("")}
${escapeHtml(objects)}
${byoNote}`; } diff --git a/apps/web/src/styles/account-content.css b/apps/web/src/styles/account-content.css index 0a9c4e2df..3a848116e 100644 --- a/apps/web/src/styles/account-content.css +++ b/apps/web/src/styles/account-content.css @@ -1798,30 +1798,19 @@ button.wft-row { pointer-events: none; display: grid; gap: 8px; - padding: 6px 6px 8px; + width: max-content; + min-width: 200px; + max-width: min(320px, calc(100vw - 24px)); + box-sizing: border-box; + padding: 10px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 12px 32px color-mix(in srgb, #000 45%, transparent); } -.wsp-preview img { - display: block; - max-width: min(560px, calc(100vw - 24px)); - max-height: 70vh; - width: auto; - height: auto; - border-radius: 2px; - outline: 1px solid oklch(1 0 0 / 0.1); -} -.wsp-preview__meta { - display: grid; - gap: 2px; - padding: 0 2px; - min-width: 0; -} .wsp-preview__name { color: var(--fg); - font: var(--weight-semibold) var(--text-micro) / 1 var(--mono); + font: var(--weight-semibold) var(--text-micro) / 1.3 var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1829,10 +1818,28 @@ button.wft-row { .wsp-preview__pr { color: var(--muted); font: var(--text-micro) var(--mono); + min-width: 0; +} +/* Label/value rows: Page, State, Device, Uploaded. */ +.wsp-preview__details { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 12px; + margin: 0; + font: var(--text-micro) / 1.35 var(--sans); +} +.wsp-preview__details > div { + display: contents; } -.wsp-preview__time { +.wsp-preview__details dt { color: var(--muted); - font: var(--text-micro) var(--mono); +} +.wsp-preview__details dd { + margin: 0; + color: var(--body); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } @media (prefers-reduced-motion: no-preference) { .wsp-preview {