History

fix(web): sample the SnapShot band from the visible crop and add a Button overlay variant

The thumbnail renders with object-cover in a 52:28 frame, so a portrait capture hides its top and bottom and an ultra-wide one hides its sides. Sampling the full image's bottom band could tone the overlay from pixels the user never sees. readBottomBand now computes the centered cover crop for the frame's aspect ratio and samples the bottom band of that region. The contents button was overriding Button colors at the call site. Button gains an overlay variant that inherits currentColor and tints hover from it, so the host overlay sets the tone through its text color.

Julius Marminge committed 2 months ago

Commit 57a00a61 parent 25d04ea4 files · +85 −14Browse files

Unified · Split

4 files
  1. apps/web/src/components/chat/SnapShotAttachmentDetails.tsx +5 −2
  2. apps/web/src/components/ui/button.tsx +4 −0
  3. apps/web/src/lib/snapShotOverlayTone.test.ts +34 −1
  4. apps/web/src/lib/snapShotOverlayTone.ts +42 −11
apps/web/src/components/chat/SnapShotAttachmentDetails.tsx+5−2
@@ -135,10 +135,12 @@ export function SnapShotContentsButton({ · 134 unmodified lines
135135 source,
136136 className,
137137 side = "top",
138+ variant = "ghost-muted",
138139 }: {
139140 source: SnapShotSource;
140141 className?: string;
141142 side?: "top" | "right" | "bottom" | "left";
143+ variant?: "ghost-muted" | "overlay";
142144 }) {
143145 const includesAccessibility = snapShotIncludesAccessibility(source);
144146 const ContentsIcon = includesAccessibility ? TextIcon : ImageIcon;
@@ -159,7 +161,7 @@ export function SnapShotContentsButton({ · 14 unmodified lines
159161 className={cn("[--control-icon-color:currentColor]", className)}
160162 onClick={(event) => event.stopPropagation()}
161163 size="icon-micro"
162− variant="ghost-muted"
164+ variant={variant}
163165 />
164166 }
165167 />
@@ -269,7 +271,8 @@ export function SnapShotAttachmentDetails({ · 103 unmodified lines
269271 <span className="truncate">{source.appName}</span>
270272 <SnapShotContentsButton
271273 source={source}
272− className="pointer-events-auto text-(--snap-shot-text-muted) hover:bg-(--snap-shot-badge) hover:text-(--snap-shot-text)"
274+ variant="overlay"
275+ className="pointer-events-auto"
273276 />
274277 </div>
275278 <div className="truncate text-[9px] leading-3.5 text-(--snap-shot-text-muted)">
apps/web/src/components/ui/button.tsx+4−0
@@ -49,6 +49,10 @@ const buttonVariants = cva( · 48 unmodified lines
4949 glass:
5050 "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border",
5151 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",
52+ /** For controls placed over imagery: inherits the surrounding text color
53+ * and tints its hover from it, so the host decides the tone. */
54+ overlay:
55+ "border-transparent text-current opacity-72 [:hover,[data-pressed]]:bg-current/14 [:hover,[data-pressed]]:opacity-100",
5256 outline:
5357 "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64",
5458 secondary:
apps/web/src/lib/snapShotOverlayTone.test.ts+34−1
@@ -1,6 +1,11 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import { averageOpaqueColor, snapShotOverlayTone } from "./snapShotOverlayTone";
3+import {
4+ averageOpaqueColor,
5+ coverCrop,
6+ SNAP_SHOT_FRAME_ASPECT,
7+ snapShotOverlayTone,
8+} from "./snapShotOverlayTone";
49
510 describe("snapShotOverlayTone", () => {
611 it("puts white text on a scrim darker than a dark capture", () => {
@@ -54,3 +59,31 @@ describe("averageOpaqueColor", () => { · 47 unmodified lines
5459 expect(averageOpaqueColor(new Uint8ClampedArray([1, 2, 3, 0, 4, 5, 6, 0]))).toBeNull();
5560 });
5661 });
62+
63+describe("coverCrop", () => {
64+ it("keeps a frame-shaped image whole", () => {
65+ expect(coverCrop(520, 280, SNAP_SHOT_FRAME_ASPECT)).toEqual({
66+ x: 0,
67+ y: 0,
68+ width: 520,
69+ height: 280,
70+ });
71+ });
72+
73+ it("crops the top and bottom of a portrait capture, so the sampled band is mid-image", () => {
74+ const crop = coverCrop(400, 800, SNAP_SHOT_FRAME_ASPECT);
75+ expect(crop.x).toBe(0);
76+ expect(crop.width).toBe(400);
77+ expect(crop.height).toBeCloseTo(400 / SNAP_SHOT_FRAME_ASPECT);
78+ expect(crop.y).toBeCloseTo((800 - crop.height) / 2);
79+ expect(crop.y + crop.height).toBeLessThan(800);
80+ });
81+
82+ it("crops the sides of an ultra-wide capture and keeps its full height", () => {
83+ const crop = coverCrop(3000, 500, SNAP_SHOT_FRAME_ASPECT);
84+ expect(crop.y).toBe(0);
85+ expect(crop.height).toBe(500);
86+ expect(crop.width).toBeCloseTo(500 * SNAP_SHOT_FRAME_ASPECT);
87+ expect(crop.x).toBeCloseTo((3000 - crop.width) / 2);
88+ });
89+});
apps/web/src/lib/snapShotOverlayTone.ts+42−11
@@ -27,9 +27,16 @@ const TEXT_LUMINANCE_THRESHOLD = 0.179; · 26 unmodified lines
2727 /** How far the scrim is pushed toward its text's opposite pole. */
2828 const SCRIM_PUSH = 0.35;
2929
30−/** Fraction of the image height, measured from the bottom, that the overlay covers. */
30+/** Fraction of the visible thumbnail height, from the bottom, that the overlay covers. */
3131 const SAMPLE_BAND = 0.3;
3232
33+/**
34+ * Aspect ratio of the frame the thumbnail is rendered into with `object-cover`
35+ * (see `SNAP_SHOT_ATTACHMENT_FRAME_CLASS`: w-52 by h-28). Sampling must use the
36+ * centered crop that ratio imposes, not the full source image.
37+ */
38+export const SNAP_SHOT_FRAME_ASPECT = 52 / 28;
39+
3340 function relativeLuminance(color: RgbColor): number {
3441 const linearize = (channel: number) => {
3542 const normalized = channel / 255;
@@ -82,7 +89,25 @@ const SAMPLE_HEIGHT = 4; · 46 unmodified lines
8289 const CACHE_LIMIT = 256;
8390 const sampleCache = new Map<string, Promise<RgbColor | null>>();
8491
85−function readBottomBand(image: HTMLImageElement): RgbColor | null {
92+/**
93+ * Source rectangle that `object-cover` shows for an image of the given size in
94+ * a frame of the given aspect ratio: the largest centered region of that ratio.
95+ */
96+export function coverCrop(
97+ width: number,
98+ height: number,
99+ frameAspect: number,
100+): { x: number; y: number; width: number; height: number } {
101+ const imageAspect = width / height;
102+ if (imageAspect > frameAspect) {
103+ const cropWidth = height * frameAspect;
104+ return { x: (width - cropWidth) / 2, y: 0, width: cropWidth, height };
105+ }
106+ const cropHeight = width / frameAspect;
107+ return { x: 0, y: (height - cropHeight) / 2, width, height: cropHeight };
108+}
109+
110+function readBottomBand(image: HTMLImageElement, frameAspect: number): RgbColor | null {
86111 const { naturalWidth: width, naturalHeight: height } = image;
87112 if (width === 0 || height === 0) return null;
88113 const canvas = document.createElement("canvas");
@@ -90,13 +115,14 @@ function readBottomBand(image: HTMLImageElement): RgbColor | null { · 1 unmodified line
90115 canvas.height = SAMPLE_HEIGHT;
91116 const context = canvas.getContext("2d", { willReadFrequently: true });
92117 if (context === null) return null;
93− const bandHeight = Math.max(1, Math.round(height * SAMPLE_BAND));
118+ const crop = coverCrop(width, height, frameAspect);
119+ const bandHeight = Math.max(1, crop.height * SAMPLE_BAND);
94120 // Drawing the band into a tiny canvas lets the rasterizer do the averaging.
95121 context.drawImage(
96122 image,
97− 0,
98− height - bandHeight,
99− width,
123+ crop.x,
124+ crop.y + crop.height - bandHeight,
125+ crop.width,
100126 bandHeight,
101127 0,
102128 0,
@@ -112,18 +138,23 @@ function readBottomBand(image: HTMLImageElement): RgbColor | null { · 9 unmodified lines
112138 }
113139
114140 /**
115− * Average color of the image's bottom band, or null when the image cannot be
116− * loaded or read. Results are memoized per URL so the three surfaces that
117− * show the same capture decode it once.
141+ * Average color of the bottom band of the region the thumbnail frame shows, or
142+ * null when the image cannot be loaded or read. Results are memoized per URL
143+ * so the surfaces that show the same capture decode it once.
118144 */
119−export function sampleSnapShotBottomColor(src: string): Promise<RgbColor | null> {
145+export function sampleSnapShotBottomColor(
146+ src: string,
147+ frameAspect = SNAP_SHOT_FRAME_ASPECT,
148+): Promise<RgbColor | null> {
120149 const cached = sampleCache.get(src);
121150 if (cached) return cached;
122151 const pending = new Promise<RgbColor | null>((resolve) => {
123152 const image = new Image();
124153 image.crossOrigin = "anonymous";
125154 image.decoding = "async";
126− image.addEventListener("load", () => resolve(readBottomBand(image)), { once: true });
155+ image.addEventListener("load", () => resolve(readBottomBand(image, frameAspect)), {
156+ once: true,
157+ });
127158 image.addEventListener("error", () => resolve(null), { once: true });
128159 image.src = src;
129160 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t