InstantPage V2 table: rounded corners and corner-cell fill rounding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-30 18:47:04 +02:00
parent 79bcc7bc34
commit 3d9fbdec3c
3 changed files with 60 additions and 27 deletions

View file

@ -96,16 +96,18 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th
- **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY` in the apply closure exactly, or the date will be clipped/misplaced. (`streamingHeaderOffset` is `0.0` — there is no header offset to add.) 6pt matches TextBubble's bottom bubble inset.
- **`hasDraft` adds the same 6pt at the streaming site.** The status max() above is gated by `!hasDraft`, so during streaming (status hidden, alpha=0) it can't supply the bubble's bottom inset. A separate `boundingSize.height += 6.0` inside `if hasDraft` in the SizeBlock closure does it instead — same 6pt, so the streaming bubble's bottom breathing room matches its post-stream height and there's no 6pt grow-pop when the status node fades in at finalize. The `hadDraft && !hasDraft` finalize pass doesn't need it because `!hasDraft` re-enables the status max(). If you ever refactor the `+6.0` constant out of the status max() into a `bottomInset` (TextBubble's pattern), kill this separate term at the same time — they're two ends of the same invariant.
## InstantPage V2 table — flush frame, inset borders
## InstantPage V2 table — flush frame, inset borders, rounded corners
A V2 `.table` block's item frame is **full-width / flush** with the bubble interior (so a horizontally-scrollable wide table's scroll container bleeds edge-to-edge), but the actual grid **borders start at the body-text side inset** — matching the V1 renderer. Spec: [`docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md).
A V2 `.table` block's item frame is **full-width / flush** with the bubble interior (so a horizontally-scrollable wide table's scroll container bleeds edge-to-edge), but the actual grid **borders start at the body-text side inset** — matching the V1 renderer. The grid card also has a **10pt rounded outer border**. Specs: [`…-table-inset-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md), [`…-table-corner-radius-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-corner-radius-design.md).
### Non-obvious invariants
- **`InstantPageV2TableItem.contentInset` (= page `horizontalInset`) is the linchpin.** `layoutTable` (`InstantPageV2Layout.swift`) sizes columns against `contentBoundingWidth = boundingWidth horizontalInset·2` (so a fitting table aligns with body text on both sides) and stores `contentInset` on the item; the item `frame.width` is the flush `boundingWidth`, and `contentSize.width` stays the **bare grid width** (`totalWidth`, no inset).
- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, border lines, the outer border, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the outer border keeps using the bare `contentSize.width`.
- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, inner border lines, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the rounded outer border is `contentView.layer`'s own border (see below), which wraps the shifted layer automatically.
- **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a left margin only: a fitting table (`grid + inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls its right border flush to the full-width edge (no trailing gap).
- **Manual cell-coordinate helpers MUST add `contentInset`.** Because the shift is a real `contentView` frame change, UIKit `hitTest` and `self.convert(_:to:)` paths (`propagateVisibilityRect`, the row-reveal mask) handle it automatically — but the *manual* coordinate helpers `findTextItem` / `collectSelectableTextItems` (the live tap / URL / text-selection path) compute cell/title positions arithmetically and must add `table.contentInset` to the x-offset, or in-cell hit-testing is off by the inset. (These helpers still do **not** account for the table's live horizontal `scrollView.contentOffset` — a pre-existing limitation, so in-cell hit-testing is only correct at scroll offset 0.) The dead-but-symmetric `lastTextLineFrame(in:)` table branch has the same omission but has no callers.
- **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.
## Inline custom emoji (RichText.textCustomEmoji)

View file

@ -1916,6 +1916,22 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
// MARK: - Table view
/// The set of grid corners a cell occupies, so a filled corner cell's stripe can be rounded to
/// follow the table's rounded outer border. `cellFrame` is table-grid-local (pre-`gridOffsetY`).
private func tableStripeCornerMask(cellFrame: CGRect, gridWidth: CGFloat, gridHeight: CGFloat, effectiveBorderWidth: CGFloat) -> CACornerMask {
let edge = effectiveBorderWidth / 2.0 + 0.5
let firstCol = cellFrame.minX <= edge
let firstRow = cellFrame.minY <= edge
let lastCol = cellFrame.maxX >= gridWidth - edge
let lastRow = cellFrame.maxY >= gridHeight - edge
var mask: CACornerMask = []
if firstRow && firstCol { mask.insert(.layerMinXMinYCorner) }
if firstRow && lastCol { mask.insert(.layerMaxXMinYCorner) }
if lastRow && firstCol { mask.insert(.layerMinXMaxYCorner) }
if lastRow && lastCol { mask.insert(.layerMaxXMaxYCorner) }
return mask
}
final class InstantPageV2TableView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2TableItem
var itemFrame: CGRect { return self.item.frame }
@ -1970,6 +1986,8 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
// Grid origin: shifted down by title height when present.
let gridOffsetY = item.titleFrame?.height ?? 0.0
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
let gridHeight = item.contentSize.height - gridOffsetY
// Cell backgrounds and sub-layouts.
for cell in item.cells {
@ -1977,6 +1995,16 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
let stripe = CALayer()
stripe.backgroundColor = bg.cgColor
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
// Round the stripe at any grid corner it touches, so a filled corner cell
// follows the rounded outer border instead of poking past it.
let cornerMask = tableStripeCornerMask(cellFrame: cell.frame, gridWidth: item.contentSize.width, gridHeight: gridHeight, effectiveBorderWidth: effectiveBorderWidth)
if cornerMask.isEmpty {
stripe.cornerRadius = 0.0
stripe.maskedCorners = []
} else {
stripe.cornerRadius = max(0.0, v2TableCornerRadius - effectiveBorderWidth)
stripe.maskedCorners = cornerMask
}
self.contentView.layer.insertSublayer(stripe, at: 0)
self.stripeLayers.append(stripe)
}
@ -1990,7 +2018,14 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
}
}
// Border lines.
// Rounded outer border. `cornerRadius` rounds the layer's border WITHOUT `masksToBounds`,
// so table contents (cell fills, text) stay unclipped only the border visual is rounded.
// Replaces the four straight outer-edge layers the old code appended to `lineLayers`.
self.contentView.layer.cornerRadius = v2TableCornerRadius
self.contentView.layer.borderColor = item.borderColor.cgColor
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
// Inner grid lines (between cells).
if item.bordered {
for r in item.horizontalLines + item.verticalLines {
let line = CALayer()
@ -1999,27 +2034,6 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
self.contentView.layer.addSublayer(line)
self.lineLayers.append(line)
}
// Outer border rect (four edges).
let outerW = v2TableBorderWidth
let outerRect = CGRect(
x: outerW / 2.0,
y: gridOffsetY + outerW / 2.0,
width: item.contentSize.width - outerW,
height: item.contentSize.height - outerW
)
let outerEdges: [CGRect] = [
CGRect(x: outerRect.minX, y: outerRect.minY, width: outerRect.width, height: outerW),
CGRect(x: outerRect.minX, y: outerRect.maxY - outerW, width: outerRect.width, height: outerW),
CGRect(x: outerRect.minX, y: outerRect.minY, width: outerW, height: outerRect.height),
CGRect(x: outerRect.maxX - outerW, y: outerRect.minY, width: outerW, height: outerRect.height)
]
for edge in outerEdges {
let line = CALayer()
line.backgroundColor = item.borderColor.cgColor
line.frame = edge
self.contentView.layer.addSublayer(line)
self.lineLayers.append(line)
}
}
}
@ -2058,21 +2072,37 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
}
}
// Stripe layers (cell backgrounds) update color + frame in original order.
// Stripe layers (cell backgrounds) update color + frame + corner rounding in original order.
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
let gridHeight = item.contentSize.height - gridOffsetY
var stripeIndex = 0
for cell in item.cells {
if let bg = cell.backgroundColor, stripeIndex < self.stripeLayers.count {
let stripe = self.stripeLayers[stripeIndex]
stripe.backgroundColor = bg.cgColor
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
let cornerMask = tableStripeCornerMask(cellFrame: cell.frame, gridWidth: item.contentSize.width, gridHeight: gridHeight, effectiveBorderWidth: effectiveBorderWidth)
if cornerMask.isEmpty {
stripe.cornerRadius = 0.0
stripe.maskedCorners = []
} else {
stripe.cornerRadius = max(0.0, v2TableCornerRadius - effectiveBorderWidth)
stripe.maskedCorners = cornerMask
}
stripeIndex += 1
}
}
// Line layers (borders) update color in place; frames recomputed in original order.
// Inner line layers refresh color in place. (`lineLayers` now holds only inner grid
// lines; the outer border is the contentView layer's own rounded border, refreshed below.)
for line in self.lineLayers {
line.backgroundColor = item.borderColor.cgColor
}
// Rounded outer border refresh radius/color/width (theme or `bordered` flag may change).
self.contentView.layer.cornerRadius = v2TableCornerRadius
self.contentView.layer.borderColor = item.borderColor.cgColor
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
}
}

View file

@ -1049,6 +1049,7 @@ private struct V2TableRow {
let v2TableCellInsets = UIEdgeInsets(top: 14.0, left: 12.0, bottom: 14.0, right: 12.0)
let v2TableBorderWidth: CGFloat = 1.0
let v2TableCornerRadius: CGFloat = 10.0
private func layoutTable(
title: RichText,