From a0f22c8722c28eed4790c4ca7859b6b650d1c540 Mon Sep 17 00:00:00 2001 From: Fanoflix Date: Mon, 7 Sep 2026 08:52:42 +0500 Subject: [PATCH] added how-it-works.md files. ironed out UX --- .gitignore | 3 + src/components/upload/FileDropZone.tsx | 114 +++++++++++++ .../canvas-stuff/anti-aliasing/AACanvas.tsx | 2 +- .../canvas-stuff/anti-aliasing/AntiAlias.tsx | 13 +- .../canvas-stuff/anti-aliasing/Controls.tsx | 29 ++-- .../canvas-stuff/anti-aliasing/raster.test.ts | 55 +++++++ .../canvas-stuff/anti-aliasing/raster.ts | 45 ++++++ .../anti-aliasing/useAntiAlias.ts | 39 +++-- .../canvas-stuff/dithering/Controls.tsx | 30 ++-- .../canvas-stuff/dithering/Dither.tsx | 40 +++-- .../canvas-stuff/dithering/DitherCanvas.tsx | 5 +- .../canvas-stuff/dithering/useDither.ts | 18 ++- .../low-res-video/LowResVideo.tsx | 24 +-- .../low-res-video/PixelScreen.tsx | 4 +- .../motion/character-flow/how-it-works.md | 151 ++++++++++++++++++ .../concept-chat/how-it-works.md | 139 ++++++++++++++++ .../future-table/how-it-works.md | 130 +++++++++++++++ src/hooks/useCompare.test.tsx | 65 ++++++++ src/hooks/useCompare.ts | 34 ++++ 19 files changed, 845 insertions(+), 95 deletions(-) create mode 100644 src/components/upload/FileDropZone.tsx create mode 100644 src/features/canvas-stuff/anti-aliasing/raster.test.ts create mode 100644 src/features/motion/character-flow/how-it-works.md create mode 100644 src/features/speculative-ui/concept-chat/how-it-works.md create mode 100644 src/features/speculative-ui/future-table/how-it-works.md create mode 100644 src/hooks/useCompare.test.tsx create mode 100644 src/hooks/useCompare.ts diff --git a/.gitignore b/.gitignore index 1e5ee14..415ce29 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ short-demo.md # Yarn (node-modules linker) .yarn/* !.yarn/patches + +# private launch working file +launch-plan.md diff --git a/src/components/upload/FileDropZone.tsx b/src/components/upload/FileDropZone.tsx new file mode 100644 index 0000000..dc94d6b --- /dev/null +++ b/src/components/upload/FileDropZone.tsx @@ -0,0 +1,114 @@ +import { useRef, useState } from "react" +import type { DragEvent, ReactNode } from "react" + +import { cn } from "@/lib/utils" + +type Props = { + /** An `accept` value with a wildcard subtype, e.g. `image/*`. */ + accept: `${string}/*` + onPick: (file: File) => void + /** + * Whether nothing is loaded yet. Clicking only opens the picker while empty: + * once there's a source, the surface belongs to the tool (dithering uses a + * press on the canvas to compare), and a stray click that reopened a file + * dialog would be worse than no shortcut at all. Dropping still works either + * way, which is the gesture people reach for to *replace* something. + */ + empty: boolean + /** Empty-state line, e.g. "Drop an image, or click to upload". */ + hint: string + className?: string + children: ReactNode +} + +/** + * The preview surface, doubling as the upload target. + * + * The empty state used to be a `pointer-events-none` label that said "click + * upload" while pointing at a button somewhere else on the screen — it read as + * an affordance and behaved like a caption. Here the whole area is a real + * ` + )} + + {/* Ring the whole surface while a file is over it, loaded or not — the + only feedback that a drop will actually land. */} + {dragging && !empty && ( + + )} + + { + takeFile(e.target.files?.[0]) + // Let the same file be chosen twice in a row. + e.target.value = "" + }} + /> + + ) +} diff --git a/src/features/canvas-stuff/anti-aliasing/AACanvas.tsx b/src/features/canvas-stuff/anti-aliasing/AACanvas.tsx index 9a2ae91..7e3a162 100644 --- a/src/features/canvas-stuff/anti-aliasing/AACanvas.tsx +++ b/src/features/canvas-stuff/anti-aliasing/AACanvas.tsx @@ -82,7 +82,7 @@ export function AACanvas({ [] ) - const endHold = () => comparing && onHoldEnd() + const endHold = () => onHoldEnd() const startHold = (e: ReactPointerEvent) => { e.preventDefault() onHoldStart() diff --git a/src/features/canvas-stuff/anti-aliasing/AntiAlias.tsx b/src/features/canvas-stuff/anti-aliasing/AntiAlias.tsx index 0b493d3..0bbb33f 100644 --- a/src/features/canvas-stuff/anti-aliasing/AntiAlias.tsx +++ b/src/features/canvas-stuff/anti-aliasing/AntiAlias.tsx @@ -10,6 +10,7 @@ export function AntiAlias() { const { settings, comparing, + compareLatched, collapsed, animating, region, @@ -20,7 +21,9 @@ export function AntiAlias() { displayHeight, onChange, exportPng, - setComparing, + setCompareLatched, + startPeek, + endPeek, setCollapsed, setAnimating, setRegion, @@ -42,8 +45,8 @@ export function AntiAlias() { comparing={comparing} displayWidth={displayWidth} displayHeight={displayHeight} - onHoldStart={() => setComparing(true)} - onHoldEnd={() => setComparing(false)} + onHoldStart={startPeek} + onHoldEnd={endPeek} > setComparing(true)} - onCompareEnd={() => setComparing(false)} + comparing={compareLatched} + onComparingChange={setCompareLatched} onCollapse={() => setCollapsed(true)} /> void onExport: () => void - onCompareStart: () => void - onCompareEnd: () => void + comparing: boolean + onComparingChange: (comparing: boolean) => void onCollapse: () => void } @@ -30,8 +31,8 @@ export function Controls({ animating, onToggleAnimate, onExport, - onCompareStart, - onCompareEnd, + comparing, + onComparingChange, onCollapse, }: Props) { const sceneLabel = SCENES.find((s) => s.value === settings.scene)?.label @@ -39,21 +40,17 @@ export function Controls({ return (
- + {comparing ? "Showing aliased" : "Compare"} +
- + {comparing ? "Showing original" : "Compare"} + Old consoles had almost no colours, so they cheated: scatter dots of the colours you do have, and let someone's eyes blend the ones you don't. - Hold the image to see what it really looks like underneath. + Hold the image — or leave Compare on — to see what it really looks like + underneath. -
e.preventDefault()} - onDrop={(e) => { - e.preventDefault() - const file = e.dataTransfer.files.item(0) - if (file && file.type.startsWith("image/")) pickFile(file) - }} + setComparing(true)} - onHoldEnd={() => setComparing(false)} + onHoldStart={startPeek} + onHoldEnd={endPeek} > {source && ( )} - {!source && ( -
-

- Drop an image or click upload -

-
- )} -
+ setComparing(true)} - onCompareEnd={() => setComparing(false)} + comparing={compareLatched} + onComparingChange={setCompareLatched} onCollapse={() => setCollapsed(true)} /> comparing && onHoldEnd() + // Unconditional: with the compare toggle latched on, a press *hides* the + // original, so `comparing` is false exactly when a release still has to be + // reported. Guarding on it would strand the peek. + const endHold = () => onHoldEnd() const startHold = (e: ReactPointerEvent) => { e.preventDefault() onHoldStart() diff --git a/src/features/canvas-stuff/dithering/useDither.ts b/src/features/canvas-stuff/dithering/useDither.ts index c161ee7..4141cb5 100644 --- a/src/features/canvas-stuff/dithering/useDither.ts +++ b/src/features/canvas-stuff/dithering/useDither.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react" +import { useCompare } from "@/hooks/useCompare" import type { Region } from "@/components/loupe/region" import type { DitherCanvasHandle } from "./DitherCanvas" import { countUniqueColors, dither } from "./pipeline" @@ -61,7 +62,7 @@ const workDims = (source: Source, pixelScale: number) => ({ export function useDither() { const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [source, setSource] = useState(null) - const [comparing, setComparing] = useState(false) + const compare = useCompare() const [collapsed, setCollapsed] = useState(false) const [stats, setStats] = useState(null) // Normalised centre of the loupe selection; the square region is derived from @@ -138,8 +139,8 @@ export function useDither() { // Redraw the loupe whenever the frame, the selection, or the compare state // changes. The selection is square, so it always fills the square loupe // exactly; nearest-neighbour keeps the dithered dots crisp under - // magnification. While holding to compare we sample the original image - // instead — same region, for a like-for-like before/after. + // magnification. Whenever the view is comparing — held or latched — we sample + // the original image instead, same region, for a like-for-like before/after. useEffect(() => { const loupe = loupeRef.current const main = canvasRef.current?.getCanvas() @@ -154,7 +155,7 @@ export function useDither() { ctx.imageSmoothingEnabled = false ctx.clearRect(0, 0, LOUPE_SIZE, LOUPE_SIZE) - if (comparing && original) { + if (compare.comparing && original) { ctx.drawImage( original, region.x * source.w, @@ -186,7 +187,7 @@ export function useDither() { region.w, region.h, frameVersion, - comparing, + compare.comparing, source, settings.pixelScale, ]) @@ -236,7 +237,8 @@ export function useDither() { settings, source, stats, - comparing, + comparing: compare.comparing, + compareLatched: compare.latched, collapsed, region, zoomLevel, @@ -247,7 +249,9 @@ export function useDither() { onChange, pickFile, exportPng, - setComparing, + setCompareLatched: compare.setLatched, + startPeek: compare.startPeek, + endPeek: compare.endPeek, setCollapsed, setRegion, setZoomLevel: setZoom, diff --git a/src/features/canvas-stuff/low-res-video/LowResVideo.tsx b/src/features/canvas-stuff/low-res-video/LowResVideo.tsx index 8eda08f..3b7fbed 100644 --- a/src/features/canvas-stuff/low-res-video/LowResVideo.tsx +++ b/src/features/canvas-stuff/low-res-video/LowResVideo.tsx @@ -1,4 +1,5 @@ import { ToolIntro } from "@/components/layout/ToolIntro" +import { FileDropZone } from "@/components/upload/FileDropZone" import { Controls } from "./Controls" import { PixelScreen } from "./PixelScreen" import { useLowResVideo } from "./useLowResVideo" @@ -33,14 +34,12 @@ export function LowResVideo() { scoreboard: up close it's a grid of dots, from far away it's a face. -
e.preventDefault()} - onDrop={(e) => { - e.preventDefault() - const file = e.dataTransfer.files.item(0) - if (file && file.type.startsWith("video/")) pickFile(file) - }} + - {!hasVideo && ( -
-

- Drop a video or click upload -

-
- )} -
+
diff --git a/src/features/motion/character-flow/how-it-works.md b/src/features/motion/character-flow/how-it-works.md new file mode 100644 index 0000000..8b91736 --- /dev/null +++ b/src/features/motion/character-flow/how-it-works.md @@ -0,0 +1,151 @@ +# How CharacterFlow works + +CharacterFlow animates one string into another the way an odometer or a +departure board does: the letters that both strings share **slide** to their new +place, and only the letters that genuinely changed **roll** away and roll in. + +The hard part isn't the animation. It's deciding *which letters are the same +letter* — and doing it so that a shared run like the `lich` in `Lichking` → +`Lichbane` stays one set of elements rather than four letters that happen to +match four other letters somewhere else in the word. + +This is exactly what our code in [`CharacterFlow.tsx`](./CharacterFlow.tsx) +does. + +--- + +## The detail + +### Terms (read these first) + +- `slot` = "one character position on screen, carrying an **id** that survives a value change." +- `LCS` = "longest common subsequence — the longest run of characters that appears in both strings *in the same order*, though not necessarily side by side." +- `anchor` = "a pair `(ai, bi)`: character `ai` of the old string is the same character as `bi` of the new one." +- `entering` = "a character with no anchor — it wasn't on screen last render." +- `roll` = "the vertical travel, in `em`, a character moves as it enters or exits." +- `layout slide` = "the *horizontal* move a surviving character makes when the string grows or shrinks." + +### Step 1: match the old string to the new one + +Everything rests on this. We compute the LCS with the standard table, filled +from the bottom-right so `dp[i][j]` means "longest match still available from +`a[i]` and `b[j]` onward": + +``` +dp[i][j] = a[i] === b[j] + ? dp[i+1][j+1] + 1 + : max(dp[i+1][j], dp[i][j+1]) +``` + +Then we walk forward from `(0, 0)` and emit an anchor whenever the characters +agree, stepping whichever side the table says has more left to gain. + +Two properties matter, and both come free with LCS: + +- **Anchors never cross.** They're emitted in increasing order on both sides, so + a surviving letter can never be asked to slide *past* another survivor. +- **Contiguous runs match as a block.** A greedy "find this letter anywhere" + approach would happily pair the `e` in `the` with the `e` in `theme`'s tail + and drag it across the whole word. LCS won't. + +### Step 2: hand out ids + +Anchored characters inherit the old slot's id. Everything else gets a fresh one +from a counter: + +``` +slot(j) = anchored(j) ? { id: prev[ai].id, char } + : { id: nextId++, char } +``` + +The id is the React key. That single choice is what makes the rest work: an +unchanged id means React *moves* the element (Framer's `layout="position"` +slides it), and a new id means it mounts (so it rolls in). Nothing else in the +component asks "did this change?" — the ids already answered. + +### Step 3: only new characters cascade + +Entering characters are ranked **among themselves**, not by their position in +the string: + +``` +staggerDelay(rank) = + from "first" → rank · step + from "last" → (count − 1 − rank) · step + from "center" → |rank − (count − 1)/2| · step +``` + +Ranking by string index instead would mean appending one letter to a 12-letter +word inherits an 12-step delay and appears to lag. Survivors get no delay at +all, so a one-character edit stays snappy. + +### Step 4: how far a character rolls + +A character that changed a lot travels farther than one that barely changed — +and since both take the same time, the big change *moves faster*. That's the +NumberFlow signature, and it's two lines: + +``` +charDistance(a, b) = min(1, |code(a) − code(b)| / 9) +travel = rollDistance · (1 + distanceScale · charDistance) +``` + +The `/ 9` is calibrated for digits: `1 → 9` is the largest single-digit jump and +saturates the scale at 1. Letters reuse the same ramp, which is not +linguistically meaningful but reads correctly — near-neighbours in the alphabet +feel like small changes. `distanceScale = 0` makes every roll identical. + +### Step 5: split the two axes + +Each character is **two nested spans**: + +| Span | Owns | Why | +|---|---|---| +| outer | horizontal `layout="position"` | FLIPs a survivor to its new x with a GPU transform | +| inner | vertical `y` + `opacity` | the roll and fade of enter / exit | + +If one element owned both, a character that is simultaneously sliding right and +rolling up would compose into a **diagonal**. Splitting them keeps the roll +strictly vertical while the slide happens underneath it. + +`AnimatePresence mode="popLayout"` takes exiting characters out of flow +immediately, so survivors start reflowing the moment a letter leaves rather than +waiting for its exit to finish. + +### Step 6: the timings that make it read as a roll + +Enter and exit run in **opposite** directions around the same axis: + +``` +enter: y from dir · travel → 0 +exit: y from 0 → −dir · rollDistance +``` + +so at a changed position the outgoing letter rolls up and out while the incoming +one rolls up and in beneath it. That's the odometer read — one letter scrolling +to the next, rather than the whole word blinking out and a new one appearing. + +The vertical move is deliberately **faster than the fade's envelope**: + +``` +y duration = duration / 3 +opacity duration = max(0.1, duration / 3) +``` + +with `duration = 0.7s` by default. The character arrives in place early and +spends the rest of the budget settling in opacity, which is what stops it +looking like it's still drifting after it's landed. The horizontal slide keeps +its own default (`0.3s`) so roll speed and layout speed tune independently. + +### Accessibility + +The animated characters are all `aria-hidden`; the wrapper carries +`aria-label={value}`, so a screen reader hears the word once instead of a +letter-by-letter stream. With `prefers-reduced-motion`, the component renders +plain text and does nothing at all. + +### Why it works in one sentence + +Match the two strings with LCS so identity is decided *before* any animation +exists, then let React's keys do the rest — survivors move because their id +didn't change, and everything else rolls because it's genuinely new. diff --git a/src/features/speculative-ui/concept-chat/how-it-works.md b/src/features/speculative-ui/concept-chat/how-it-works.md new file mode 100644 index 0000000..7fc812b --- /dev/null +++ b/src/features/speculative-ui/concept-chat/how-it-works.md @@ -0,0 +1,139 @@ +# How concept chat works + +A chat message is normally one lump of text that arrives all at once. This one +can carry **timing**: it arrives as a poster with a play button, and pressing +play makes it perform itself — lines landing one after another, at the pace the +sender chose. + +The design constraint that shaped everything: a timeline message must be the +*same kind of thing* as a plain message. Not a second message type, not a second +renderer, not a second storage shape. One body, one code path, and timing is +just something a segment may or may not carry. + +This is exactly what our code in [`engine/beats.ts`](./engine/beats.ts) does. + +--- + +## The detail + +### Terms (read these first) + +- `segment` = "one piece of a message body — a line of text, an image, a link." +- `body` = "a flat `Segment[]`. This is what's stored and what's sent. There is no other shape." +- `timing` = "an optional `{ hold, enter }` on a segment. Its presence is the whole signal." +- `beat` = "one step of a performance: the segments that land together." +- `hold` = "milliseconds a beat stays alone before the next lands." +- `enter` = "how a beat arrives — `fade` by default." +- `run` = "one playthrough." + +### Step 1: beats are derived, never stored + +The body stays flat on disk and in state. Beats are computed on the way to the +screen, by one rule: + +> **Timing starts a beat. No timing joins the one before.** + +``` +opensBeat(i) = i === 0 || segment.timing !== undefined +``` + +The first segment always opens a beat whether or not it carries timing. That +single clause is why there's no special case for plain messages: a body with no +timing anywhere yields **exactly one beat containing everything**, which renders +identically to a static message and reaches the screen through the same code +path. A static message isn't a different branch — it's a one-beat performance. + +### Step 2: the round trip is the identity + +The composer works in beats; storage works in bodies. Going back down, timing is +written onto each beat's *opening* segment and stripped from every other: + +``` +toBeats(flatten(beats)) ≡ beats (on structure) +``` + +And one beat flattens to a body with no timing at all — precisely a plain +message. So "remove the timing from this message" isn't an operation the code +has to implement; it's what collapsing to one beat already means. + +### Step 3: how long it runs + +``` +totalDuration = Σ hold(beat) for every beat except the last +``` + +The final beat's hold is excluded on purpose: a hold is a *gap before the next +beat*, and after the last one there is no next beat. Counting it would leave the +message sitting there having visibly finished, waiting out a timer for nothing. + +Default hold is `1500ms`; the composer offers presets but always prints the real +number next to the friendly label, so the name never hides what's being chosen. + +### Step 4: playback is a count, not a cursor + +The entire performance is one number: **how many beats are visible.** + +Beats accumulate rather than replace. There's no current-beat pointer, nothing +to tear down between steps, and no window where two things are on screen at +once. Idle sits at `visibleCount = 1` — the poster *is* the first beat, already +rendered, which is why the message has a sensible resting state without a +separate preview. + +A `runId` increments on each run and is used as the React key, so starting a run +re-mounts the beats and they animate in again — including the first one, which +was already on screen and would otherwise sit motionless while everything after +it performed. + +Replay winds the beats back down to empty first. The playback state carries +`fromCleared` so the first beat knows which it is: arriving into empty space it +should grow in like any other beat, but arriving over a poster of exactly its +own height it must only fade — otherwise the message collapses and re-expands +for no reason. + +An idle message runs **no timers at all**. The only effect is unmount cleanup, +which exists because a pending timeout outliving the component would set state +on something that no longer exists. + +### Step 5: the thread groups itself + +The view never groups, never sorts, and never decides where a date divider goes. +One pure function turns a flat message list into what gets rendered: + +``` +continues = sameAuthor && (message.sentAt − lastSentAt(group)) ≤ 5 min +``` + +The gap is measured from the **previous message**, not the run's start — so a +steady back-and-forth stays one group instead of splitting every five minutes no +matter how continuous the conversation is. + +A new calendar day always closes the open run, because a group must never +straddle the divider that would be drawn between its own messages. + +### Step 6: time is passed in, never read + +Every formatter takes `now` as an argument and reads no clock of its own: + +``` +< 60s → "Just now" +< 1h → "5m" +same day → "3h" +1 day → "Yesterday" +else → "12 Mar" (plus the year, once it's a different one) +``` + +That makes each one directly testable, and it means the whole list re-derives +its labels from a **single shared tick** rather than every timestamp holding its +own timer. Days are counted by calendar day, not elapsed hours, so 11pm → +1am is "Yesterday" rather than "2h". + +Future timestamps — clock skew, a restored thread — clamp to `Just now` instead +of rendering a negative age, and the countdown clamps at zero rather than +flashing `-0:01` when a render lands a few milliseconds the wrong side of +expiry. + +### Why it works in one sentence + +Make timing an optional property of a segment rather than a property of a +message, and a performed message stops being a new feature — it's the same +message, played. diff --git a/src/features/speculative-ui/future-table/how-it-works.md b/src/features/speculative-ui/future-table/how-it-works.md new file mode 100644 index 0000000..a39994f --- /dev/null +++ b/src/features/speculative-ui/future-table/how-it-works.md @@ -0,0 +1,130 @@ +# How the future table works + +A normal table renders a list. When the data changes, a new list appears and +everything on screen jumps to a new position — you re-find your place by +reading. + +This one inverts that. It renders a **fixed number of slots** that live at fixed +positions and never move. Data is projected *into* them, and a slot whose +contents changed flips over in place, like a split-flap departure board. Rows +never move, so there is nothing for your eyes to track. + +This is exactly what our code in [`engine/core.ts`](./engine/core.ts) does. + +--- + +## The detail + +### Terms (read these first) + +- `slot` = "a fixed position in the table. Slot 3 is the fourth row on screen, always, whatever data is in it." +- `rowCount` = "how many slots exist. Set once; never derived from how much data arrived." +- `projection` = "the mapping from data to slots. Pure — same inputs, same output." +- `snapshot` = "what every slot showed on the previous render, kept so the next one can diff against it." +- `phase` = "what a slot should be doing right now: `idle`, `filling`, `clearing`, or `updating`." +- `empty slot` = "a slot with no data behind it — rendered as a placeholder, not omitted." + +### Step 1: slots come first, data second + +The whole inversion is one line: + +``` +slot i ↔ data[pageIndex · rowCount + i] +``` + +`rowCount` is an input, not a result. Ask for 10 slots and you get 10 rows +whether the data has 100 entries, 3, or none — the last case being a table that +is fully laid out and completely empty, which is exactly what you want on first +paint instead of a collapsed box that expands later. + +Page count follows from the same arithmetic: + +``` +pageCount = max(1, ceil(data.length / rowCount)) +``` + +The `max(1, …)` keeps an empty dataset at one page rather than zero, so the +pager has something coherent to show. + +### Step 2: cells are addressed by position, not by record + +Every cell gets an id built from **where it is**, never from what's in it: + +``` +cellId = `${slotIndex}:${columnId}` +``` + +This is the load-bearing decision. In a normal table the key is the record id, +so when the data changes React unmounts one row and mounts another — the DOM +node is different, and any animation is an exit plus an enter. Here the key is +the position, so **the DOM node persists** and the change is a value swap inside +a node that never went anywhere. That is what makes a flip-in-place possible at +all. + +### Step 3: the phase comes from a diff, not from an event + +Nothing tells a cell it changed. Each render compares the slot's new state to +the snapshot of its old one: + +| previous | now | phase | +|---|---|---| +| — (no history) | anything | `idle` | +| empty | filled | `filling` | +| filled | empty | `clearing` | +| filled | filled, different record | `updating` | +| filled | filled, same record, different value | `updating` | +| filled | filled, same record, same value | `idle` | + +Two details worth their own line: + +- **First render is always `idle`**, deliberately. With no history there's no + transition to show, and animating every slot on mount would turn arriving at + the page into a light show. +- **A different record in the slot always animates**, even when this particular + column's value is identical. Two people both named "Active" in a status column + are still two different people arriving, and a slot that sat still would + quietly lie about that. + +Value comparison uses `Object.is`, so `NaN` matches itself and `+0` / `-0` don't +collide. + +### Step 4: pagination is re-projection + +Turning the page doesn't rebuild anything. It changes `pageIndex`, which changes +which record each slot maps to, which makes every occupied slot's `rowId` +differ — so the whole board flips at once, in place, with no layout change, +because the slots themselves never moved. + +An in-between page (say 7 records across 10 slots) fills three slots with +`clearing` and leaves the table exactly the same height it was. + +### Step 5: what an empty slot renders + +Empty cells are not blank. They render a placeholder string, resolved by +layering three levels — **system ← global ← column**, later wins: + +``` +resolved = { ...SYSTEM_EMPTY_STATE, ...tableDefaultEmpty, ...column.empty } +``` + +Each field resolves independently, so a column that only sets `fillFontType` +keeps the system's `fillString` and `fillClassName` rather than blanking them. +The system default is `xxx-xxx` at `text-muted-foreground/15` — visible enough +to hold the shape, faint enough not to read as content. + +### Step 6: the projection is pure + +`buildFutureTable(options, prevSnapshot)` returns `{ instance, next }` and +touches nothing else — no React, no refs, no side effects. The snapshot goes in +as an argument and the new one comes back as a value. + +Cells are built **eagerly** rather than behind a getter, specifically so `next` +is fully populated by the time the function returns. A lazy getter would mean +the snapshot depended on whether the view happened to read a cell, and a column +scrolled out of view would silently miss its own history. + +### Why it works in one sentence + +Key the DOM by position instead of by record, and "the data changed" stops being +a list being replaced and becomes a value changing inside a node that was +already there — which is the only reason it can flip instead of jump. diff --git a/src/hooks/useCompare.test.tsx b/src/hooks/useCompare.test.tsx new file mode 100644 index 0000000..473770f --- /dev/null +++ b/src/hooks/useCompare.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it } from "vitest" + +import { useCompare } from "./useCompare" + +let api: ReturnType + +function Probe() { + api = useCompare() + return {api.comparing ? "before" : "after"} +} + +const showing = () => screen.getByTestId("probe").textContent + +afterEach(cleanup) + +describe("useCompare", () => { + it("starts on the processed image", () => { + render() + expect(showing()).toBe("after") + }) + + it("stays on the original while latched", () => { + render() + + act(() => api.setLatched(true)) + expect(showing()).toBe("before") + + // No release to wait for — that is the whole point of the toggle. + expect(api.latched).toBe(true) + }) + + it("shows the original only for the duration of a peek", () => { + render() + + act(() => api.startPeek()) + expect(showing()).toBe("before") + + act(() => api.endPeek()) + expect(showing()).toBe("after") + }) + + it("flips back to the processed image when peeking while latched", () => { + render() + + act(() => api.setLatched(true)) + act(() => api.startPeek()) + expect(showing()).toBe("after") + + act(() => api.endPeek()) + expect(showing()).toBe("before") + }) + + it("does not let a peek clear the latch", () => { + render() + + act(() => api.setLatched(true)) + act(() => api.startPeek()) + act(() => api.endPeek()) + + expect(api.latched).toBe(true) + expect(showing()).toBe("before") + }) +}) diff --git a/src/hooks/useCompare.ts b/src/hooks/useCompare.ts new file mode 100644 index 0000000..62f8119 --- /dev/null +++ b/src/hooks/useCompare.ts @@ -0,0 +1,34 @@ +import { useState } from "react" + +/** + * The two ways to look at the "before" of a before/after. + * + * Both live here because the dithering and anti-aliasing tools want exactly the + * same pair, and because the interesting part is how they combine: + * + * - **Latched** — a toggle in the controls panel. Stays on until you switch it + * off. This is what you need when the comparison isn't a glance: dragging a + * slider while watching the original, or leaving it on to point something out + * to someone else. Holding a button can't do that. + * - **Peeking** — press and hold on the canvas itself. Momentary, zero travel, + * right where your eyes already are. + * + * They combine with XOR, not OR, so a hold always shows *the other one*. Held + * while latched, you get the processed image back. That keeps the gesture + * meaningful in both states instead of being dead half the time — with OR, a + * hold would do nothing whenever the toggle was already on, which reads as a + * broken control rather than a deliberate one. + */ +export function useCompare() { + const [latched, setLatched] = useState(false) + const [peeking, setPeeking] = useState(false) + + return { + /** What the view should actually render as "before". */ + comparing: latched !== peeking, + latched, + setLatched, + startPeek: () => setPeeking(true), + endPeek: () => setPeeking(false), + } +}