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.
Unified · Split
| @@ -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 | <SnapShotContentsButton | 272 | <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)"> |
| @@ -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: |
| @@ -1,6 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { averageOpaqueColor, snapShotOverlayTone } from "./snapShotOverlayTone"; | 3 | import { |
| 4 | averageOpaqueColor, | ||
| 5 | coverCrop, | ||
| 6 | SNAP_SHOT_FRAME_ASPECT, | ||
| 7 | snapShotOverlayTone, | ||
| 8 | } from "./snapShotOverlayTone"; | ||
| 4 | 9 | ||
| 5 | describe("snapShotOverlayTone", () => { | 10 | describe("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 | |||
| 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 | }); | ||
| @@ -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. */ |
| 28 | const SCRIM_PUSH = 0.35; | 28 | const 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. */ |
| 31 | const SAMPLE_BAND = 0.3; | 31 | const 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 | */ | ||
| 38 | export const SNAP_SHOT_FRAME_ASPECT = 52 / 28; | ||
| 39 | |||
| 33 | function relativeLuminance(color: RgbColor): number { | 40 | function 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 | |||
| 82 | const CACHE_LIMIT = 256; | 89 | const CACHE_LIMIT = 256; |
| 83 | const sampleCache = new Map<string, Promise<RgbColor | null>>(); | 90 | const sampleCache = new Map<string, Promise<RgbColor | null>>(); |
| 84 | 91 | ||
| 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 { | ||
| 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 be | 141 | * 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 that | 142 | * 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 | */ |
| 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> { | ||
| 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 | }); |