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
135 source,135 source,
136 className,136 className,
137 side = "top",137 side = "top",
138 variant = "ghost-muted",
138}: {139}: {
139 source: SnapShotSource;140 source: SnapShotSource;
140 className?: string;141 className?: string;
141 side?: "top" | "right" | "bottom" | "left";142 side?: "top" | "right" | "bottom" | "left";
143 variant?: "ghost-muted" | "overlay";
142}) {144}) {
143 const includesAccessibility = snapShotIncludesAccessibility(source);145 const includesAccessibility = snapShotIncludesAccessibility(source);
144 const ContentsIcon = includesAccessibility ? TextIcon : ImageIcon;146 const ContentsIcon = includesAccessibility ? TextIcon : ImageIcon;
@@ -159,7 +161,7 @@ export function SnapShotContentsButton({ · 14 unmodified lines
159 className={cn("[--control-icon-color:currentColor]", className)}161 className={cn("[--control-icon-color:currentColor]", className)}
160 onClick={(event) => event.stopPropagation()}162 onClick={(event) => event.stopPropagation()}
161 size="icon-micro"163 size="icon-micro"
162 variant="ghost-muted"164 variant={variant}
163 />165 />
164 }166 }
165 />167 />
@@ -269,7 +271,8 @@ export function SnapShotAttachmentDetails({ · 103 unmodified lines
269 <span className="truncate">{source.appName}</span>271 <span className="truncate">{source.appName}</span>
270 <SnapShotContentsButton272 <SnapShotContentsButton
271 source={source}273 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"
273 />276 />
274 </div>277 </div>
275 <div className="truncate text-[9px] leading-3.5 text-(--snap-shot-text-muted)">278 <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
49 glass:49 glass:
50 "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border",50 "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border",
51 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",51 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",
52 outline:56 outline:
53 "[--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",57 "[--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",
54 secondary:58 secondary:
apps/web/src/lib/snapShotOverlayTone.test.ts+34−1
@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2 2
3import { averageOpaqueColor, snapShotOverlayTone } from "./snapShotOverlayTone";3import {
4 averageOpaqueColor,
5 coverCrop,
6 SNAP_SHOT_FRAME_ASPECT,
7 snapShotOverlayTone,
8} from "./snapShotOverlayTone";
4 9
5describe("snapShotOverlayTone", () => {10describe("snapShotOverlayTone", () => {
6 it("puts white text on a scrim darker than a dark capture", () => {11 it("puts white text on a scrim darker than a dark capture", () => {
@@ -54,3 +59,31 @@ describe("averageOpaqueColor", () => { · 47 unmodified lines
54 expect(averageOpaqueColor(new Uint8ClampedArray([1, 2, 3, 0, 4, 5, 6, 0]))).toBeNull();59 expect(averageOpaqueColor(new Uint8ClampedArray([1, 2, 3, 0, 4, 5, 6, 0]))).toBeNull();
55 });60 });
56});61});
62
63describe("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
27/** How far the scrim is pushed toward its text's opposite pole. */27/** How far the scrim is pushed toward its text's opposite pole. */
28const SCRIM_PUSH = 0.35;28const SCRIM_PUSH = 0.35;
29 29
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. */
31const SAMPLE_BAND = 0.3;31const SAMPLE_BAND = 0.3;
32 32
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 */
38export const SNAP_SHOT_FRAME_ASPECT = 52 / 28;
39
33function relativeLuminance(color: RgbColor): number {40function relativeLuminance(color: RgbColor): number {
34 const linearize = (channel: number) => {41 const linearize = (channel: number) => {
35 const normalized = channel / 255;42 const normalized = channel / 255;
@@ -82,7 +89,25 @@ const SAMPLE_HEIGHT = 4; · 46 unmodified lines
82const CACHE_LIMIT = 256;89const CACHE_LIMIT = 256;
83const sampleCache = new Map<string, Promise<RgbColor | null>>();90const sampleCache = new Map<string, Promise<RgbColor | null>>();
84 91
85function 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 */
96export 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
110function readBottomBand(image: HTMLImageElement, frameAspect: number): RgbColor | null {
86 const { naturalWidth: width, naturalHeight: height } = image;111 const { naturalWidth: width, naturalHeight: height } = image;
87 if (width === 0 || height === 0) return null;112 if (width === 0 || height === 0) return null;
88 const canvas = document.createElement("canvas");113 const canvas = document.createElement("canvas");
@@ -90,13 +115,14 @@ function readBottomBand(image: HTMLImageElement): RgbColor | null { · 1 unmodified line
90 canvas.height = SAMPLE_HEIGHT;115 canvas.height = SAMPLE_HEIGHT;
91 const context = canvas.getContext("2d", { willReadFrequently: true });116 const context = canvas.getContext("2d", { willReadFrequently: true });
92 if (context === null) return null;117 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);
94 // Drawing the band into a tiny canvas lets the rasterizer do the averaging.120 // Drawing the band into a tiny canvas lets the rasterizer do the averaging.
95 context.drawImage(121 context.drawImage(
96 image,122 image,
97 0,123 crop.x,
98 height - bandHeight,124 crop.y + crop.height - bandHeight,
99 width,125 crop.width,
100 bandHeight,126 bandHeight,
101 0,127 0,
102 0,128 0,
@@ -112,18 +138,23 @@ function readBottomBand(image: HTMLImageElement): RgbColor | null { · 9 unmodified lines
112}138}
113 139
114/**140/**
115 * Average color of the image's bottom band, or null when the image cannot be141 * Average color of the bottom band of the region the thumbnail frame shows, or
116 * loaded or read. Results are memoized per URL so the three surfaces that142 * null when the image cannot be loaded or read. Results are memoized per URL
117 * show the same capture decode it once.143 * so the surfaces that show the same capture decode it once.
118 */144 */
119export function sampleSnapShotBottomColor(src: string): Promise<RgbColor | null> {145export function sampleSnapShotBottomColor(
146 src: string,
147 frameAspect = SNAP_SHOT_FRAME_ASPECT,
148): Promise<RgbColor | null> {
120 const cached = sampleCache.get(src);149 const cached = sampleCache.get(src);
121 if (cached) return cached;150 if (cached) return cached;
122 const pending = new Promise<RgbColor | null>((resolve) => {151 const pending = new Promise<RgbColor | null>((resolve) => {
123 const image = new Image();152 const image = new Image();
124 image.crossOrigin = "anonymous";153 image.crossOrigin = "anonymous";
125 image.decoding = "async";154 image.decoding = "async";
126 image.addEventListener("load", () => resolve(readBottomBand(image)), { once: true });155 image.addEventListener("load", () => resolve(readBottomBand(image, frameAspect)), {
156 once: true,
157 });
127 image.addEventListener("error", () => resolve(null), { once: true });158 image.addEventListener("error", () => resolve(null), { once: true });
128 image.src = src;159 image.src = src;
129 });160 });

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