Skip to content

feat(attending): full-bleed photo layout for the share card - #355

Open
ryzizub wants to merge 25 commits into
2026from
feat/attending-fullbleed
Open

feat(attending): full-bleed photo layout for the share card#355
ryzizub wants to merge 25 commits into
2026from
feat/attending-fullbleed

Conversation

@ryzizub

@ryzizub ryzizub commented Sep 10, 2026

Copy link
Copy Markdown
Member

Summary

  • Restores /attending (dropped by the earlier revert Revert "feat(attending): I'm attending share-card generator (#332)" #333 — first commit here is that revert-of-a-revert, kept separate from the layout change) and rebuilds it to Danny's full-bleed mock: the photo covers the whole 1200×1200 card with a radial vignette, text sits directly on it instead of in a small framed well.
  • Everything centered per Dominik's call, including the wordmark in the bottom band.
  • Old framed-well layout is replaced outright, not kept behind a toggle.

Behavior

  • SAFE_SPACE (10% per edge) keeps headline/name text clear of the card's outer margin; the bottom band stays edge-to-edge as chrome, exempt from that rule (same as before).
  • Readability is enforced measurably, not eyeballed: a directional black scrim sits behind the headline and behind the name, at an alpha chosen so a pure-white photo underneath still leaves white text at ~4.6:1 contrast — above the 4.5:1 AA floor for normal text and well past the 3:1 floor for the ≥40px text actually used. A dark photo only raises that ratio.
  • No-photo fallback now fills the whole card with the brand panel + a large centered monogram, instead of a small framed square.
  • Zoom/pan, Download/Share and a11y all carry over unchanged — only the well's size/position changed, not the transform math.

Verification

  • npm run a11y — all 17 audited pages/states pass WCAG 2.2 AA, /attending/ included.
  • A11Y_MOCK=1 astro build — clean build, no type errors.
  • Rendered exports via a local Playwright script against the built page (not committed): with a photo, without a photo (monogram fallback), and against a synthetic overexposed/bright photo to stress-test the scrim — attached to the tracking issue.

ryzizub and others added 25 commits September 10, 2026 20:30
Photo now covers the whole 1200×1200 card with a radial vignette plus
directional scrims behind the headline and name, sized so contrast
holds even against a pure-white photo (measured, not eyeballed). All
text is centered, including the wordmark in the bottom band. Fallback
with no photo fills the card with the brand panel + monogram instead
of a small framed well.
Scrims were gradients fading to zero right where the text sits, so
contrast at the subtitle/name baselines collapsed on a bright photo
(measured ~1.2:1 / ~2.7:1 against the 3:1 floor). Made both scrims a
flat SCRIM_ALPHA plateau across the whole text zone, fading only past
it. Vignette corners now go to pure black (alpha 1) instead of 0.78.
The band wordmark is tinted black on a scratch canvas instead of
in-place, since source-atop against the already-opaque red band was
blacking out its full bounding box, not just the glyph shapes.
Vignette was a single radial gradient spanning the full half-diagonal,
darkening the entire width/height at mid-card, not just the corners.
Tuned inner/outer stops so the horizontal/vertical center lines never
enter the gradient and only the true corners clamp to flat black.

Text scrims were flat plateaus from the card edge to past the text,
covering the safe-space margin even where no text sits. Tightened
each to the actual text bounding box (cap-height to descender + a
small pad), with short fades on both sides instead of one 120px fade.
The top zone's plateau started at the headline's cap-height minus a
small pad, with a fade-in above it back down toward y=0 — leaving a
bare strip of full-strength photo along the very top edge (~48px)
before the fade even began. The bottom zone has no such gap because
the accent band covers its edge; the top has no band, so the fade
itself needs to reach y=0.

Now the plateau runs from y=0 straight through to the subtitle's
clearance line, with the fade kept only on the inner side, easing
back into the photo below.
…t text scrims

Replace the horizontal top/bottom scrims (which read as a band) with a
single radial vignette: clean photo at the card center, fading to solid
black by the edge-midpoint radius, corners clamped to rgb(0,0,0). Since
the radial alone doesn't hold contrast directly behind the
headline/subtitle/name (they sit inside its clear center), each line
gets its own scrim plate sized to its measured glyph bounds instead of
the old full-width bars.

Fixes a real bug surfaced by pixel-measuring the export rather than
trusting the sizing constants: the plates were built from
`actualBoundingBoxAscent`/`Descent` read while `textBaseline` was still
`'middle'` (left over from the no-photo monogram fallback), undersizing
the box so glyphs poked out above it. Forcing `'alphabetic'` at measure
time fixes it.

Also bumps SCRIM_ALPHA 0.82 → 0.88: pixel-measuring isolated the
headline's red word (not just the cream text) as the actual binding
contrast case, and at 0.82 it only cleared AA by ~6% against a
synthetic solid-white photo. Measured worst case is now ~4.97:1.

Bottom band enlarged (108px → 168px, wordmark 56px → 88px, "2026" pill
scaled to match) so the logo reads at a legible size.
…tate

The bare <input type="file"> reset via `event.target.value = ''` after a
successful pick (kept so the same file stays re-selectable), which made the
native control revert to "No file chosen" while the photo was already
loaded — so the form told users nothing was selected right after they
selected something.

Swaps it for a visually-hidden (clip, not display:none) input driven by our
own <button> trigger, with our own rendered state (filename, thumbnail,
Replace/Remove) so the OS string is never shown. Keeps the value-reset,
the 20 MB/non-image guards, and the existing error strings. No restyling of
the surrounding form.

Co-authored-by: multica-agent <github@multica.ai>
…m plates

Darkening radius: outer stop moved from the edge-midpoint radius to the
corner distance, fade starting at 70% of it — the literal 50%-of-edge-radius
fade left the visible photo as an oval ~55% of card width; this restores the
~0.2-0.85 band without touching text position or SAFE_SPACE.

Scrim plates: rounded corners plus a blurred edge feather so they read as a
glow behind the letters instead of stuck-on rectangles, without growing the
plate footprint.

Moving the vignette's clear zone out from under the headline/subtitle/name
removed darkening they used to get for free there, dropping the headline's
red word below 4.5:1 AA against the #f2f0ea worst-case swatch (measured
~4.29:1). SCRIM_ALPHA raised 0.88 -> 0.92 to restore it (~4.73:1) without
moving or resizing anything.
…scrim feather

Vignette fade starting at 70% of the corner radius read as corner-only
darkening on real pixels (photo bleeding to the card edge top/bottom/
left/right-center). Starting the fade at 40% brings the visible photo
band to ~0.22-0.78 on the center axes with edge midpoints roughly
half-dark, matching the mock, while corners stay pure black at the same
outer stop.

Scrim plates now feather with shadowBlur/shadowColor instead of
ctx.filter blur, since WebKit silently ignores canvas filter (confirmed
on an exported render there) and rendered a hard-edged box. shadowBlur
is supported everywhere canvas is; verified matching soft edges on both
Chromium and WebKit exports.

With the vignette helping under the text again, SCRIM_ALPHA eases from
0.92 back to 0.88 (measured ~5.23:1 on the documented #f2f0ea worst
case, still comfortably above the 4.5:1 AA floor).

Co-authored-by: multica-agent <github@multica.ai>
… fill

Both the fill and its shadow used SCRIM_ALPHA, compounding to ~0.985 instead
of the intended 0.88 — QA measured plate interior pixels at (0,0,0)-(5,3,3).
Push the fill offscreen and shift its shadow back by the same offset, so
only the blurred silhouette composites onto the card.
Numbered steps with a numeral/spine progress indicator, a drag-and-drop
photo dropzone sharing the picker's single validation path, and themed
range rails for the framing controls. Card rendering untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
createImageBitmap only decodes HEIC natively in Safari; elsewhere it threw,
and the mime guard rejected HEIC files before decode even ran (Chromium
reports an empty file.type for them). Now the guard also checks the file
extension, and a failed native decode of a HEIC/HEIF file falls back to
heic-to (lazy-imported, so the wasm never ships in the initial bundle) to
convert to PNG and feed that back through the same createImageBitmap path.
The thumbnail preview uses the converted blob too, since <img> can't render
a HEIC blob URL on Chromium either; filename/size shown stay the original
file's.

Co-authored-by: multica-agent <github@multica.ai>
Site-wide CSP blocked the blob: worker heic-to needs and had no
wasm-unsafe-eval, so WebAssembly.instantiate failed on the next step.
Add explicit worker-src 'self' blob:, blob: to img-src, and
'wasm-unsafe-eval' to script-src.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
heic-to's default build calls new Function() in its wasm glue, which
CSP without 'unsafe-eval' blocks on Chromium. The library ships a CSP
build with the same API and no eval usage.

Co-authored-by: multica-agent <github@multica.ai>
- Photo is now required to export: Download/Share disabled until one is
  picked, the "Optional" status word and monogram fallback are gone, and
  the canvas shows an empty placeholder overlay instead of a finished card
  until a photo is chosen.
- Name line moved down roughly halfway toward the accent band, clamped so
  a long name always keeps a 24px gap above the band regardless of its
  fitted font size.
- Vignette switched from corner-radius-relative stops to absolute ones:
  fades from 300px to 600px from center, so edge midpoints and corners are
  both pure black and the photo reads as a circular spotlight, matching
  the mock. SCRIM_ALPHA eased 0.88 -> 0.80 since the stronger vignette now
  darkens more under the text on its own; headline contrast re-measured
  at ~4.68:1 (Playwright, real Bebas Neue, #f2f0ea worst case), still
  above the 4.5:1 AA floor.

Co-authored-by: multica-agent <github@multica.ai>
…ed area

MIN_ZOOM 1 -> 0.5 so a tightly-cropped profile photo can be zoomed out to fit
the card. drawAttendingCard now fills the canvas with pure black before
drawing the photo (previously the bg token) so the area outside the photo at
sub-1x zoom is opaque black, matching the vignette's own black outer stop
instead of leaving a mismatched seam.
Vignette fade/outer stops (300/600px) were fixed regardless of zoom, so
below 1x the photo's own edge appeared before the vignette's black outer
stop. Scale both by k = min(zoom, 1): unchanged at zoom >= 1, halved at
0.5x (150/300) etc, so the photo's edges/corners stay inside full black.

Co-authored-by: multica-agent <github@multica.ai>
Empty state now paints the card with a bundled silhouette portrait and
"Ada Lovelace" (or the typed name), with a SAMPLE corner badge, instead
of a blank canvas with a dashed placeholder that read as another form
field. Download/Share stay disabled until a real photo is picked.
…photo

panBounds returned 0 whenever the drawn photo was smaller than the well
(zoom < 1), locking both pan sliders. Switch to abs(well - drawn)/2 so
the same formula works on either side of 1x: above 1x it's how far the
larger photo can shift before exposing the well edge, below 1x it's how
far the smaller photo can shift while staying inside the well.

Panning below 1x moves the photo itself (not just what's visible), so
the vignette's center now follows the drawn photo's center in that
regime, keeping the black-corner framing intact. At zoom >= 1 the
center stays pinned to the card center, unchanged.
Real photos rarely have exactly equal natural width/height, so
cover-scale only cancels exactly on the true min(width, height) axis;
a 1px natural-dimension mismatch leaves the other axis with an exact
scale/2 source-px slack (deterministic, not jitter). Snap any bound
under that sub-pixel threshold to 0 so 1x locks both sliders cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Center and radii were scaled/recentered by zoom·pan, so panning or
zooming shifted the shadow with the photo. Vignette now fixed to
size/2 center and 300/600px stops at every zoom/pan; only the photo
moves under it.
Below 1x zoom the drawn photo shrinks below the card, so the fixed
300/600px stops cleared past the photo's own edge. Scaling both by
k = min(zoom, 1) brings the fade back to the photo's shorter drawn
side; center stays size/2, pan never shifts it, and zoom >= 1 is
unchanged.

Co-authored-by: multica-agent <github@multica.ai>
# Conflicts:
#	package-lock.json
panBounds locked the photo to always fully cover the square well, which
for a tall/narrow photo pins one axis at 1x and leaves the other unable
to reach the edge. Bounds are now zoom-independent: each axis may pan
until the photo's edge reaches the card's center, backfilled by the
vignette/black fill beyond that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant