diff --git a/apps/web/src/components/admin/LimitsEditor.tsx b/apps/web/src/components/admin/LimitsEditor.tsx index ef1f9010..ba893b17 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 d4fdf5ae..0375cd80 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 96a7d320..181b9fd3 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 f07e18c7..95d21f72 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 3e11909c..939b92a5 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 99711aca..929e2f9c 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 0a9c4e2d..3a848116 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 {