import { useLayoutEffect, useRef, useState } from 'react' import { ChevronDown, ChevronLeft, Ellipsis } from 'lucide-react' import { Stage, type Act } from '../kit' import './details.css' // Detail pairs: small scenes where one choice decides whether the demo looks // made or real. Four move (a swipe's throw, a reaction, a chart drawing // on, a label pinned to its chevron); two are stills (a top bar, copy length). export const detailLessons = [ { id: 'detail-throw', title: 'Throw a swipe at the finger’s speed', note: 'The same row swiped left. Released, it should carry on at the speed the finger left it and the gap should close in one ease. A pause at the hand-over, an ease-in from rest or a gap that snaps shut each reads as harsh. See examples/swipe.', bad: 'Pauses at release, eases in from rest, gap snaps shut', good: 'Leaves at the finger’s speed, gap closes in one ease', motion: true }, { id: 'detail-reaction', title: 'Draw a reaction as the app does', note: 'The same message getting a reaction. A drawn glyph in a disc over the bubble’s words, appearing in one frame, reads as made up. Use the real emoji in a small pill on the bubble’s lower corner, ringed in the ground so it sits on the edge, scaling in from the corner it hangs from.', bad: 'A drawn heart in a disc, over the words, in one frame', good: 'The emoji in a pill on the corner, scaling in', motion: true }, { id: 'detail-chart', title: 'Draw a chart on from its start', note: 'The same revenue line arriving. Faded in whole, the line, area and end dot arrive as one flat picture and the trend is never seen. Draw the line left to right, let the area follow under it, and land the end dot when the line reaches it.', bad: 'Line, area and dot fade in together', good: 'Line draws on, area follows, dot lands last', motion: true }, { id: 'detail-bar', title: 'Balance a top bar', note: 'The same inbox. A bar with one control and an empty side reads as unfinished. Give it what the product puts there: Mail has a back button on the left. If the product has nothing, drop the bar rather than leave half of it.', bad: 'Only the more button, the left side empty', good: 'Back on the left, more on the right' }, { id: 'detail-copy', title: 'Choose copy that fits', note: 'The same invite. An email that truncates in its chip, a name that wraps and labels of uneven length make the scene look broken. Unless the brief fixes them, pick names, emails and values whose length suits the layout.', bad: 'Truncated email, wrapped name, ragged labels', good: 'Short real names and an email that fits' }, { id: 'detail-pin', title: 'Pin a changing label to its icon', note: 'The same role picker as its value changes. A picker at the end of a row is anchored at its chevron: the label sits against it and grows away from it, so the chevron never moves and the gap never changes. Held in a fixed slot instead, the label starts at the slot’s left and the space before the chevron grows and shrinks with every value.', bad: 'Label at the slot’s left, the gap to the chevron changing', good: 'Label against the chevron, growing left', motion: true }, ] as const type Id = (typeof detailLessons)[number]['id'] type S = { phase: number; role: number } const ROLES = ['Can view', 'Can edit', 'Full access'] async function throwScript(a: Act) { await a.wait(700) a.put({ phase: 1 }) // the drag await a.wait(320) a.put({ phase: 2 }) // released await a.wait(700) a.put({ phase: 3 }) // gap closes await a.wait(1800) a.snap({ phase: 0 }) } async function stepScript(a: Act) { await a.wait(800) a.put({ phase: 1 }) await a.wait(2600) a.snap({ phase: 0 }) } async function roleScript(a: Act) { for (let i = 1; i <= ROLES.length; i++) { await a.wait(1300) a.put({ role: i % ROLES.length }) } } const SCRIPT: Partial) => Promise>> = { 'detail-throw': throwScript, 'detail-reaction': stepScript, 'detail-chart': stepScript, 'detail-pin': roleScript } function Throw({ s, good }: { s: S; good: boolean }) { return (
Archive
N Northwind AirYour trip to Lisbon
J Jon ParkRe: pricing page
) } function Reaction({ s, good }: { s: S; good: boolean }) { return (
Landed! The view from the hotel is unreal
Send photos {good ? ( ) : ( )}
) } const POINTS = [62, 58, 64, 55, 60, 48, 52, 40, 44, 34, 38, 26, 30, 18] const LINE = POINTS.map((y, i) => `${i ? 'L' : 'M'}${8 + i * 20},${y}`).join(' ') const AREA = `${LINE} L${8 + 13 * 20},86 L8,86 Z` function ChartDraw({ s, good }: { s: S; good: boolean }) { return (
Revenue, 14 days $68,200
) } function Bar({ good }: { good: boolean }) { return (
{good ? : }
Inbox
{[0, 1].map(i => (
))}
) } function Copy({ good }: { good: boolean }) { return (
{good ? 'maya@northwind.com' : 'alexandra.whitfield@northwindlabs.com'}
{(good ? [['Jon Park', 'Owner'], ['Ana Ruiz', 'Editor']] : [['Bartholomew Featherstonehaugh', 'Owner'], ['Al', 'Editor with comment access']]).map(([n, r]) => (
{n} {r}
))}
) } function Pin({ s, good }: { s: S; good: boolean }) { return (
JJon Park {/* Every value stacked in one cell, crossfading: the cell is as wide as the longest, so nothing resizes or gets cut */} {ROLES.map((r, i) => {r})}
) } export function DetailComparison({ lessonId }: { lessonId: string }) { const [revision, replay] = useState(0) const lesson = detailLessons.find(item => item.id === lessonId) if (!lesson) return null const id = lesson.id as Id const script = SCRIPT[id] const height = 252 return (

{lesson.title}

{lesson.note}

{script && }
key={`${id}-${revision}`} width={704} height={height + 50} compact={{ below: 704, width: 340, height: height * 2 + 116 }} initial={{ phase: 0, role: 0 }} poster={{ phase: 1, role: 0 }} script={script} className="tc-stage"> {s => (
{[false, true].map(good => (
{id === 'detail-throw' ? : id === 'detail-reaction' ? : id === 'detail-chart' ? : id === 'detail-bar' ? : id === 'detail-copy' ? : }
{good ? lesson.good : lesson.bad}
))}
)}
) } // An emoji centered by its drawing, not its glyph box. Emoji fonts pad the // glyph unevenly (Apple's sits off center in its box), by an amount that // changes with the size and the screen. So the emoji is drawn to a canvas, // cropped to its ink, and the crop is what the pill centers function Emoji({ char, size = 15 }: { char: string; size?: number }) { const ref = useRef(null) const [box, setBox] = useState<[number, number]>([size, size]) useLayoutEffect(() => { const out = ref.current if (!out) return const k = Math.max(2, Math.ceil(devicePixelRatio)) * 2 const n = Math.ceil(size * 2 * k) const c = document.createElement('canvas') c.width = c.height = n const g = c.getContext('2d', { willReadFrequently: true }) if (!g) return g.font = `${size * k}px ${getComputedStyle(out).fontFamily}` g.textAlign = 'center' g.textBaseline = 'middle' g.fillText(char, n / 2, n / 2) const { data } = g.getImageData(0, 0, n, n) let x0 = n, x1 = -1, y0 = n, y1 = -1 for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { if (data[(y * n + x) * 4 + 3]! > 8) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y } } if (x1 < 0) return const w = x1 - x0 + 1, h = y1 - y0 + 1 out.width = w out.height = h out.getContext('2d')!.drawImage(c, x0, y0, w, h, 0, 0, w, h) // Shown at an even whole-pixel size, so it sits the same whole number // of pixels from every side of an even-sized pill const side = Math.round(Math.max(w, h) / k / 2) * 2 setBox([side * (w / Math.max(w, h)), side * (h / Math.max(w, h))]) }, [char, size]) return }