From 9b1573e87e95eb614731882eccbd2a4ddd81fc79 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 2 Jun 2026 00:32:24 +0200 Subject: [PATCH] InstantPage V2: flush, un-rounded block media (except audio) All block-media kinds except .audio now lay out edge-to-edge (0 inset) with cornerRadius 0; the bubble's existing rounded containerNode clip rounds media at the bubble edge. Small images keep natural size (not upscaled); captions stay inset. Shared instantPageV2MediaFrame helper + flush flag on the two media layout helpers. V1 unchanged. Invariants documented in docs/instantpage-richtext.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/instantpage-richtext.md | 20 ++ .../2026-06-01-instantpage-v2-media-flush.md | 338 ------------------ ...06-01-instantpage-v2-media-flush-design.md | 145 -------- .../Sources/InstantPageV2Layout.swift | 114 ++++-- 4 files changed, 100 insertions(+), 517 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-01-instantpage-v2-media-flush.md delete mode 100644 docs/superpowers/specs/2026-06-01-instantpage-v2-media-flush-design.md diff --git a/docs/instantpage-richtext.md b/docs/instantpage-richtext.md index 623375bd42..9eb6f894e6 100644 --- a/docs/instantpage-richtext.md +++ b/docs/instantpage-richtext.md @@ -55,6 +55,26 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter - **The 10pt rounded outer border is `contentView.layer`'s own border, NOT sublayers.** `v2TableCornerRadius = 10.0` (`InstantPageV2Layout.swift`). The renderer sets `contentView.layer.cornerRadius`/`borderColor`/`borderWidth = bordered ? v2TableBorderWidth : 0.0` in BOTH `init` and `update` (the four straight outer-edge rect layers were removed; `lineLayers` now holds only inner grid lines). **Border-only — deliberately no `masksToBounds`:** `cornerRadius` rounds the layer's border without clipping contents (filled corner cells round their own fills separately — see next bullet), and there is **zero interaction with the streaming reveal mask** (`contentView.layer.mask`, set only during AI streaming) — the border reveals row-by-row with the rows and is part of the masked layer. The rounded card belongs to the grid (scrolls with it). For a non-empty-title table (never produced by markdown/AI), the border wraps title+grid since `contentView` includes the title region — an accepted, approved nuance. - **Filled corner cells round their own fills to match the border.** A header/striped cell's background is a stripe `CALayer`; `tableStripeCornerMask(cellFrame:gridWidth:gridHeight:effectiveBorderWidth:)` detects which grid corners the cell's (grid-local) frame touches — `firstCol/firstRow` via `frame.min{X,Y} <= effectiveBorderWidth/2 + 0.5`, `lastCol/lastRow` via `frame.max{X,Y} >= grid{Width,Height} - …` (gridWidth = `item.contentSize.width`, gridHeight = `item.contentSize.height - gridOffsetY`) — and rounds only those corners: `stripe.cornerRadius = max(0, v2TableCornerRadius - effectiveBorderWidth)` (the `-borderWidth` leaves an even border ring; borderless → full radius) + `stripe.maskedCorners`, in BOTH `init` and `update`. A `CALayer`'s `backgroundColor` honors `cornerRadius`+`maskedCorners` with no `masksToBounds`. A full-width (colspan) header rounds both top corners; a one-row filled table rounds all four; bottom corners round only when the last row is filled. The empty-mask branch resets `cornerRadius = 0` **and** `maskedCorners = []` so reused stripes (persist across streaming chunks) don't keep stale rounding. Detection is grid-local, so it's independent of the `contentInset` shift / horizontal scroll. +## InstantPage V2 block media — flush (edge-to-edge), un-rounded + +Every V2 block-media kind **except `.audio`** lays out **flush** with the bubble interior (0 inset, full bounding width) and **un-rounded** (cornerRadius 0). The bubble's existing rounded clipping container rounds any media that meets the bubble's top/bottom edge. V1 (`InstantPageLayout.swift`) is unchanged. (Audio keeps the legacy inset + 8pt rounding.) + +### Where things live + +| File | Responsibility | +|---|---| +| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `instantPageV2MediaFrame(naturalSize:flush:cornerRadius:boundingWidth:horizontalInset:)` — the shared frame helper; `instantPageV2MediaEdgeBleed` constant; the `flush: Bool` parameter on `layoutTypedMediaWithCaption` (image/video/webEmbed-cover/map) and `layoutMediaWithCaption` (audio/webEmbed-placeholder/postEmbed/collage/slideshow/channelBanner/relatedArticles). | +| `submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift`, `…/InstantPageRenderer.swift` (`InstantPageV2MediaPlaceholderView`) | Renderer — **no change needed**: every media view + the placeholder view already does `clipsToBounds = item.cornerRadius > 0.0`, so cornerRadius 0 means the view doesn't self-clip; the bubble's `containerNode` clips. | +| `…/Chat/ChatMessageRichDataBubbleContentNode/…` | The clipping container: `containerNode` (`clipsToBounds = true`, `cornerRadius = layoutConstants.image.defaultCornerRadius` ≈ 15–16pt) is what rounds flush media at the bubble edge. | + +### Non-obvious invariants + +- **`flush` is a parameter, not inferred from cornerRadius.** All media call sites pass `flush: true` **except `.audio`** (`flush: false`). On the flush path the helper forces the returned corner radius to `0` regardless of the caller's `cornerRadius` argument (the legacy `8.0`/`0.0` args at the call sites are now inert on the flush path — kept as-is, documented in the helper). +- **Small images are NOT upscaled.** The `scale = min(availableWidth / naturalSize.width, 1.0)` cap is kept (now against `availableWidth = boundingWidth`). A small image stays at natural size, **flush-left at x = 0** (not stretched to full width). Large images (the common server/AI case) fill the width. +- **Full-width media bleeds `instantPageV2MediaEdgeBleed` (4pt) past the trailing edge.** The pageView sits at `x: -1` inside `containerNode` (a border-hiding hairline), so a frame at `x: 0, width: boundingWidth` falls ~1px short of the container's right rounded-clip edge → a 1px corner notch. A small over-bleed on **full-width** items only (`fillsWidth = scaledSize.width >= availableWidth - 1.0`) closes it; a genuinely small image gets no bleed. **The bleed never widens the bubble** because `layoutInstantPageV2` clamps `contentSize.width = min(maxX, boundingWidth)` (gated by `context.fitToWidth`, which both callers — the rich bubble and the send preview — pass `true`). +- **Captions stay inset.** `layoutCaptionAndCredit` is still called with the page `horizontalInset` and offset by the **un-bled** `scaledSize.height`; the caption/credit text is inset under a full-bleed image. The `isCover && captionHeight > 0` cover-padding block is unchanged. +- **Audio is the lone exception** and routes through the non-flush branch of `instantPageV2MediaFrame` (inset by `horizontalInset`, caller's cornerRadius), reproducing the legacy behavior exactly. + ## InstantPage V2 text item height (true font line box) `layoutTextItem` (`InstantPageV2Layout.swift`) sizes a `.text` item to the **true font line height**, not the cap box. A single-line item measures exactly `fontAscent + fontDescentBelowBaseline` (`A + D`); the old behavior was the cap box `fontLineHeight = floor(fontAscent + fontDescent)` (`A − D`). diff --git a/docs/superpowers/plans/2026-06-01-instantpage-v2-media-flush.md b/docs/superpowers/plans/2026-06-01-instantpage-v2-media-flush.md deleted file mode 100644 index 3d6746c3cb..0000000000 --- a/docs/superpowers/plans/2026-06-01-instantpage-v2-media-flush.md +++ /dev/null @@ -1,338 +0,0 @@ -# Flush (edge-to-edge) un-rounded InstantPage V2 block media — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make every InstantPage **V2** block-media kind except `.audio` render flush with the bubble interior (0 inset) and un-rounded (cornerRadius 0), relying on the bubble's existing rounded-corner clipping container. - -**Architecture:** One file changes — `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift`. A new shared helper `instantPageV2MediaFrame(...)` computes the media frame: in `flush` mode it uses full width at `x: 0`, forces `cornerRadius 0`, and bleeds a full-width item a few points past the right edge so the rounded clip container rounds the trailing corners cleanly (the existing `contentSize.width = min(maxX, boundingWidth)` clamp prevents the bleed from widening the bubble). The two media-layout helpers (`layoutTypedMediaWithCaption`, `layoutMediaWithCaption`) route through it and gain a `flush: Bool` parameter; every media call site passes `flush: true` except `.audio`. No renderer change — the V2 media views and the placeholder view already disable their own clip when `cornerRadius == 0`. - -**Tech Stack:** Swift, Bazel (full-app build is the only build/compile gate — there are no unit tests in this project). - ---- - -## Spec - -See `docs/superpowers/specs/2026-06-01-instantpage-v2-media-flush-design.md`. - -## Background an implementer needs - -- **This project has no unit tests** (CLAUDE.md: "No tests are used at the moment"). The verification gate is the **full Bazel build** (it compiles every module; the enum/signature changes are compile-enforced) plus a best-effort manual visual check. -- **Build is slow (~minutes) and must be driven from the top-level session**, not a backgrounded subagent (a backgrounded subagent build gets torn down on yield). Prefix with `source ~/.zshrc` to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`. -- **Geometry the bleed accounts for:** in `ChatMessageRichDataBubbleContentNode`, the clipping `containerNode` sits at self-`(1, 1)` with `clipsToBounds = true` and `cornerRadius = layoutConstants.image.defaultCornerRadius` (≈ 15–16pt); the `pageView` sits at `x: -1` inside it. So a page-layout item at `x: 0, width: boundingWidth` falls ~1px short of the container's right clip edge, leaving a 1px corner notch. A small symmetric over-bleed on full-width media closes it; the `min(maxX, boundingWidth)` clamp (line 552, active because both callers pass `fitToWidth: true`) keeps `contentSize.width` at `boundingWidth` so the bubble does not widen. -- **Captions stay inset** — `layoutCaptionAndCredit` is called with the page `horizontalInset` and is unchanged. -- **Render side needs no change:** `InstantPageV2MediaImageView/VideoView/MapView/CoverImageView.update(...)` and `InstantPageV2MediaPlaceholderView.update(...)` all do `self.clipsToBounds = item.cornerRadius > 0.0` and `self.layer.cornerRadius = item.cornerRadius`. With `cornerRadius 0` the media view does not clip itself; the `containerNode` clips. - ---- - -## Task 1: Flush media layout in `InstantPageV2Layout.swift` - -**Files:** -- Modify: `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` - -All edits are in this one file. Steps 1–3 add the shared helper and route the two layout helpers through it; step 4 updates the 11 call sites. The file will not compile until step 4 is complete (the new required `flush:` parameter) — that is expected; the build is Task 2. - -- [ ] **Step 1: Add the bleed constant and shared frame helper** - -Insert the following immediately **above** the existing `private func layoutTypedMediaWithCaption(` declaration (currently at line ~1650, just after the closing `}` / `return (items, totalHeight)` of `layoutCaptionAndCredit`'s neighbor at line ~1645): - -```swift -// Points a full-width flush media item bleeds past the bubble interior on the trailing -// edge so the rounded `containerNode` clip (see ChatMessageRichDataBubbleContentNode) rounds -// the trailing corners with no 1px background sliver. Harmless: the -// `contentSize.width = min(maxX, boundingWidth)` clamp keeps it from widening the bubble. -private let instantPageV2MediaEdgeBleed: CGFloat = 4.0 - -// Computes the laid-out frame for a block-media item. -// -// `flush == true` (every block media except audio): the media is edge-to-edge (x = 0, full -// `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping -// container to round media that meets the bubble's top/bottom edge. A media item that fills the -// full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant). -// A media item narrower than the full width (a small image — NOT upscaled, the `min(_, 1.0)` -// scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed. -// -// `flush == false` (audio only): legacy behavior — inset by `horizontalInset` on each side with -// the caller-supplied corner radius. -// -// Returns the frame, the un-bled scaled content size (the caption is offset by -// `scaledSize.height`), and the effective corner radius to stamp on the item. -private func instantPageV2MediaFrame( - naturalSize: CGSize, - flush: Bool, - cornerRadius: CGFloat, - boundingWidth: CGFloat, - horizontalInset: CGFloat -) -> (frame: CGRect, scaledSize: CGSize, cornerRadius: CGFloat) { - let availableWidth = flush ? boundingWidth : (boundingWidth - horizontalInset * 2.0) - let scaledSize: CGSize - if naturalSize.width > 0.0 && naturalSize.height > 0.0 { - let scale = min(availableWidth / naturalSize.width, 1.0) - scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) - } else { - scaledSize = CGSize(width: availableWidth, height: naturalSize.height) - } - - if flush { - // `floor(x) > x - 1` always, so a full-width item (scaledSize.width == floor(availableWidth)) - // always trips this; a genuinely smaller image does not. - let fillsWidth = scaledSize.width >= availableWidth - 1.0 - let frameWidth = fillsWidth ? boundingWidth + instantPageV2MediaEdgeBleed : scaledSize.width - let frame = CGRect(x: 0.0, y: 0.0, width: frameWidth, height: scaledSize.height) - return (frame, scaledSize, 0.0) - } else { - let frame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height) - return (frame, scaledSize, cornerRadius) - } -} -``` - -- [ ] **Step 2: Route `layoutTypedMediaWithCaption` through the helper + add `flush`** - -Replace the head of `layoutTypedMediaWithCaption` — from its signature through the `var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, cornerRadius)]` line (currently lines ~1650–1670). Old: - -```swift -private func layoutTypedMediaWithCaption( - produceItem: (CGRect, CGFloat) -> InstantPageV2LaidOutItem, - naturalSize: CGSize, - caption: InstantPageCaption, - isCover: Bool, - cornerRadius: CGFloat, - boundingWidth: CGFloat, - horizontalInset: CGFloat, - context: inout LayoutContext -) -> [InstantPageV2LaidOutItem] { - let availableWidth = boundingWidth - horizontalInset * 2.0 - let scaledSize: CGSize - if naturalSize.width > 0.0 && naturalSize.height > 0.0 { - let scale = min(availableWidth / naturalSize.width, 1.0) - scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) - } else { - scaledSize = CGSize(width: availableWidth, height: naturalSize.height) - } - - let mediaFrame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height) - var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, cornerRadius)] -``` - -New: - -```swift -private func layoutTypedMediaWithCaption( - produceItem: (CGRect, CGFloat) -> InstantPageV2LaidOutItem, - naturalSize: CGSize, - caption: InstantPageCaption, - isCover: Bool, - cornerRadius: CGFloat, - flush: Bool, - boundingWidth: CGFloat, - horizontalInset: CGFloat, - context: inout LayoutContext -) -> [InstantPageV2LaidOutItem] { - let (mediaFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame( - naturalSize: naturalSize, - flush: flush, - cornerRadius: cornerRadius, - boundingWidth: boundingWidth, - horizontalInset: horizontalInset - ) - var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, effectiveCornerRadius)] -``` - -The remainder of the function (the `layoutCaptionAndCredit(..., offset: scaledSize.height, ...)` call and the `isCover && captionHeight > 0.0` block) is unchanged — `scaledSize` is still in scope. - -- [ ] **Step 3: Route `layoutMediaWithCaption` through the helper + add `flush`** - -Replace the head of `layoutMediaWithCaption` — from its signature through the `var result: [InstantPageV2LaidOutItem] = [.mediaPlaceholder(placeholderItem)]` line (currently lines ~1698–1730). Old: - -```swift -private func layoutMediaWithCaption( - kind: InstantPageV2MediaPlaceholderKind, - naturalSize: CGSize, - caption: InstantPageCaption, - isCover: Bool, - cornerRadius: CGFloat, - boundingWidth: CGFloat, - horizontalInset: CGFloat, - context: inout LayoutContext -) -> [InstantPageV2LaidOutItem] { - // Scale naturalSize to fit within (boundingWidth - horizontalInset*2) × naturalSize.height. - let availableWidth = boundingWidth - horizontalInset * 2.0 - let scaledSize: CGSize - if naturalSize.width > 0.0 && naturalSize.height > 0.0 { - let scale = min(availableWidth / naturalSize.width, 1.0) - scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) - } else { - scaledSize = CGSize(width: availableWidth, height: naturalSize.height) - } - - let placeholderFrame = CGRect( - x: horizontalInset, - y: 0.0, - width: scaledSize.width, - height: scaledSize.height - ) - let placeholderItem = InstantPageV2MediaPlaceholderItem( - frame: placeholderFrame, - kind: kind, - cornerRadius: cornerRadius - ) - - var result: [InstantPageV2LaidOutItem] = [.mediaPlaceholder(placeholderItem)] -``` - -New: - -```swift -private func layoutMediaWithCaption( - kind: InstantPageV2MediaPlaceholderKind, - naturalSize: CGSize, - caption: InstantPageCaption, - isCover: Bool, - cornerRadius: CGFloat, - flush: Bool, - boundingWidth: CGFloat, - horizontalInset: CGFloat, - context: inout LayoutContext -) -> [InstantPageV2LaidOutItem] { - let (placeholderFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame( - naturalSize: naturalSize, - flush: flush, - cornerRadius: cornerRadius, - boundingWidth: boundingWidth, - horizontalInset: horizontalInset - ) - let placeholderItem = InstantPageV2MediaPlaceholderItem( - frame: placeholderFrame, - kind: kind, - cornerRadius: effectiveCornerRadius - ) - - var result: [InstantPageV2LaidOutItem] = [.mediaPlaceholder(placeholderItem)] -``` - -The remainder (the `layoutCaptionAndCredit(..., offset: scaledSize.height, ...)` call and the `isCover && captionHeight > 0.0` block) is unchanged. - -- [ ] **Step 4: Add `flush:` to all 11 media call sites in `layoutBlock`** - -In each call below, insert the `flush:` argument line **immediately after** the existing `cornerRadius:` argument line (so argument order matches the new signatures). Use the listed anchor (the `produceItem` body's `.mediaXxx(...)` or the `kind:` value) to disambiguate the otherwise-similar calls. - -`layoutTypedMediaWithCaption` calls — all get `flush: true`: - -1. `.image` — `produceItem` returns `.mediaImage(...)`; `cornerRadius: 8.0,` → add `flush: true,` -2. `.video` — `produceItem` returns `.mediaVideo(...)`; `cornerRadius: 8.0,` → add `flush: true,` -3. `.webEmbed` cover — `produceItem` returns `.mediaCoverImage(...)`; `cornerRadius: 0.0,` → add `flush: true,` -4. `.map` — `produceItem` returns `.mediaMap(...)`; `cornerRadius: 8.0,` → add `flush: true,` - -`layoutMediaWithCaption` calls: - -5. `.audio` — `kind: .audio`; `isCover: false, cornerRadius: 8.0,` → add `flush: false,` (**the only `false`**) -6. `.webEmbed` no-cover — `kind: .webEmbed`; `isCover: false, cornerRadius: 0.0,` → add `flush: true,` -7. `.postEmbed` — `kind: .postEmbed`; `isCover: false, cornerRadius: 8.0,` → add `flush: true,` -8. `.collage` — `kind: .collage`; `isCover: false, cornerRadius: 8.0,` → add `flush: true,` -9. `.slideshow` — `kind: .slideshow`; `isCover: false, cornerRadius: 8.0,` → add `flush: true,` -10. `.channelBanner` — `kind: .channelBanner`; `isCover: false, cornerRadius: 0.0,` → add `flush: true,` -11. `.relatedArticles` — `kind: .relatedArticles`; `isCover: false, cornerRadius: 0.0,` → add `flush: true,` - -For example, the `.audio` call becomes: - -```swift - case let .audio(_, caption): - return layoutMediaWithCaption(kind: .audio, - naturalSize: CGSize(width: boundingWidth, height: 56.0), caption: caption, - isCover: false, cornerRadius: 8.0, flush: false, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) -``` - -and the `.collage` call becomes: - -```swift - case let .collage(_, caption): - return layoutMediaWithCaption(kind: .collage, - naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption, - isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) -``` - -and the `.image` call's argument tail becomes: - -```swift - naturalSize: naturalSize, - caption: caption, - isCover: isCover, - cornerRadius: 8.0, - flush: true, - boundingWidth: boundingWidth, - horizontalInset: horizontalInset, - context: &context -``` - -- [ ] **Step 5: Sanity-grep — exactly one `flush: false` and ten `flush: true`** - -Run: - -```bash -grep -n "flush:" submodules/InstantPageUI/Sources/InstantPageV2Layout.swift -``` - -Expected: the two helper signatures (`flush: Bool`), the two `instantPageV2MediaFrame(... flush: flush ...)` calls, **one** `flush: false` (audio), and **ten** `flush: true` (the other media call sites) — i.e. 11 call-site `flush:` arguments total plus the helper-internal references. - ---- - -## Task 2: Build and commit - -**Files:** none (verification + commit) - -- [ ] **Step 1: Run the full Bazel build (the compile gate)** - -Run from the repo root, in the **top-level session** (not a backgrounded subagent), capturing the real exit code: - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 -``` - -Expected: build succeeds. The signature change makes every media call site a compile-time gate, so a missing/extra `flush:` argument fails here. If a Swift worker fails **without** a surfaced compile error (the known silent-worker failure mode), re-run once; if it persists, re-read the changed hunks for a malformed argument list. - -- [ ] **Step 2: Commit** - -```bash -git add submodules/InstantPageUI/Sources/InstantPageV2Layout.swift -git commit -m "InstantPage V2: flush, un-rounded block media (except audio) - -All block-media kinds except .audio now lay out edge-to-edge (0 inset) with -cornerRadius 0; the bubble's existing rounded containerNode clip rounds media at -the bubble edge. Small images keep natural size (not upscaled); captions stay -inset. Shared instantPageV2MediaFrame helper + flush flag on the two media -layout helpers. V1 unchanged. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Manual visual verification (best-effort) - -**Files:** none - -- [ ] **Step 1: Verify a rich message with media renders flush + un-rounded** - -Block media in rich messages comes from server/AI rich content — the markdown compose path cannot produce `.image`/`.video` blocks (the markdown→InstantPage converter skips media), so a fresh rich-data bubble containing an image is reached via a received AI/server rich message. If such a message is available, confirm: - -- The image/video spans the full bubble interior width (no ~11pt side inset). -- No 8pt corner rounding on the media itself; where the media meets the bubble's top/bottom edge, the corner is rounded **to the bubble's radius** (clipped by `containerNode`) with no 1px background sliver on the trailing edge. -- A small image (narrower than the bubble) sits flush-left at the bubble's left edge at its natural size (not upscaled), caption text still inset below it. -- `.audio` blocks (if any) are unchanged (inset + rounded). - -If no server/AI rich message with media is reachable in the test environment, record that the visual check was deferred and rely on the build as the gate — note this in the completion summary rather than claiming the visual check passed. - ---- - -## Self-review notes - -- **Spec coverage:** scope table (all media except audio, V2 only) → Task 1 step 4; no-upscale → `min(_, 1.0)` kept in `instantPageV2MediaFrame`; square corners + rounded-container clip → `cornerRadius 0` returned by the helper + no renderer change (documented in Background); captions inset → `layoutCaptionAndCredit` untouched; 1px hairline / no-widen → `instantPageV2MediaEdgeBleed` + the `min(maxX, boundingWidth)` clamp (Background + helper). -- **Type consistency:** the helper is named `instantPageV2MediaFrame` and the constant `instantPageV2MediaEdgeBleed` everywhere; both layout helpers gain `flush: Bool` in the same signature position (after `cornerRadius:`, before `boundingWidth:`); call sites insert `flush:` in that same position. -- **No placeholders:** every step shows the exact code/command. diff --git a/docs/superpowers/specs/2026-06-01-instantpage-v2-media-flush-design.md b/docs/superpowers/specs/2026-06-01-instantpage-v2-media-flush-design.md deleted file mode 100644 index 3c00bed559..0000000000 --- a/docs/superpowers/specs/2026-06-01-instantpage-v2-media-flush-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# InstantPage V2 — flush (edge-to-edge) un-rounded block media - -## Goal - -In the InstantPage **V2** renderer (rich-text chat bubbles), block media is -currently laid out as an **inset rounded rect**: the media frame is inset by the -page `horizontalInset` (11pt) on each side and drawn with an 8pt corner radius. -Change it so block media is **flush with the bubble interior (0-inset)** and -**un-rounded (cornerRadius 0)**, relying on the bubble's existing rounded-corner -clipping container to round media that reaches the bubble's top/bottom edge. - -**V1 is out of scope** — only the V2 layout/renderer changes. - -## Scope of "block media" - -Every block-media kind in the V2 layout goes flush + un-rounded **except -`.audio`**: - -| Block | Today | After | -|---|---|---| -| `.image` | inset, r=8 | flush, r=0 | -| `.video` | inset, r=8 | flush, r=0 | -| `.map` | inset, r=8 | flush, r=0 | -| `.webEmbed` (cover image) | inset, r=0 | flush, r=0 | -| `.webEmbed` (grey placeholder) | inset, r=0 | flush, r=0 | -| `.postEmbed` | inset, r=8 | flush, r=0 | -| `.collage` | inset, r=8 | flush, r=0 | -| `.slideshow` | inset, r=8 | flush, r=0 | -| `.channelBanner` | inset, r=0 | flush, r=0 | -| `.relatedArticles` | inset, r=0 | flush, r=0 | -| **`.audio`** | inset, r=8 | **unchanged** (inset, r=8) | - -`.audio` is the single exception — it keeps today's inset + 8pt rounding. - -## Decisions (from brainstorming) - -- **Small images are NOT upscaled.** Keep the existing `scale = min(availableWidth / - naturalSize.width, 1.0)` cap. A small image renders at its natural pixel size, - flush-left at x=0 (not stretched to full width). Large images (the common - AI-generated case) still fill the width. -- **Square corners everywhere.** No per-corner rounding logic. Media views draw - square; the existing rounded-corner clipping container rounds whatever media - reaches the bubble's top/bottom edge. -- **Captions stay inset.** Caption/credit text is laid out separately at - `horizontalInset` and is unchanged — inset text under a full-bleed image. - -## Architecture - -Two layout helpers in -`submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` are the only places -that compute the inset media frame and carry the corner radius: - -- `layoutTypedMediaWithCaption(produceItem:naturalSize:caption:isCover:cornerRadius:boundingWidth:horizontalInset:context:)` - — used by `.image`, `.video`, `.webEmbed` cover, `.map`. -- `layoutMediaWithCaption(kind:naturalSize:caption:isCover:cornerRadius:boundingWidth:horizontalInset:context:)` - — used by `.audio`, `.webEmbed` placeholder, `.postEmbed`, `.collage`, - `.slideshow`, `.channelBanner`, `.relatedArticles`. - -Both compute `availableWidth = boundingWidth − horizontalInset*2`, place the -media at `x: horizontalInset`, and pass a caller-supplied `cornerRadius` onto the -produced item. - -### Change: add a `flush` parameter to both helpers - -Add `flush: Bool` to both helper signatures. When `flush == true`: - -- `availableWidth = boundingWidth` (full width, was `boundingWidth − horizontalInset*2`). -- media frame `x: 0` (was `horizontalInset`). -- the produced item's `cornerRadius` is forced to `0` (the per-call-site - `cornerRadius` argument becomes irrelevant on the flush path). - -When `flush == false` (audio only): today's behavior verbatim (inset frame, -caller's corner radius). - -The `scale = min(availableWidth / naturalSize.width, 1.0)` cap is **kept** in both -paths — only `availableWidth` and the frame `x` change, so the no-upscale -behavior is preserved. - -### Call sites - -Every media `case` in `layoutBlock` passes `flush: true` **except `.audio`**, -which passes `flush: false`. The cover-padding logic (`isCover && captionHeight > -0`) and the caption/credit layout (`layoutCaptionAndCredit` at `horizontalInset`) -are unchanged in both helpers. - -### Render side — no change - -`InstantPageV2MediaViews.swift` already gates clipping on the radius: -`self.clipsToBounds = item.cornerRadius > 0.0` and `self.layer.cornerRadius = -item.cornerRadius` in every media view's `update(...)`. With `cornerRadius 0` the -media view does **not** clip itself; the bubble's container does (below). No edit -to the renderer or the media views is required. - -### Rounded clip is the existing `containerNode` - -In -`submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` -the `containerNode` already: - -- sets `clipsToBounds = true` (init), and -- sets `cornerRadius = layoutConstants.image.defaultCornerRadius` (≈ 15–16pt) on - every layout pass. - -So flush, square-cornered media at the bubble's top/bottom edge is clipped to the -bubble's rounded shape automatically. **No new clipping container is introduced.** - -## Implementation detail to resolve in the plan: the 1px hairline - -The pageView is positioned at `x: −1` inside the `containerNode` (an existing -hairline that hides the bubble border seam): `containerNode` is at self-(1, 1) -with width `boundingWidth − 2`, and the pageView is at containerNode-`x: −1`. - -Consequence: a flush media frame at page-layout `x: 0`, `width: boundingWidth` -covers the container's clip region only to within ~1px on the right, and the -contentSize-width interaction must be handled so the bubble is not widened. The -plan must: - -1. Make flush media cover the rounded `containerNode` clip region edge-to-edge - (small symmetric bleed under the rounded corners is fine — the container - clips it), leaving no 1px bubble-background sliver, **and** -2. Ensure the flush media frame does **not** inflate the layout's - `contentSize.width` beyond the bounding width (which would widen the bubble or - feed back into the suggested width). Clamp `contentSize.width` to the bounding - width if needed. - -This is mechanical; the exact arithmetic is deferred to the plan. - -## Out of scope / unchanged - -- **V1** renderer (`InstantPageLayout.swift`, V1 `InstantPageRenderer`). -- **`.audio`** block (stays inset + r=8). -- **Captions/credits** (stay inset). -- **Streaming reveal** — media still contributes `frame.width` to the reveal - cost map; a wider flush frame slightly increases that cost (expected, benign). -- **Status node** (date/time/checks) placement — trails text only when the - bottom-most item is `.text`; media being flush does not change that logic. -- **Tap / gallery routing, edit / copy / paste round-trips** — the edit and copy - converters already skip media; nothing changes. - -## Testing - -No unit tests in this project (per CLAUDE.md). Verification is a full Bazel build -plus a manual check that a rich message containing an image and/or video renders -edge-to-edge with no corner rounding and a correctly-clipped rounded top/bottom -where the media meets the bubble edge, with the caption still inset. diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index b651dcd81b..911fb2781f 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -688,6 +688,7 @@ private func layoutBlock( caption: caption, isCover: isCover, cornerRadius: 8.0, + flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context @@ -727,6 +728,7 @@ private func layoutBlock( caption: caption, isCover: isCover, cornerRadius: 8.0, + flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context @@ -738,7 +740,7 @@ private func layoutBlock( case let .audio(_, caption): return layoutMediaWithCaption(kind: .audio, naturalSize: CGSize(width: boundingWidth, height: 56.0), caption: caption, - isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, + isCover: false, cornerRadius: 8.0, flush: false, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .webEmbed(url, _, dimensions, caption, _, _, coverId): @@ -800,6 +802,7 @@ private func layoutBlock( caption: caption, isCover: false, cornerRadius: 0.0, + flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context @@ -809,26 +812,26 @@ private func layoutBlock( let h: CGFloat = CGFloat(dimensions?.height ?? 240) return layoutMediaWithCaption(kind: .webEmbed, naturalSize: CGSize(width: boundingWidth, height: h), caption: caption, - isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, + isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) } case let .postEmbed(_, _, _, _, _, _, caption): return layoutMediaWithCaption(kind: .postEmbed, naturalSize: CGSize(width: boundingWidth, height: 140.0), caption: caption, - isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, + isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .collage(_, caption): return layoutMediaWithCaption(kind: .collage, naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption, - isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, + isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .slideshow(_, caption): return layoutMediaWithCaption(kind: .slideshow, naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption, - isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, + isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .channelBanner(channel): @@ -836,7 +839,7 @@ private func layoutBlock( return layoutMediaWithCaption(kind: .channelBanner, naturalSize: CGSize(width: boundingWidth, height: 60.0), caption: InstantPageCaption(text: .empty, credit: .empty), - isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, + isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .map(latitude, longitude, zoom, dimensions, caption): @@ -875,6 +878,7 @@ private func layoutBlock( caption: caption, isCover: false, cornerRadius: 8.0, + flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context @@ -885,7 +889,7 @@ private func layoutBlock( return layoutMediaWithCaption(kind: .relatedArticles, naturalSize: CGSize(width: boundingWidth, height: max(h, 80.0)), caption: InstantPageCaption(text: .empty, credit: .empty), - isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, + isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .formula(latex): @@ -1644,6 +1648,58 @@ private func layoutCaptionAndCredit( return (items, totalHeight) } +// How many points a full-width flush media item bleeds past the bubble interior on the +// trailing edge so the rounded `containerNode` clip (see ChatMessageRichDataBubbleContentNode) rounds +// the trailing corners with no 1px background sliver. Harmless: the +// `contentSize.width = min(maxX, boundingWidth)` clamp keeps it from widening the bubble. +private let instantPageV2MediaEdgeBleed: CGFloat = 4.0 + +// Computes the laid-out frame for a block-media item. +// +// `flush == true` (every block media except audio): the media is edge-to-edge (x = 0, full +// `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping +// container to round media that meets the bubble's top/bottom edge. A media item that fills the +// full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant). +// A media item narrower than the full width (a small image — NOT upscaled, the `min(_, 1.0)` +// scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed. +// (The `cornerRadius` argument is ignored when `flush == true` — flush media is always +// un-rounded; callers may still pass their legacy radius, it has no effect.) +// +// `flush == false` (audio only): legacy behavior — inset by `horizontalInset` on each side with +// the caller-supplied corner radius. +// +// Returns the frame, the un-bled scaled content size (the caption is offset by +// `scaledSize.height`), and the effective corner radius to stamp on the item. +private func instantPageV2MediaFrame( + naturalSize: CGSize, + flush: Bool, + cornerRadius: CGFloat, + boundingWidth: CGFloat, + horizontalInset: CGFloat +) -> (frame: CGRect, scaledSize: CGSize, cornerRadius: CGFloat) { + let availableWidth = flush ? boundingWidth : (boundingWidth - horizontalInset * 2.0) + let scaledSize: CGSize + if naturalSize.width > 0.0 && naturalSize.height > 0.0 { + let scale = min(availableWidth / naturalSize.width, 1.0) + scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) + } else { + scaledSize = CGSize(width: availableWidth, height: naturalSize.height) + } + + if flush { + // `floor(x) > x - 1` always, so a full-width item (scaledSize.width == floor(availableWidth)) + // always trips this; a genuinely smaller image does not. (availableWidth == boundingWidth + // in the flush branch, so the bleed below extends past the full bounding width.) + let fillsWidth = scaledSize.width >= availableWidth - 1.0 + let frameWidth = fillsWidth ? boundingWidth + instantPageV2MediaEdgeBleed : scaledSize.width + let frame = CGRect(x: 0.0, y: 0.0, width: frameWidth, height: scaledSize.height) + return (frame, scaledSize, 0.0) + } else { + let frame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height) + return (frame, scaledSize, cornerRadius) + } +} + /// Variant of `layoutMediaWithCaption` that emits a caller-produced typed media item /// instead of a `.mediaPlaceholder`. The frame-fitting logic + caption/credit text item /// layout is otherwise identical. @@ -1653,21 +1709,19 @@ private func layoutTypedMediaWithCaption( caption: InstantPageCaption, isCover: Bool, cornerRadius: CGFloat, + flush: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { - let availableWidth = boundingWidth - horizontalInset * 2.0 - let scaledSize: CGSize - if naturalSize.width > 0.0 && naturalSize.height > 0.0 { - let scale = min(availableWidth / naturalSize.width, 1.0) - scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) - } else { - scaledSize = CGSize(width: availableWidth, height: naturalSize.height) - } - - let mediaFrame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height) - var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, cornerRadius)] + let (mediaFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame( + naturalSize: naturalSize, + flush: flush, + cornerRadius: cornerRadius, + boundingWidth: boundingWidth, + horizontalInset: horizontalInset + ) + var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, effectiveCornerRadius)] let (captionItems, captionHeight) = layoutCaptionAndCredit( caption, @@ -1701,30 +1755,22 @@ private func layoutMediaWithCaption( caption: InstantPageCaption, isCover: Bool, cornerRadius: CGFloat, + flush: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { - // Scale naturalSize to fit within (boundingWidth - horizontalInset*2) × naturalSize.height. - let availableWidth = boundingWidth - horizontalInset * 2.0 - let scaledSize: CGSize - if naturalSize.width > 0.0 && naturalSize.height > 0.0 { - let scale = min(availableWidth / naturalSize.width, 1.0) - scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) - } else { - scaledSize = CGSize(width: availableWidth, height: naturalSize.height) - } - - let placeholderFrame = CGRect( - x: horizontalInset, - y: 0.0, - width: scaledSize.width, - height: scaledSize.height + let (placeholderFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame( + naturalSize: naturalSize, + flush: flush, + cornerRadius: cornerRadius, + boundingWidth: boundingWidth, + horizontalInset: horizontalInset ) let placeholderItem = InstantPageV2MediaPlaceholderItem( frame: placeholderFrame, kind: kind, - cornerRadius: cornerRadius + cornerRadius: effectiveCornerRadius ) var result: [InstantPageV2LaidOutItem] = [.mediaPlaceholder(placeholderItem)]