phone layout: text wraps the traced portrait outline; status lines no longer sit under the composer
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m59s

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 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-07 23:27:53 +00:00
commit 636cad9655
5 changed files with 127 additions and 6 deletions

View file

@ -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)',
},

View file

@ -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<HTMLImageElement>(null);
const [shape, setShape] = useState<string>();
// 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 (
<button
type="button"
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
// On phones the bubble text wraps around the portrait's silhouette.
style={{ shapeOutside: `url(${src})` }}
style={{ shapeOutside: shape }}
data-user-id={userId}
data-pose={pose}
onClick={onClick}
>
<img className={css.PosePortrait} src={src} alt={`${mind}, ${pose}`} draggable={false} />
<img
ref={imgRef}
className={css.PosePortrait}
src={src}
alt={`${mind}, ${pose}`}
draggable={false}
/>
</button>
);
}

View file

@ -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();
});
});

View file

@ -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`;
};

View file

@ -42,7 +42,7 @@ export function RoomViewPoseStatus({ room }: { room: Room }) {
if (statuses.length === 0) return null;
return (
<Box direction="Column" style={{ padding: '0 1rem' }}>
<Box direction="Column" shrink="No" style={{ padding: '0 1rem' }}>
{statuses.map((s) => (
<Text key={s.mind} size="T200" priority="300">
{poseStatusLine(s)}