Skip to content

Commit f9a7653

Browse files
Hotragnclaude
andcommitted
Photo-fit: the photo shows whole above the words and sheet; dark photos brightened for display
WIP: gate and evidence follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Entire-Checkpoint: 01M4PRBZNG64Q4X2GHGBJJ237S
1 parent 7792e2c commit f9a7653

14 files changed

Lines changed: 448 additions & 15 deletions

File tree

‎apps/web/src/lib/components/PhotoPrint.svelte‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
--dur-develop (2.4 s), matching the ~3 s the hub takes to draft the six words.
55
state: blank | developing | developed. Reduced motion shows the final print at once.
66
`scene` is a short description used as the image's accessible name (SRS-ACC-5).
7+
Photo-fit: a real photo keeps its own shape (height is then the most it may take, never a
8+
crop), a dark one is brightened for display only, and a tap opens it full screen.
79
Motion (docs/design/motion.md 3.4): only opacity animates (animated blur is a known
810
slow path in iOS Safari). A white haze lifts first, the sepia veil clears from the
911
middle, and the edges clear last, like a chemical print. No flash.
1012
-->
1113
<script lang="ts">
1214
import { t } from "#lib/copy/index.ts";
1315
import SampleScene from "./SampleScene.svelte";
16+
import { lit, zoom } from "#lib/photo-fit.ts";
1417
let {
1518
state = "developed",
1619
scene = "",
@@ -29,20 +32,29 @@
2932
} = $props();
3033
</script>
3134

32-
<figure class="print {state}" style:--tilt="{tilt}deg" style:height="{height}px">
35+
{#snippet layers()}
36+
{#if state === "developing"}
37+
<span class="veil" aria-hidden="true"></span>
38+
<span class="edge" aria-hidden="true"></span>
39+
<span class="haze" aria-hidden="true"></span>
40+
{/if}
41+
{/snippet}
42+
43+
<figure class="print {state}" class:natural={!!src} style:--tilt="{tilt}deg" style:--max="{height}px" style:height={src ? undefined : `${height}px`}>
3344
<div class="paper grain">
45+
{#if src}
46+
<button type="button" class="image photo-art" aria-label={scene || t("invite.photoFallback")} onclick={zoom}>
47+
<img {src} alt="" onload={lit} />
48+
{@render layers()}
49+
</button>
50+
{:else}
3451
<div class="image photo-art" role="img" aria-label={scene || t("invite.photoFallback")}>
35-
{#if src}
36-
<img {src} alt="" />
37-
{:else if sample}
52+
{#if sample}
3853
<SampleScene />
3954
{/if}
40-
{#if state === "developing"}
41-
<span class="veil" aria-hidden="true"></span>
42-
<span class="edge" aria-hidden="true"></span>
43-
<span class="haze" aria-hidden="true"></span>
44-
{/if}
55+
{@render layers()}
4556
</div>
57+
{/if}
4658
</div>
4759
</figure>
4860

@@ -51,6 +63,12 @@
5163
.paper { height: 100%; padding: 12px 12px 44px; background: var(--c-birch-50); border-radius: 6px; box-shadow: var(--e-3); }
5264
.image { position: relative; width: 100%; height: 100%; overflow: hidden; border-radius: 3px; background: #efe6d4; }
5365
.image :global(img), .image :global(svg) { width: 100%; height: 100%; object-fit: cover; }
66+
/* Photo-fit: the print wraps the photo at its own shape, at most --max tall (44% of the screen
67+
on short phones), so the main button still fits on a 375 x 667 screen. */
68+
.natural { width: fit-content; max-width: 100%; margin: 0 auto; }
69+
.natural .paper, .natural .image { height: auto; }
70+
button.image { display: block; padding: 0; border: 0; cursor: zoom-in; color: inherit; }
71+
.natural .image :global(img) { display: block; width: auto; height: auto; max-width: 100%; max-height: calc(min(var(--max), 44dvh) - 56px); object-fit: initial; }
5472
.blank .image > :global(*) { opacity: 0; }
5573
.developed .image > :global(*) { opacity: 1; }
5674
.haze, .veil, .edge { position: absolute; inset: 0; pointer-events: none; }

‎apps/web/src/lib/photo-fit.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Photo-fit hooks for the photo screens (I1, H2, H3). Each loads its module on first use, so
2+
// the entry bundles carry only these lines: the light check on the photo's load event, and
3+
// the full-screen viewer on tap.
4+
export const lit = (e: Event) => import("./photo-light.ts").then((m) => m.light(e.currentTarget as HTMLImageElement));
5+
export const zoom = (e: Event) => {
6+
const b = e.currentTarget as HTMLElement;
7+
const img = b.querySelector("img");
8+
if (img) import("./photo-view.ts").then((m) => m.openPhoto(img, b));
9+
};

‎apps/web/src/lib/photo-light.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Dark photos, brightened for display only (photo-fit fix). A friend opened a dim indoor photo
2+
// and could not see the place. We draw the decoded photo onto a 32 x 32 canvas, take its mean
3+
// luminance (0 to 1), and if it is under 0.35 we set a CSS filter on the <img>. The photo's
4+
// bytes (sealed, uploaded, decrypted) are never touched: only the element's style changes.
5+
// Loaded lazily from the photo's load event, so it costs the entry bundles nothing.
6+
7+
/** Under this mean luminance a photo counts as dark. */
8+
export const DARK = 0.35;
9+
const SIDE = 32;
10+
11+
/** Mean luma (Rec. 709 weights on sRGB values) of RGBA pixels, 0 to 1. Transparent pixels are skipped. */
12+
export function meanLuma(rgba: ArrayLike<number>): number {
13+
let sum = 0;
14+
let n = 0;
15+
for (let i = 0; i + 3 < rgba.length; i += 4) {
16+
if (rgba[i + 3] === 0) continue;
17+
sum += 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
18+
n++;
19+
}
20+
return n ? sum / n / 255 : 1;
21+
}
22+
23+
/** The display filter for a mean luma: "" when bright enough, stronger the darker it is (max 1.6). */
24+
export function filterFor(mean: number): string {
25+
if (!(mean < DARK)) return "";
26+
const b = Math.min(1.6, 1 + (DARK - mean) * 2);
27+
const c = 1 + (b - 1) * 0.15;
28+
return `brightness(${b.toFixed(2)}) contrast(${c.toFixed(2)})`;
29+
}
30+
31+
/** Mean luma of a decoded image, or null when the canvas can't be read (tainted, no 2D context). */
32+
export function measure(img: HTMLImageElement): number | null {
33+
try {
34+
const canvas = document.createElement("canvas");
35+
canvas.width = canvas.height = SIDE;
36+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
37+
if (!ctx || !img.naturalWidth) return null;
38+
ctx.drawImage(img, 0, 0, SIDE, SIDE);
39+
return meanLuma(ctx.getImageData(0, 0, SIDE, SIDE).data);
40+
} catch {
41+
return null; // a cross-origin photo taints the canvas: show it as it is
42+
}
43+
}
44+
45+
/** Sets (or clears) the display filter on a loaded photo. */
46+
export function light(img: HTMLImageElement): void {
47+
const mean = measure(img);
48+
img.style.filter = mean === null ? "" : filterFor(mean);
49+
if (mean !== null) img.dataset.luma = mean.toFixed(3);
50+
}

‎apps/web/src/lib/photo-view.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Full-screen photo (photo-fit fix): tap the photo to see all of it. A native <dialog> opened
2+
// with showModal() gives the focus trap, Escape and the top layer; a 44 px close button; focus
3+
// goes back to the photo that opened it. Loaded lazily on the first tap, so the entry bundles
4+
// don't carry it. Plain DOM, no Svelte runtime. The same display filter as the small photo.
5+
6+
import { t } from "#lib/copy/index.ts";
7+
8+
export function openPhoto(small: HTMLImageElement, from: HTMLElement): void {
9+
const alt = small.alt || from.getAttribute("aria-label") || "";
10+
const d = document.createElement("dialog");
11+
d.className = "photo-view";
12+
d.setAttribute("aria-label", alt);
13+
d.style.cssText =
14+
"position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;background:#000;color:#fff;display:grid;place-items:center";
15+
const img = document.createElement("img");
16+
img.src = small.currentSrc || small.src;
17+
img.alt = alt;
18+
img.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;display:block";
19+
img.style.filter = small.style.filter;
20+
const x = document.createElement("button");
21+
x.type = "button";
22+
x.setAttribute("aria-label", t("app.close"));
23+
x.style.cssText =
24+
"position:absolute;top:calc(env(safe-area-inset-top,0px) + 8px);right:10px;width:44px;height:44px;border-radius:50%;border:0;background:rgb(255 255 255 / .2);color:#fff;font:700 22px/1 system-ui;display:grid;place-items:center";
25+
x.textContent = "×";
26+
x.onclick = () => d.close();
27+
d.onclick = (e) => {
28+
if (e.target === d || e.target === img) d.close();
29+
};
30+
d.onclose = () => {
31+
d.remove();
32+
from.focus({ preventScroll: true });
33+
};
34+
d.append(img, x);
35+
document.body.append(d);
36+
d.showModal();
37+
x.focus();
38+
}

‎apps/web/src/lib/screens/H2Develop.svelte‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
</script>
6060

6161
<Screen {theme} heading={t("develop.heading")} offline={view === "offline" ? t("develop.offline") : ""}>
62-
<PhotoPrint state={view === "developing" ? "developing" : "developed"} {scene} src={photo} {sample} height={290} />
62+
<PhotoPrint state={view === "developing" ? "developing" : "developed"} {scene} src={photo} {sample} height={askLocation ? 210 : 290} />
6363
{#if spot}<p class="spot"><Icon name="hotspots" size={20} /><span>{spot}</span></p>{/if}
6464
{#if view === "developing"}
6565
<p class="t-invite pending" role="status">{t("develop.writing")}</p>

‎apps/web/src/lib/screens/H3Send.svelte‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import Icon from "../components/Icon.svelte";
99
import Tip from "../components/Tip.svelte";
1010
import { t } from "#lib/copy/index.ts";
11+
import { lit, zoom } from "#lib/photo-fit.ts";
1112
import type { Access, Theme } from "../components/parts";
1213
let {
1314
view = "ready",
@@ -54,8 +55,12 @@
5455
</script>
5556

5657
<Screen {theme} {onclose} heading={t("send.title")} title={t("send.title")} offline={view === "offline" ? t("send.offline") : ""}
57-
stage backdrop={photo} backdropAlt={scene} fallbackArt>
58-
{#snippet hero()}<p class="t-invite words">{words}</p>{/snippet}
58+
stage fallbackArt={!photo}>
59+
{#snippet hero()}
60+
<!-- Photo-fit: the photo is the content, shown whole at the top; words and sheet sit below it. -->
61+
{#if photo}<button class="pf" type="button" onclick={zoom}><img src={photo} alt={scene || t("invite.photoFallback")} onload={lit} /></button>{/if}
62+
<p class="t-invite words">{words}</p>
63+
{/snippet}
5964
<Tip id="send" text="tip.send" />
6065
{#if askName}
6166
<TextField label={t("join.nameLabel")} bind:value={hostName} placeholder={t("join.namePlaceholder")} hint={t("join.nameHint")} max={24} />
@@ -89,6 +94,13 @@
8994

9095
<style>
9196
.words { margin: 0; font-size: calc(2.5rem * var(--text-scale)); line-height: 1.08; max-width: 14ch; }
97+
/* Photo-fit: the photo at its own shape, capped so the main button still fits on a 375 x 667
98+
phone. The words sit under it and the sheet takes the rest, so nothing covers the place. */
99+
.pf { display: block; margin: 0 auto; padding: 0; border: 0; background: none; border-radius: 16px; overflow: hidden; max-width: 100%; cursor: zoom-in; flex: none; }
100+
.pf img { display: block; max-width: 100%; max-height: min(38dvh, 420px); width: auto; height: auto; }
101+
.pf + .words { font-size: calc(2rem * var(--text-scale)); max-width: none; }
102+
:global(.staged > .hero:has(.pf)) { flex: none; justify-content: flex-start; gap: 10px; padding-top: 4px; }
103+
:global(.staged:has(> .hero .pf) > .sheet) { flex: 1; max-height: none; min-height: 0; }
92104
.hostline { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 0; }
93105
.hostline p { margin: 0; }
94106
.lbl { font-weight: 700; opacity: 0.85; }

‎apps/web/src/lib/screens/I1Invite.svelte‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import Tip from "../components/Tip.svelte";
99
import Flame from "../components/Flame.svelte";
1010
import { t } from "#lib/copy/index.ts";
11+
import { lit, zoom } from "#lib/photo-fit.ts";
1112
import type { InviteInfo, Theme } from "../components/parts";
1213
let {
1314
view = "kindling",
@@ -47,9 +48,11 @@
4748
</script>
4849

4950
<Screen {theme} sound {onclose} title={host ? t("invite.title", { host }) : ""} heading={view === "loading" ? t("app.loading") : ""}
50-
stage={(view === "kindling" || view === "lit") && !!info} backdrop={info?.photo ?? ""} backdropAlt={info?.scene ?? ""}>
51+
stage={(view === "kindling" || view === "lit") && !!info}>
5152
{#snippet hero()}
5253
{#if info && (view === "kindling" || view === "lit")}
54+
<!-- Photo-fit: the photo is the content, shown whole at the top; words and sheet sit below it. -->
55+
{#if info.photo}<button class="pf" type="button" onclick={zoom}><img src={info.photo} alt={info.scene || t("invite.photoFallback")} onload={lit} /></button>{/if}
5356
<h1 class="t-invite words">{info.words}</h1>
5457
{/if}
5558
{/snippet}
@@ -96,6 +99,13 @@
9699

97100
<style>
98101
.words { margin: 0; font-size: calc(2.5rem * var(--text-scale)); line-height: 1.08; max-width: 14ch; }
102+
/* Photo-fit: the photo at its own shape, capped so I'm in still fits on a 375 x 667 phone.
103+
The words sit under it and the sheet takes the rest, so nothing covers the place. */
104+
.pf { display: block; margin: 0 auto; padding: 0; border: 0; background: none; border-radius: 16px; overflow: hidden; max-width: 100%; cursor: zoom-in; flex: none; }
105+
.pf img { display: block; max-width: 100%; max-height: min(38dvh, 420px); width: auto; height: auto; }
106+
.pf + .words { font-size: calc(2rem * var(--text-scale)); max-width: none; }
107+
:global(.staged > .hero:has(.pf)) { flex: none; justify-content: flex-start; gap: 10px; padding-top: 4px; }
108+
:global(.staged:has(> .hero .pf) > .sheet) { flex: 1; max-height: none; min-height: 0; }
99109
.where { display: flex; gap: 8px; align-items: flex-start; }
100110
.where p { margin: 0; }
101111
.spark { display: flex; gap: 6px; align-items: center; color: var(--accent-text); font-weight: 700; margin-top: 4px !important; }

0 commit comments

Comments
 (0)