From 636cad965551e3bc68ca2b6a6cd5805719c0e572 Mon Sep 17 00:00:00 2001 From: Void Date: Sat, 7 Nov 2026 23:27:53 +0000 Subject: [PATCH] phone layout: text wraps the traced portrait outline; status lines no longer sit under the composer shape-outside: url() sizes the image at its natural size and anchors it at the margin box, which the phone layout's -82px top margin moves down by the overhang, so the exclusion was the wrong size and in the wrong place and long-post words ran under the art. PoseAvatar now traces the displayed art's alpha (0.35, as before) into a polygon in border-box pixels, retraced on load and resize. The pose status lines sat in a shrinkable flex box (12px high, three 18px lines) and overflowed into the composer; the box is shrink="No". Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 1 - .../components/message/pose/PoseAvatar.tsx | 40 +++++++++++-- .../message/pose/silhouette.test.ts | 56 +++++++++++++++++++ src/app/components/message/pose/silhouette.ts | 34 +++++++++++ src/app/features/room/RoomViewPoseStatus.tsx | 2 +- 5 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 src/app/components/message/pose/silhouette.test.ts create mode 100644 src/app/components/message/pose/silhouette.ts diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 502c4156..991f60d8 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -266,7 +266,6 @@ export const PoseButton = style({ height: 'auto', margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, shapeMargin: toRem(7), - shapeImageThreshold: 0.35, WebkitMaskImage: 'linear-gradient(#000 84%, transparent)', maskImage: 'linear-gradient(#000 84%, transparent)', }, diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx index e98d6139..75a4b333 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import * as css from '../layout/layout.css'; import { poseSrc } from './poses'; +import { silhouette } from './silhouette'; type PoseAvatarProps = { mind: string; @@ -16,17 +17,48 @@ type PoseAvatarProps = { export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { const src = poseSrc(mind, pose, import.meta.env.BASE_URL); + const imgRef = useRef(null); + const [shape, setShape] = useState(); + + // On phones the bubble text wraps around the portrait's silhouette. Retraced when the art + // loads and when the portrait's width changes with the viewport. + useEffect(() => { + const img = imgRef.current; + if (!img) return undefined; + const trace = () => { + if (!img.complete || img.naturalWidth === 0) return; + try { + setShape(silhouette(img, !alt)); + } catch { + setShape(undefined); + } + }; + trace(); + img.addEventListener('load', trace); + const resize = new ResizeObserver(trace); + resize.observe(img); + return () => { + img.removeEventListener('load', trace); + resize.disconnect(); + }; + }, [src, alt]); + return ( ); } diff --git a/src/app/components/message/pose/silhouette.test.ts b/src/app/components/message/pose/silhouette.test.ts new file mode 100644 index 00000000..9deb3f4f --- /dev/null +++ b/src/app/components/message/pose/silhouette.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { silhouette } from './silhouette'; + +// A w x h image whose opaque pixels are the given columns [from, to) on each row. +const fakeImage = (w: number, h: number, solid: (y: number) => [number, number] | null) => { + const data = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y += 1) { + const span = solid(y); + if (span) for (let x = span[0]; x < span[1]; x += 1) data[(y * w + x) * 4 + 3] = 255; + } + const canvas = { + width: 0, + height: 0, + getContext: () => ({ canvas, drawImage: vi.fn(), getImageData: () => ({ data }) }), + }; + vi.stubGlobal('document', { createElement: () => canvas }); + return { clientWidth: w, clientHeight: h } as HTMLImageElement; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('silhouette', () => { + // Art in columns 20..60 from row 8 down; rows 0..7 are empty. + const art = (y: number): [number, number] | null => (y < 8 ? null : [20, 60]); + + it('hugs the right edge of the art when the text sits to its right', () => { + const poly = silhouette(fakeImage(100, 16, art), true); + expect(poly).toBe( + 'polygon(0px 0, 0px 0px, 0px 4px, 60px 8px, 60px 12px, 60px 16px, 0px 16px) border-box' + ); + }); + + it('hugs the left edge of the art when the text sits to its left', () => { + const poly = silhouette(fakeImage(100, 16, art), false); + expect(poly).toBe( + 'polygon(100px 0, 100px 0px, 100px 4px, 20px 8px, 20px 12px, 20px 16px, 100px 16px) border-box' + ); + }); + + it('takes the widest row of a band, so no row of the art pokes out of the polygon', () => { + // Row 5 reaches x=90; its band's edge row (y=8 covers rows 4..8) must include it. + const poly = silhouette( + fakeImage(100, 16, (y) => (y === 5 ? [20, 90] : null)), + true + ); + expect(poly).toContain('90px 8px'); + }); + + it('has no shape before the image has a size or where there is no canvas', () => { + expect( + silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true) + ).toBeUndefined(); + }); +}); diff --git a/src/app/components/message/pose/silhouette.ts b/src/app/components/message/pose/silhouette.ts new file mode 100644 index 00000000..e9596ded --- /dev/null +++ b/src/app/components/message/pose/silhouette.ts @@ -0,0 +1,34 @@ +const SOLID = 0.35 * 255; +const ROW = 4; + +// The edge of the portrait that the text wraps against, as a polygon in border-box pixels. +// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and +// anchors it at the margin box, which the phone layout's negative top margin shifts down by the +// overhang, so the exclusion neither matches the art's size nor its place. +export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => { + const w = Math.round(img.clientWidth); + const h = Math.round(img.clientHeight); + const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null; + if (!ctx) return undefined; + ctx.canvas.width = w; + ctx.canvas.height = h; + ctx.drawImage(img, 0, 0, w, h); + const { data } = ctx.getImageData(0, 0, w, h); + const outer = textOnRight ? 0 : w; + const edge: string[] = []; + for (let y = 0; y <= h; y += ROW) { + let x = outer; + // A band's edge is the extreme solid pixel over its rows, so no row pokes out of the polygon. + for (let r = Math.max(0, y - ROW); r < Math.min(h, y + 1); r += 1) { + for (let i = 0; i < w; i += 1) { + const px = textOnRight ? w - 1 - i : i; + if (data[(r * w + px) * 4 + 3] > SOLID) { + x = textOnRight ? Math.max(x, px + 1) : Math.min(x, px); + break; + } + } + } + edge.push(`${x}px ${y}px`); + } + return `polygon(${outer}px 0, ${edge.join(', ')}, ${outer}px ${h}px) border-box`; +}; diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx index 33d0d80f..d4a52c93 100644 --- a/src/app/features/room/RoomViewPoseStatus.tsx +++ b/src/app/features/room/RoomViewPoseStatus.tsx @@ -42,7 +42,7 @@ export function RoomViewPoseStatus({ room }: { room: Room }) { if (statuses.length === 0) return null; return ( - + {statuses.map((s) => ( {poseStatusLine(s)}