diff --git a/preview-demo/poses/eureka/amused.png b/preview-demo/poses/eureka/amused.png index 655c2271..eff70ffe 100644 Binary files a/preview-demo/poses/eureka/amused.png and b/preview-demo/poses/eureka/amused.png differ diff --git a/preview-demo/poses/eureka/annoyed.png b/preview-demo/poses/eureka/annoyed.png index a142771e..312a66c8 100644 Binary files a/preview-demo/poses/eureka/annoyed.png and b/preview-demo/poses/eureka/annoyed.png differ diff --git a/preview-demo/poses/eureka/concerned.png b/preview-demo/poses/eureka/concerned.png new file mode 100644 index 00000000..f3486432 Binary files /dev/null and b/preview-demo/poses/eureka/concerned.png differ diff --git a/preview-demo/poses/eureka/determined.png b/preview-demo/poses/eureka/determined.png new file mode 100644 index 00000000..59771a80 Binary files /dev/null and b/preview-demo/poses/eureka/determined.png differ diff --git a/preview-demo/poses/eureka/happy.png b/preview-demo/poses/eureka/happy.png index dffdc754..ab4a5752 100644 Binary files a/preview-demo/poses/eureka/happy.png and b/preview-demo/poses/eureka/happy.png differ diff --git a/preview-demo/poses/eureka/hood-smug.png b/preview-demo/poses/eureka/hood-smug.png new file mode 100644 index 00000000..2545a64e Binary files /dev/null and b/preview-demo/poses/eureka/hood-smug.png differ diff --git a/preview-demo/poses/eureka/inspect.png b/preview-demo/poses/eureka/inspect.png new file mode 100644 index 00000000..44786969 Binary files /dev/null and b/preview-demo/poses/eureka/inspect.png differ diff --git a/preview-demo/poses/eureka/neutral.png b/preview-demo/poses/eureka/neutral.png index 335cfb19..f27fb141 100644 Binary files a/preview-demo/poses/eureka/neutral.png and b/preview-demo/poses/eureka/neutral.png differ diff --git a/preview-demo/poses/eureka/probe.png b/preview-demo/poses/eureka/probe.png new file mode 100644 index 00000000..0a9c3c7b Binary files /dev/null and b/preview-demo/poses/eureka/probe.png differ diff --git a/preview-demo/poses/eureka/report.png b/preview-demo/poses/eureka/report.png new file mode 100644 index 00000000..5a8a6e5c Binary files /dev/null and b/preview-demo/poses/eureka/report.png differ diff --git a/preview-demo/poses/eureka/surprised.png b/preview-demo/poses/eureka/surprised.png index 1d716bfd..34305ef9 100644 Binary files a/preview-demo/poses/eureka/surprised.png and b/preview-demo/poses/eureka/surprised.png differ diff --git a/preview-demo/poses/eureka/thinking.png b/preview-demo/poses/eureka/thinking.png index 087e56e2..31a4e917 100644 Binary files a/preview-demo/poses/eureka/thinking.png and b/preview-demo/poses/eureka/thinking.png differ diff --git a/preview-demo/poses/nibu/amused.png b/preview-demo/poses/nibu/amused.png index c13a3939..a3e02a9d 100644 Binary files a/preview-demo/poses/nibu/amused.png and b/preview-demo/poses/nibu/amused.png differ diff --git a/preview-demo/poses/nibu/annoyed.png b/preview-demo/poses/nibu/annoyed.png index f71ce0b7..e962e628 100644 Binary files a/preview-demo/poses/nibu/annoyed.png and b/preview-demo/poses/nibu/annoyed.png differ diff --git a/preview-demo/poses/nibu/console.png b/preview-demo/poses/nibu/console.png new file mode 100644 index 00000000..20a7fef4 Binary files /dev/null and b/preview-demo/poses/nibu/console.png differ diff --git a/preview-demo/poses/nibu/glitch.png b/preview-demo/poses/nibu/glitch.png new file mode 100644 index 00000000..315acd52 Binary files /dev/null and b/preview-demo/poses/nibu/glitch.png differ diff --git a/preview-demo/poses/nibu/happy.png b/preview-demo/poses/nibu/happy.png index a0432c4e..4f4ed096 100644 Binary files a/preview-demo/poses/nibu/happy.png and b/preview-demo/poses/nibu/happy.png differ diff --git a/preview-demo/poses/nibu/neutral.png b/preview-demo/poses/nibu/neutral.png index 86eb51c8..e3ada05d 100644 Binary files a/preview-demo/poses/nibu/neutral.png and b/preview-demo/poses/nibu/neutral.png differ diff --git a/preview-demo/poses/nibu/pout.png b/preview-demo/poses/nibu/pout.png new file mode 100644 index 00000000..796449b4 Binary files /dev/null and b/preview-demo/poses/nibu/pout.png differ diff --git a/preview-demo/poses/nibu/resolute.png b/preview-demo/poses/nibu/resolute.png index 8ce6377f..bab06adf 100644 Binary files a/preview-demo/poses/nibu/resolute.png and b/preview-demo/poses/nibu/resolute.png differ diff --git a/preview-demo/poses/nibu/sheepish.png b/preview-demo/poses/nibu/sheepish.png new file mode 100644 index 00000000..f9ec933f Binary files /dev/null and b/preview-demo/poses/nibu/sheepish.png differ diff --git a/preview-demo/poses/nibu/surprised.png b/preview-demo/poses/nibu/surprised.png index dc94588c..4fa62657 100644 Binary files a/preview-demo/poses/nibu/surprised.png and b/preview-demo/poses/nibu/surprised.png differ diff --git a/preview-demo/poses/nibu/thinking.png b/preview-demo/poses/nibu/thinking.png index e5d636ef..b02967ed 100644 Binary files a/preview-demo/poses/nibu/thinking.png and b/preview-demo/poses/nibu/thinking.png differ diff --git a/preview-demo/poses/nibu/triumph.png b/preview-demo/poses/nibu/triumph.png index d5a26ec2..31994236 100644 Binary files a/preview-demo/poses/nibu/triumph.png and b/preview-demo/poses/nibu/triumph.png differ diff --git a/preview-demo/poses/void/amused.png b/preview-demo/poses/void/amused.png index c90c81c5..a8332ed2 100644 Binary files a/preview-demo/poses/void/amused.png and b/preview-demo/poses/void/amused.png differ diff --git a/preview-demo/poses/void/annoyed.png b/preview-demo/poses/void/annoyed.png index fcff912f..4dc43e7a 100644 Binary files a/preview-demo/poses/void/annoyed.png and b/preview-demo/poses/void/annoyed.png differ diff --git a/preview-demo/poses/void/deadpan.png b/preview-demo/poses/void/deadpan.png index 6dc8083d..d65dcb3e 100644 Binary files a/preview-demo/poses/void/deadpan.png and b/preview-demo/poses/void/deadpan.png differ diff --git a/preview-demo/poses/void/determined.png b/preview-demo/poses/void/determined.png new file mode 100644 index 00000000..f095d292 Binary files /dev/null and b/preview-demo/poses/void/determined.png differ diff --git a/preview-demo/poses/void/glare.png b/preview-demo/poses/void/glare.png index 70860e32..44fcc81f 100644 Binary files a/preview-demo/poses/void/glare.png and b/preview-demo/poses/void/glare.png differ diff --git a/preview-demo/poses/void/happy.png b/preview-demo/poses/void/happy.png index f20ec90a..ca6027de 100644 Binary files a/preview-demo/poses/void/happy.png and b/preview-demo/poses/void/happy.png differ diff --git a/preview-demo/poses/void/neutral.png b/preview-demo/poses/void/neutral.png index aa2283a9..6e2a286d 100644 Binary files a/preview-demo/poses/void/neutral.png and b/preview-demo/poses/void/neutral.png differ diff --git a/preview-demo/poses/void/point.png b/preview-demo/poses/void/point.png index 5bd2207a..fae15ebf 100644 Binary files a/preview-demo/poses/void/point.png and b/preview-demo/poses/void/point.png differ diff --git a/preview-demo/poses/void/reading.png b/preview-demo/poses/void/reading.png index c2869474..3fff2cd8 100644 Binary files a/preview-demo/poses/void/reading.png and b/preview-demo/poses/void/reading.png differ diff --git a/preview-demo/poses/void/shrug.png b/preview-demo/poses/void/shrug.png new file mode 100644 index 00000000..aa4aee5d Binary files /dev/null and b/preview-demo/poses/void/shrug.png differ diff --git a/preview-demo/poses/void/surprised.png b/preview-demo/poses/void/surprised.png index 3dd340f8..73816f29 100644 Binary files a/preview-demo/poses/void/surprised.png and b/preview-demo/poses/void/surprised.png differ diff --git a/preview-demo/poses/void/thinking.png b/preview-demo/poses/void/thinking.png index 895f3e6b..ab93f2cc 100644 Binary files a/preview-demo/poses/void/thinking.png and b/preview-demo/poses/void/thinking.png differ diff --git a/scripts/make-pose-thumbs.mjs b/scripts/make-pose-thumbs.mjs new file mode 100644 index 00000000..f78fc19e --- /dev/null +++ b/scripts/make-pose-thumbs.mjs @@ -0,0 +1,203 @@ +// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the +// portrait's displayed width x a phone's device pixel ratio (not for the 124px desktop box). +// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a +// 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src] +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inflateSync, deflateSync, crc32 } from 'node:zlib'; + +const WIDTH = 480; // phone portrait: min(50vw, 210px) x DPR 2.5; art is never upscaled +const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(root, 'preview-demo/poses'); +const demoMinds = ['eureka', 'void', 'nibu']; + +function decode(buf) { + let off = 8; + let w, h; + const idat = []; + while (off < buf.length) { + const len = buf.readUInt32BE(off); + const type = buf.toString('latin1', off + 4, off + 8); + const data = buf.subarray(off + 8, off + 8 + len); + if (type === 'IHDR') { + w = data.readUInt32BE(0); + h = data.readUInt32BE(4); + if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0) + throw new Error('need 8-bit RGBA, non-interlaced'); + } else if (type === 'IDAT') idat.push(data); + off += 12 + len; + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = w * 4; + const px = Buffer.alloc(h * stride); + for (let y = 0; y < h; y++) { + const f = raw[y * (stride + 1)]; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + for (let x = 0; x < stride; x++) { + const a = x >= 4 ? px[y * stride + x - 4] : 0; + const b = y > 0 ? px[(y - 1) * stride + x] : 0; + const c = x >= 4 && y > 0 ? px[(y - 1) * stride + x - 4] : 0; + let p = 0; + if (f === 1) p = a; + else if (f === 2) p = b; + else if (f === 3) p = (a + b) >> 1; + else if (f === 4) { + const pa = Math.abs(b - c), + pb = Math.abs(a - c), + pc = Math.abs(a + b - 2 * c); + p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + px[y * stride + x] = (line[x] + p) & 255; + } + } + return { w, h, px }; +} + +// Separable area average over premultiplied RGBA floats. +function resize({ w, h, px }, nw, nh) { + const pre = new Float32Array(w * h * 4); + for (let i = 0; i < w * h; i++) { + const a = px[i * 4 + 3] / 255; + for (let c = 0; c < 3; c++) pre[i * 4 + c] = px[i * 4 + c] * a; + pre[i * 4 + 3] = px[i * 4 + 3]; + } + const pass = (data, sw, sh, dw, dh, horiz) => { + const out = new Float32Array((horiz ? dw * sh : sw * dh) * 4); + const n = horiz ? dw : dh; + const sn = horiz ? sw : sh; + const scale = sn / n; + for (let o = 0; o < n; o++) { + const s0 = o * scale, + s1 = (o + 1) * scale; + for (let i = Math.floor(s0); i < Math.min(sn, Math.ceil(s1)); i++) { + const wt = Math.min(i + 1, s1) - Math.max(i, s0); + const lines = horiz ? sh : sw; + for (let l = 0; l < lines; l++) { + const si = (horiz ? l * sw + i : i * sw + l) * 4; + const di = (horiz ? l * dw + o : o * dw + l) * 4; + for (let c = 0; c < 4; c++) out[di + c] += (data[si + c] * wt) / scale; + } + } + } + return out; + }; + const mid = pass(pre, w, h, nw, h, true); + const fin = pass(mid, nw, h, nw, nh, false); + const out = Buffer.alloc(nw * nh * 4); + for (let i = 0; i < nw * nh; i++) { + const a = fin[i * 4 + 3]; + out[i * 4 + 3] = Math.round(a); + for (let c = 0; c < 3; c++) + out[i * 4 + c] = a > 0 ? Math.min(255, Math.round((fin[i * 4 + c] * 255) / a)) : 0; + } + return out; +} + +// Median cut to 256 colours over (r,g,b,a); alpha is a quantised channel too. +function quantise(px, n) { + const key = (i) => [px[i * 4], px[i * 4 + 1], px[i * 4 + 2], px[i * 4 + 3]]; + const hist = new Map(); + for (let i = 0; i < n; i++) { + const q = key(i).map((v) => v & 0xf8); + const k = (q[0] << 24) | (q[1] << 16) | (q[2] << 8) | q[3]; + const e = hist.get(k) ?? { c: q, n: 0 }; + e.n++; + hist.set(k, e); + } + let boxes = [[...hist.values()]]; + while (boxes.length < 256) { + let bi = -1, + best = 0, + ch = 0; + boxes.forEach((b, i) => { + if (b.length < 2) return; + for (let c = 0; c < 4; c++) { + const vs = b.map((e) => e.c[c]); + const r = (Math.max(...vs) - Math.min(...vs)) * Math.sqrt(b.reduce((s, e) => s + e.n, 0)); + if (r > best) { + best = r; + bi = i; + ch = c; + } + } + }); + if (bi < 0) break; + const b = boxes[bi].sort((x, y) => x.c[ch] - y.c[ch]); + const half = b.reduce((s, e) => s + e.n, 0) / 2; + let acc = 0, + cut = 1; + for (; cut < b.length - 1 && acc + b[cut - 1].n < half; cut++) acc += b[cut - 1].n; + boxes.splice(bi, 1, b.slice(0, cut), b.slice(cut)); + } + const pal = boxes.map((b) => { + const t = b.reduce((s, e) => s + e.n, 0); + return [0, 1, 2, 3].map((c) => Math.round(b.reduce((s, e) => s + e.c[c] * e.n, 0) / t)); + }); + const idx = new Uint8Array(n); + const cache = new Map(); + for (let i = 0; i < n; i++) { + const k = (px[i * 4] << 24) | (px[i * 4 + 1] << 16) | (px[i * 4 + 2] << 8) | px[i * 4 + 3]; + let j = cache.get(k); + if (j === undefined) { + let bd = Infinity; + pal.forEach((p, pi) => { + const d = [0, 1, 2, 3].reduce((s, c) => s + (p[c] - px[i * 4 + c]) ** 2, 0); + if (d < bd) { + bd = d; + j = pi; + } + }); + cache.set(k, j); + } + idx[i] = j; + } + return { pal, idx }; +} + +function chunk(type, data) { + const t = Buffer.from(type, 'latin1'); + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + t.copy(out, 4); + data.copy(out, 8); + out.writeUInt32BE(crc32(Buffer.concat([t, data])) >>> 0, 8 + data.length); + return out; +} + +function encode(w, h, { pal, idx }) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; + ihdr[9] = 3; + const raw = Buffer.alloc(h * (w + 1)); + for (let y = 0; y < h; y++) + Buffer.from(idx.subarray(y * w, (y + 1) * w)).copy(raw, y * (w + 1) + 1); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('PLTE', Buffer.from(pal.flatMap((p) => p.slice(0, 3)))), + chunk('tRNS', Buffer.from(pal.map((p) => p[3]))), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); +let total = 0; +for (const mind of demoMinds) { + const m = catalogue.minds[mind]; + for (const f of [...Object.values(m.shared), ...Object.values(m.signatures)]) { + const img = decode(readFileSync(join(src, f))); + const nw = Math.min(WIDTH, img.w); + const nh = Math.round((img.h * nw) / img.w); + const png = encode(nw, nh, quantise(resize(img, nw, nh), nw * nh)); + mkdirSync(dirname(join(outDir, f)), { recursive: true }); + writeFileSync(join(outDir, f), png); + total += png.length; + console.log(f, `${nw}x${nh}`, png.length); + } +} +console.log('total', total); diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index d128ed07..82adb6f4 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { color, config, DefaultReset, toRem } from 'folds'; export const Editor = style([ @@ -12,6 +12,15 @@ export const Editor = style([ }, ]); +// Over the background scene (html[data-scene]) the composer is a bubble too: half strength, blurred. +globalStyle(`:root[data-scene] ${Editor}`, { + backgroundColor: [ + color.SurfaceVariant.Container, + `color-mix(in srgb, ${color.SurfaceVariant.Container} 50%, transparent)`, + ], + backdropFilter: 'blur(12px)', +}); + export const EditorOptions = style([ DefaultReset, { diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cdf5f3e1..502c4156 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,4 +1,4 @@ -import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css'; +import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; @@ -375,3 +375,39 @@ export const MessageTextBody = recipe({ }); export type MessageTextBodyVariants = RecipeVariants; + +// The message content is a flex column, which forms its own block formatting context and so sits +// whole beside the floated portrait: the text stays as narrow as the gap beside it for the +// bubble's full height. As a plain block its lines wrap around the silhouette and then run the +// bubble's full width below it. +globalStyle(`${BubbleContentPose} > div`, { + '@media': { + [PHONE]: { + display: 'block', + }, + }, +}); + +// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in +// the app: its own colour at half strength over a blur of the scene, so the words stay readable +// and the ridge shows through. The arrow wedge is opaque, so it goes. +const scene = ':root[data-scene]'; +const half = (c: string) => `color-mix(in srgb, ${c} 50%, transparent)`; +globalStyle(`${scene} ${BubbleContent}`, { + backgroundColor: [color.SurfaceVariant.Container, half(color.SurfaceVariant.Container)], + backdropFilter: 'blur(12px)', +}); +globalStyle(`${scene} ${BubbleContentOwn}`, { + backgroundColor: [color.Primary.Container, half(color.Primary.Container)], +}); +globalStyle(`${scene} ${BubbleHeaderPose}`, { + '@media': { + [PHONE]: { + backgroundColor: [color.Secondary.Container, half(color.Secondary.Container)], + backdropFilter: 'blur(12px)', + }, + }, +}); +globalStyle(`${scene} ${BubbleLeftArrow}`, { + display: 'none', +}); diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index cbcd626a..9015796e 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -1,9 +1,10 @@ import { JoinRule } from 'matrix-js-sdk'; -import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; +import { AvatarFallback, AvatarImage, Icon, Icons } from 'folds'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import * as css from './RoomAvatar.css'; import { getRoomIconSrc } from '../../utils/room'; import colorMXID from '../../../util/colorMXID'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; type RoomAvatarProps = { roomId: string; @@ -21,7 +22,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps if (!src || error) { return ( {renderFallback()} diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx index d9de9b79..65e5c790 100644 --- a/src/app/components/user-avatar/UserAvatar.tsx +++ b/src/app/components/user-avatar/UserAvatar.tsx @@ -1,8 +1,9 @@ -import { AvatarFallback, AvatarImage, color } from 'folds'; +import { AvatarFallback, AvatarImage } from 'folds'; import React, { ReactEventHandler, ReactNode, useState } from 'react'; import classNames from 'classnames'; import * as css from './UserAvatar.css'; import colorMXID from '../../../util/colorMXID'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; type UserAvatarProps = { className?: string; @@ -21,7 +22,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User if (!src || error) { return ( {renderFallback()} diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index a0ae4d49..643fa06d 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -3,14 +3,15 @@ import { useClientConfig } from '../../hooks/useClientConfig'; import { getFallbackSession } from '../../state/sessions'; import { mountBackgroundScene } from './host'; import { readCapabilities, resolveTier } from './tier'; +import './sceneShell.css'; /** Set on while a scene runs, so signed-out screens can let it show through. */ export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene'; /** * The one host for the background scene, mounted once at the app root. Fixed behind everything - * (z-index -1 sits above the body's background and below all normal content, so the opaque app - * covers it and the signed-out screens see it through their transparent ground). The ridge scene + * (z-index -1 sits above the body's background and below all normal content; the signed-out + * screens and the signed-in shell, see sceneShell.css.ts, are clear so the scene shows through). The ridge scene * draws its layers into this wrapper beside the canvas, so the wrapper is the positioned box. * Tier 'off' renders nothing and loads nothing. */ diff --git a/src/app/features/scene/sceneShell.css.ts b/src/app/features/scene/sceneShell.css.ts new file mode 100644 index 00000000..18911a1b --- /dev/null +++ b/src/app/features/scene/sceneShell.css.ts @@ -0,0 +1,30 @@ +import { createVar, fallbackVar, globalStyle } from '@vanilla-extract/css'; +import { color } from 'folds'; + +// While the background scene runs (html[data-scene], set by BackgroundScene) the signed-in shell +// leaves the ridge visible behind the whole app: the page, header, sidebar and room surfaces are +// clear, with only a faint wash for hover and press. Chat bubbles (message/layout/layout.css.ts) +// and the composer (editor/Editor.css.ts) are the filled surfaces. The tokens are redefined on +// #root only, so everything that renders into #portalContainer (menus, dialogs, tooltips, +// popouts) keeps its themed fill: that is the sparse menu chrome. +const wash = (pct: number) => `color-mix(in srgb, currentColor ${pct}%, transparent)`; + +globalStyle(':root[data-scene] #root', { + vars: { + [color.Background.Container]: 'transparent', + [color.Background.ContainerHover]: wash(8), + [color.Background.ContainerActive]: wash(12), + [color.Background.ContainerLine]: wash(14), + [color.Surface.Container]: 'transparent', + [color.Surface.ContainerHover]: wash(8), + [color.Surface.ContainerActive]: wash(12), + [color.Surface.ContainerLine]: wash(14), + }, +}); + +// The themed surface colour as text on a coloured ground (avatar initials) must survive the clear +// shell above. It is captured on , above the #root override, and read back with its themed +// fallback when no scene runs. +const surfaceInkVar = createVar(); +globalStyle(':root[data-scene] body', { vars: { [surfaceInkVar]: color.Surface.Container } }); +export const surfaceInk = fallbackVar(surfaceInkVar, color.Surface.Container); diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 1ed52dc1..32d3c7a6 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -26,7 +26,14 @@ export const USERS: DemoUser[] = [ ]; const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`; -export type DemoLine = { from: string; body: string; pose?: string; minutesAgo: number }; +// `html` is the formatted body a Mind's Markdown renders to; `body` is its plain-text twin. +export type DemoLine = { + from: string; + body: string; + html?: string; + pose?: string; + minutesAgo: number; +}; export type DemoRoom = { id: string; alias?: string; @@ -150,6 +157,92 @@ export const ROOMS: DemoRoom[] = [ pose: 'no-such-pose', minutesAgo: 60, }, + { + from: user('void'), + pose: 'reading', + minutesAgo: 22, + body: [ + 'Review of the sync change. Three things before it merges.', + '', + 'The resume path is right. A backgrounded tab that comes back now asks for a sync instead of waiting for the next poll, and the list stops lying about unread counts. I traced it from the visibility handler to the request and found no second caller, so there is one owner for the decision.', + '', + 'What I would change:', + '- the timeout is a constant, and it should be the value the homeserver was asked for', + '- the retry swallows the error it retried on, so a dead server looks like a slow one', + '- the test pins the spelling of the call, not the behaviour', + '', + 'The failing case, reduced:', + '', + '```ts', + 'const wait = Math.min(timeout, 30_000); // clamps what the caller asked for', + 'await client.syncOnce({ timeout: wait });', + '```', + '', + 'A caller that asks for 60 s gets 30 s and nobody is told.', + ].join('\n'), + html: + '

Review of the sync change. Three things before it merges.

' + + '

The resume path is right. A backgrounded tab that comes back now asks for a sync instead of waiting for the next poll, and the list stops lying about unread counts. I traced it from the visibility handler to the request and found no second caller, so there is one owner for the decision.

' + + '

What I would change:

' + + '
  • the timeout is a constant, and it should be the value the homeserver was asked for
  • ' + + '
  • the retry swallows the error it retried on, so a dead server looks like a slow one
  • ' + + '
  • the test pins the spelling of the call, not the behaviour
' + + '

The failing case, reduced:

' + + '
const wait = Math.min(timeout, 30_000); // clamps what the caller asked for\nawait client.syncOnce({ timeout: wait });
' + + '

A caller that asks for 60 s gets 30 s and nobody is told.

', + }, + { + from: user('eureka'), + pose: 'thinking', + minutesAgo: 12, + body: [ + 'Plan for the portrait fix, in order.', + '', + '1. Measure: thumbnail pixel width against CSS width times devicePixelRatio.', + '2. Regenerate from the cast art at about three times the displayed size.', + '3. Check the result on a phone, not in a desktop window.', + '', + 'Not in scope: the wide layout, the pose catalogue, the scene.', + '', + 'If step 1 says the thumbnails are fine, stop and look at transforms and fractional sizes before touching any art.', + ].join('\n'), + html: + '

Plan for the portrait fix, in order.

' + + '
  1. Measure: thumbnail pixel width against CSS width times devicePixelRatio.
  2. ' + + '
  3. Regenerate from the cast art at about three times the displayed size.
  4. ' + + '
  5. Check the result on a phone, not in a desktop window.
' + + '

Not in scope: the wide layout, the pose catalogue, the scene.

' + + '

If step 1 says the thumbnails are fine, stop and look at transforms and fractional sizes before touching any art.

', + }, + { + from: user('nibu'), + pose: 'console', + minutesAgo: 6, + body: [ + 'Handoff notes, long form.', + '', + 'The bubble is the unit of reading. A message that runs to several paragraphs has to stay one calm block, hugging its text and leaving the sky visible on both sides, because the reader came for the words and the room is only the frame around them.', + '', + 'Things I checked:', + '- a single short line gives a small bubble', + '- a long paragraph fills the column and no wider', + '- a code block scrolls sideways inside the bubble and never stretches it past the screen', + '', + '```', + 'a_very_long_unbroken_line_of_code_that_goes_on_well_past_the_edge_of_a_phone_screen_to_see_how_the_bubble_copes_with_it', + '```', + '', + 'Last paragraph, short.', + ].join('\n'), + html: + '

Handoff notes, long form.

' + + '

The bubble is the unit of reading. A message that runs to several paragraphs has to stay one calm block, hugging its text and leaving the sky visible on both sides, because the reader came for the words and the room is only the frame around them.

' + + '

Things I checked:

' + + '
  • a single short line gives a small bubble
  • a long paragraph fills the column and no wider
  • ' + + '
  • a code block scrolls sideways inside the bubble and never stretches it past the screen
' + + '
a_very_long_unbroken_line_of_code_that_goes_on_well_past_the_edge_of_a_phone_screen_to_see_how_the_bubble_copes_with_it
' + + '

Last paragraph, short.

', + }, ], }, { diff --git a/src/preview-demo/server.ts b/src/preview-demo/server.ts index d56e34ee..33a51b16 100644 --- a/src/preview-demo/server.ts +++ b/src/preview-demo/server.ts @@ -143,7 +143,12 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => { event: mk( 'm.room.message', l.from, - { msgtype: 'm.text', body: l.body, ...(l.pose ? { [POSE_KEY]: l.pose } : {}) }, + { + msgtype: 'm.text', + body: l.body, + ...(l.html ? { format: 'org.matrix.custom.html', formatted_body: l.html } : {}), + ...(l.pose ? { [POSE_KEY]: l.pose } : {}), + }, now() - l.minutesAgo * 60 * 1000 ), });