From c552aa829deeff7d300cbc963b9601f4c86beccc Mon Sep 17 00:00:00 2001 From: Yannick Martin Date: Wed, 17 Jun 2026 08:38:26 +0200 Subject: [PATCH 1/3] feat(quill): add color-rewrite widget and fix diff view differences --- app/(tabs)/quill/actions.ts | 40 +---- app/(tabs)/quill/page.tsx | 124 +++++++++++--- app/globals.css | 70 ++++++++ components/quill/colour-drop.tsx | 195 +++++++++++++++++++++++ components/quill/diff-view.tsx | 22 ++- components/quill/editor.tsx | 87 +++++++++- lib/quill/blocks.ts | 52 ++++++ lib/quill/diff.ts | 6 +- tests/unit/quill/sentence-window.test.ts | 27 ++++ 9 files changed, 558 insertions(+), 65 deletions(-) create mode 100644 components/quill/colour-drop.tsx create mode 100644 tests/unit/quill/sentence-window.test.ts diff --git a/app/(tabs)/quill/actions.ts b/app/(tabs)/quill/actions.ts index 2ce8cc0..1aa18f3 100644 --- a/app/(tabs)/quill/actions.ts +++ b/app/(tabs)/quill/actions.ts @@ -8,7 +8,7 @@ import { z } from "zod"; import { ensureScribe } from "@/lib/auth/scribe"; import { getDb, schema } from "@/lib/db"; import { classifyArc, MIN_ARC_SENTENCES, movingAverage } from "@/lib/quill/arc"; -import { rewriteFromDiff, type TargetRewrite } from "@/lib/quill/diff"; +import type { TargetRewrite } from "@/lib/quill/diff"; import { type HueSegment, tileInfluences } from "@/lib/quill/explain"; import { fingerprintDistance } from "@/lib/quill/fingerprint"; import { clampForModel, MAX_BAND_PARAGRAPHS } from "@/lib/quill/limits"; @@ -288,30 +288,7 @@ Rewrite the draft so it FEELS like the target while preserving: Make changes at the level of word choice, sentence rhythm, image-density, and connective tissue. Don't add new facts, characters, or events. Don't moralise. -Return your answer as an aligned diff: - -- "diff": an ordered list of text segments that, read together, spell out BOTH the - original and the rewrite. Each segment has "op": - - "same" — unchanged text shared by both versions (include the surrounding - untouched words, punctuation and spaces verbatim) - - "remove" — text present in the ORIGINAL but cut or toned down - - "add" — text present only in the REWRITE - Keep segments tight: wrap only the words that actually changed in remove/add, - and put a remove immediately before its replacement add. Preserve all - whitespace inside segments so the views read naturally. Concatenating every - non-"remove" segment MUST equal the rewritten prose; concatenating every - non-"add" segment MUST equal the original prose exactly.`; - -const RewriteResponseSchema = z.object({ - diff: z - .array( - z.object({ - text: z.string(), - op: z.enum(["same", "add", "remove"]), - }), - ) - .min(1), -}); +Return ONLY the rewritten prose — no preamble, no quotation marks, no commentary. Keep the paragraph breaks of the original.`; const INTENSITY_INSTRUCTIONS: Record = { 1: "Whisper — change at most one word every 2–3 sentences. Only the most natural synonym swap. The text must feel untouched.", @@ -323,8 +300,9 @@ const INTENSITY_INSTRUCTIONS: Record = { /** * Asks Claude to rewrite the user's draft toward a free-form target descriptor. - * Returns a structured rewrite — an aligned word-level diff — so the client can - * show green/pink highlighting and per-hunk accept/reject. + * Returns the rewritten prose as plain text; the client computes the word-level + * inline diff itself (diffWords), so we don't round-trip prose through a + * model-emitted segment array — that reconstruction dropped boundary spaces. * * @param intensity 1–5 controlling how aggressively to change the prose. * Defaults to 3 (Moderate). 1 is a near-invisible whisper; 5 is a @@ -343,18 +321,14 @@ export async function suggestRewrite(input: { const intensity = Math.min(5, Math.max(1, Math.round(input.intensity ?? 3))); const intensityLine = `Intensity: ${intensity}/5 — ${INTENSITY_INSTRUCTIONS[intensity]}`; - const { object } = await generateObject({ + const { text: out } = await generateText({ model: anthropic("claude-sonnet-4-6"), - schema: RewriteResponseSchema, system: REWRITE_SYSTEM_PROMPT, prompt: `${intensityLine}\nTarget: ${target}\n\nOriginal:\n${clampForModel(text)}`, maxRetries: 2, }); - return { - diff: object.diff, - rewrite: rewriteFromDiff(object.diff).trim(), - }; + return { rewrite: out.trim() }; } // --------------------------------------------------------------------------- diff --git a/app/(tabs)/quill/page.tsx b/app/(tabs)/quill/page.tsx index 6b9cfd9..2d8151d 100644 --- a/app/(tabs)/quill/page.tsx +++ b/app/(tabs)/quill/page.tsx @@ -14,8 +14,19 @@ import { import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { ArcChart } from "@/components/quill/arc-chart"; +import { + ColourPalette, + ColourSplash, + colourDropByKey, + type SplashState, +} from "@/components/quill/colour-drop"; import { DiffActions, DiffText } from "@/components/quill/diff-view"; -import { Editor, type EditorHandle, type SelectionRange } from "@/components/quill/editor"; +import { + type ColourDropDetail, + Editor, + type EditorHandle, + type SelectionRange, +} from "@/components/quill/editor"; import { HueExplainer } from "@/components/quill/hue-explainer"; import { TargetWidgets } from "@/components/quill/target-widgets"; import { useDiff } from "@/components/quill/use-diff"; @@ -65,7 +76,7 @@ type PanelPreset = "essentials" | "analyse" | "rewrite" | "custom"; const PANEL_PRESETS: Record, readonly string[]> = { essentials: ["hue", "save"], analyse: ["hue", "fingerprint", "arc", "neighbours", "save"], - rewrite: ["hue", "target", "save"], + rewrite: ["hue", "target", "colour", "save"], }; const CUSTOM_PANEL_OPTIONS = [ @@ -74,6 +85,7 @@ const CUSTOM_PANEL_OPTIONS = [ { key: "arc", label: "Emotional arc" }, { key: "neighbours", label: "Nearest authors" }, { key: "target", label: "Rewrite" }, + { key: "colour", label: "Colour drop" }, { key: "save", label: "Save to scribe" }, ] as const; @@ -118,6 +130,9 @@ export default function QuillPage() { const [rewriteError, setRewriteError] = useState(null); const [isRewriting, startRewrite] = useTransition(); const [highlightPending, setHighlightPending] = useState(false); + // Colour-drop splash overlay (null when idle). The page owns the two-step + // timing; the overlay just paints whatever phase it's handed. + const [splash, setSplash] = useState(null); // Pre-rewrite snapshots. Accepting a rewrite remounts the editor and wipes // TipTap's undo stack — this is the way back. const [versions, setVersions] = useState([]); @@ -130,6 +145,19 @@ export default function QuillPage() { rewrite ? (committedSelection?.text ?? plainText(draft)) : "", rewrite?.rewrite ?? "", ); + // Surrounding context for the diff. When the span opens mid-paragraph, its + // immediate before/after fragment hugs the diff inline (leadIn/tailOut) so a + // sub-paragraph rewrite reads as one paragraph; the remaining whole paragraphs + // render as greyed blocks above/below. + const beforeParts = committedSelection?.beforeText?.split("\n\n").filter(Boolean) ?? []; + const afterParts = committedSelection?.afterText?.split("\n\n").filter(Boolean) ?? []; + const leadIn = + committedSelection?.openStart && beforeParts.length + ? beforeParts[beforeParts.length - 1] + : undefined; + const tailOut = committedSelection?.openEnd && afterParts.length ? afterParts[0] : undefined; + const blockBefore = leadIn ? beforeParts.slice(0, -1) : beforeParts; + const blockAfter = tailOut ? afterParts.slice(1) : afterParts; // Bumping this remounts the Editor with new initialContent — TipTap // doesn't expose a reactive `value` prop and remount is the least // invasive way to replace the buffer when the user accepts a rewrite @@ -470,6 +498,47 @@ export default function QuillPage() { }); }; + // A colour swatch was dropped on a word. Splash lands at the drop point while + // the rewrite is in flight; when it returns we bloom the full splash over the + // affected span, then hand off to the same inline diff the Rewrite panel uses. + const handleColourDrop = (detail: ColourDropDetail) => { + if (splash) return; // one drop at a time + const colour = colourDropByKey(detail.colourKey); + if (!colour) return; + const colourCss = hueFromHSL(colour.hsl.hue, colour.hsl.saturation, colour.hsl.lightness).css; + setSplash({ colourCss, origin: detail.origin, ripples: detail.ripples, phase: "landing" }); + setCommittedSelection(detail.span); + setRewriteError(null); + setRewrite(null); + startRewrite(async () => { + try { + const result = await suggestRewrite({ + text: detail.span.text, + target: colour.target, + intensity, + }); + if (!result) { + setSplash(null); + setCommittedSelection(null); + toast.error( + "Drop on a fuller passage — the rewrite needs at least 8 words around the word.", + ); + return; + } + // Step two: bloom the splash, then reveal the diff once it settles. + setSplash((s) => (s ? { ...s, phase: "splash" } : s)); + window.setTimeout(() => { + setRewrite(result); + setSplash(null); + }, 950); + } catch (err) { + setSplash(null); + setCommittedSelection(null); + setRewriteError((err as Error).message); + } + }); + }; + const acceptRewrite = (text: string) => { const html = text .split(/\n\s*\n+/) @@ -539,9 +608,10 @@ export default function QuillPage() { aria-hidden="true" className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-ink-bleed to-transparent opacity-60" /> + {splash && } - {targetActive && rewrite ? ( -
+ {rewrite ? ( +
setHighlightPending(true)} onHighlightLeave={() => setHighlightPending(false)} /> - {committedSelection?.beforeText?.split("\n\n").map((para) => ( -

- {para} -

- ))} - - {committedSelection?.afterText?.split("\n\n").map((para) => ( -

- {para} -

- ))} + {/* One block-flow prose container mirroring the editor exactly, + so before/diff/after paragraphs collapse margins uniformly. */} +
+ {blockBefore.map((para) => ( +

+ {para} +

+ ))} + + {blockAfter.map((para) => ( +

+ {para} +

+ ))} +
) : ( setLiveSelection(sel?.text ?? null)} highlightBlock={highlight} + onColourDrop={handleColourDrop} /> )} @@ -646,6 +717,7 @@ export default function QuillPage() { /> )} {panelVisible("target") && } + {panelVisible("colour") && } {panelVisible("save") && ( c.key === key); +} + +export function ColourPalette() { + return ( + + +

Colour drop

+
+ {COLOUR_DROPS.map((c) => { + const css = hueFromHSL(c.hsl.hue, c.hsl.saturation, c.hsl.lightness).css; + return ( +
+

+ Drag a colour onto a word — the splash marks what gets rewritten toward that mood. +

+
+
+ ); +} + +export type SplashState = { + /** CSS colour of the dropped swatch. */ + colourCss: string; + /** Targeted word, in viewport px. */ + origin: { x: number; y: number }; + /** Sampled points across the affected span, in viewport px. */ + ripples: { x: number; y: number }[]; + /** "landing" while the rewrite is in flight, "splash" once it has arrived. */ + phase: "landing" | "splash"; +}; + +/** + * The splash overlay, painted over the editor card. Step one (`landing`) is a + * single ripple at the drop point — the stone hitting the water — held while + * Claude rewrites. Step two (`splash`) blooms the main splash on the word, then + * radiates secondary splashes across the words about to change. Pure visual; + * the page owns the timing and clears it. + */ +export function ColourSplash({ splash }: { splash: SplashState }) { + const ref = useRef(null); + const [rect, setRect] = useState(null); + + // Splash coords arrive in viewport px; convert to this overlay's local space. + useLayoutEffect(() => { + if (ref.current) setRect(ref.current.getBoundingClientRect()); + }, []); + + const local = (p: { x: number; y: number }) => + rect ? { x: p.x - rect.left, y: p.y - rect.top } : null; + const origin = local(splash.origin); + + return ( + + ); +} + +function Drop({ + x, + y, + colour, + className, +}: { + x: number; + y: number; + colour: string; + className: string; +}) { + return ( + + ); +} diff --git a/components/quill/diff-view.tsx b/components/quill/diff-view.tsx index 51e3081..1446153 100644 --- a/components/quill/diff-view.tsx +++ b/components/quill/diff-view.tsx @@ -66,17 +66,30 @@ export function DiffText({ states, setHunkState, highlightPending = false, + leadIn, + tailOut, }: { segments: DiffSegment[]; states: Record; setHunkState: (id: string, next: HunkState) => void; highlightPending?: boolean; + /** Unchanged in-paragraph context hugging the start of the span, rendered + * greyed and inline so a mid-paragraph rewrite reads as one paragraph. */ + leadIn?: string; + /** Unchanged in-paragraph context hugging the end of the span. */ + tailOut?: string; }) { const paragraphs = buildParagraphGroups(segments); + const lastIndex = paragraphs.length - 1; + // Returns a bare run of

s (no wrapper) so the diff paragraphs sit in the + // same block flow as the surrounding before/after context the page renders — + // margins collapse uniformly and `first:mt-0` resolves against the real first + // paragraph, matching the Tiptap editor's rhythm exactly. return ( -

- {paragraphs.map((para) => ( + <> + {paragraphs.map((para, pIndex) => (

+ {pIndex === 0 && leadIn && {leadIn}} {para.items.map((item) => { if (item.kind === "br") return
; if (item.kind === "text") return {item.value}; @@ -92,9 +105,12 @@ export function DiffText({ /> ); })} + {pIndex === lastIndex && tailOut && ( + {tailOut} + )}

))} -
+ ); } diff --git a/components/quill/editor.tsx b/components/quill/editor.tsx index fde6530..e5a193c 100644 --- a/components/quill/editor.tsx +++ b/components/quill/editor.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { @@ -40,7 +41,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { hueFromHSL } from "@/lib/colour/placeholder"; -import { textblockRanges } from "@/lib/quill/blocks"; +import { sentenceWindowAt, textblockRanges } from "@/lib/quill/blocks"; import { NUDGE_PRESETS } from "@/lib/quill/nudge-presets"; import { cn } from "@/lib/utils"; @@ -60,6 +61,26 @@ export type SelectionRange = { beforeText?: string; /** Text in the document after the selection — populated by getSelection(), not onSelectionChange. */ afterText?: string; + /** Span begins mid-paragraph — the preceding context continues inline with it. */ + openStart?: boolean; + /** Span ends mid-paragraph — the following context continues inline with it. */ + openEnd?: boolean; +}; + +/** Drag-and-drop mime carrying a colour-drop swatch key from the palette. */ +export const COLOUR_DROP_MIME = "application/x-inklings-colour"; + +/** What a colour-drop resolves to: the swatch, the span to rewrite, and the + * viewport coordinates the splash should bloom from. */ +export type ColourDropDetail = { + /** The dropped swatch's key — the page maps it to a rewrite target. */ + colourKey: string; + /** Sentence-window around the drop, ready to feed the diff/rewrite flow. */ + span: SelectionRange; + /** Where the swatch landed (the targeted word), in viewport px. */ + origin: { x: number; y: number }; + /** Sampled points across the span for the radiating secondary splashes. */ + ripples: { x: number; y: number }[]; }; type EditorProps = { @@ -83,6 +104,8 @@ type EditorProps = { * the ink accent. Pass null to clear the highlight. */ highlightBlock?: { index: number; tint?: string | null } | null; + /** A colour swatch was dropped on the prose — resolved span + splash coords. */ + onColourDrop?: (detail: ColourDropDetail) => void; }; /** Imperative handle for the Quill editor, exposed via `ref`. */ @@ -95,6 +118,22 @@ export type EditorHandle = { replaceRange: (from: number, to: number, html: string) => void; }; +// Whether the span begins/ends mid-paragraph — when it does, the adjacent +// context belongs to the same block and should read inline with the rewrite +// rather than as a separate paragraph above/below it. +function openness( + doc: ProseMirrorNode, + from: number, + to: number, +): { openStart: boolean; openEnd: boolean } { + const $from = doc.resolve(from); + const $to = doc.resolve(to); + return { + openStart: $from.parentOffset > 0, + openEnd: $to.parentOffset < $to.parent.content.size, + }; +} + // Marks the top-level block holding the caret with `quill-focus-active`, so // focus mode can dim everything else via CSS. Always installed — it's inert // until the wrapper opts into the dimming classes. @@ -182,6 +221,7 @@ export function Editor({ onDeriveHue, onRewriteSelection, highlightBlock, + onColourDrop, }: EditorProps & { ref?: Ref }) { // Track selection emptiness so the right-click menu can disable // selection-only actions; updated on every selection change. @@ -306,6 +346,47 @@ export function Editor({ toast.success("Copied"); }; + // Allow the palette's swatch drag to drop on the prose — without + // preventDefault on dragover the browser refuses the drop. + const handleColourDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes(COLOUR_DROP_MIME)) e.preventDefault(); + }; + + // Resolve a dropped swatch to its target word, the sentence-window to rewrite, + // and the screen coordinates the splash plays over. We preventDefault so + // ProseMirror's own drop handling never sees it. + const handleColourDrop = (e: React.DragEvent) => { + if (!editor) return; + const colourKey = e.dataTransfer.getData(COLOUR_DROP_MIME); + if (!colourKey) return; // not our drag — let the editor handle it normally + e.preventDefault(); + e.stopPropagation(); + const { view } = editor; + const at = view.posAtCoords({ left: e.clientX, top: e.clientY }); + if (!at) return; + const window = sentenceWindowAt(view.state.doc, at.pos); + if (!window) return; + const { from, to } = window; + const { doc } = view.state; + const span: SelectionRange = { + text: doc.textBetween(from, to, "\n\n"), + from, + to, + beforeText: doc.textBetween(0, from, "\n\n").trimStart(), + afterText: doc.textBetween(to, doc.content.size, "\n\n").trimEnd(), + ...openness(doc, from, to), + }; + // Sample evenly across the span so the secondary splashes trace the words + // about to change, radiating out from the drop point. + const STEPS = 5; + const ripples = Array.from({ length: STEPS + 1 }, (_, i) => { + const p = Math.min(to, Math.max(from, Math.round(from + ((to - from) * i) / STEPS))); + const c = view.coordsAtPos(p); + return { x: c.left, y: (c.top + c.bottom) / 2 }; + }); + onColourDrop?.({ colourKey, span, origin: { x: e.clientX, y: e.clientY }, ripples }); + }; + const canHue = hasSelection && !!onDeriveHue; const canRewrite = hasSelection && !!onRewriteSelection; @@ -353,6 +434,7 @@ export function Editor({ to, beforeText: doc.textBetween(0, from, "\n\n").trimStart(), afterText: doc.textBetween(to, doc.content.size, "\n\n").trimEnd(), + ...openness(doc, from, to), }; }, replaceRange(from: number, to: number, html: string) { @@ -373,7 +455,10 @@ export function Editor({
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only colour-drop zone over the editable area; the same rewrite is keyboard-reachable via the Rewrite panel */}
offset < s.end); + if (i === -1) i = spans.length - 1; + const lo = spans[Math.max(0, i - 1)]; + const hi = spans[Math.min(spans.length - 1, i + 1)]; + if (!lo || !hi) return null; + return { from: blockStart + lo.start, to: blockStart + hi.end }; +} diff --git a/lib/quill/diff.ts b/lib/quill/diff.ts index 1a93f8c..80ed21a 100644 --- a/lib/quill/diff.ts +++ b/lib/quill/diff.ts @@ -19,9 +19,11 @@ export type RewriteSegment = { }; export type TargetRewrite = { - /** Full rewritten prose, reconstructed from the diff (`add` + `same`). */ + /** Full rewritten prose. The client diffs this against the original itself + * (diffWords), so the structured `diff` is optional and no longer the source + * of the rewrite text. */ rewrite: string; - diff: RewriteSegment[]; + diff?: RewriteSegment[]; }; /** Reconstruct the rewritten text: everything except removed spans. */ diff --git a/tests/unit/quill/sentence-window.test.ts b/tests/unit/quill/sentence-window.test.ts new file mode 100644 index 0000000..af9847a --- /dev/null +++ b/tests/unit/quill/sentence-window.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { sentenceSpans } from "@/lib/quill/blocks"; + +describe("sentenceSpans", () => { + it("splits on sentence-ending punctuation", () => { + const text = "One fish. Two fish! Red fish? Blue fish."; + const spans = sentenceSpans(text); + expect(spans.map((s) => text.slice(s.start, s.end))).toEqual([ + "One fish.", + "Two fish!", + "Red fish?", + "Blue fish.", + ]); + }); + + it("keeps a trailing fragment with no terminator", () => { + const text = "Done. Not done"; + const spans = sentenceSpans(text); + expect(spans).toHaveLength(2); + const last = spans[1]; + expect(last && text.slice(last.start, last.end)).toBe("Not done"); + }); + + it("treats abbreviation-free single sentences as one span", () => { + expect(sentenceSpans("just one sentence")).toEqual([{ start: 0, end: 17 }]); + }); +}); From 3fe8bb28dc74dbb140a9a4f14a7a4c9f0e0d7fec Mon Sep 17 00:00:00 2001 From: Yannick Martin Date: Wed, 17 Jun 2026 08:53:54 +0200 Subject: [PATCH 2/3] feat(quill): add better splash animation --- app/globals.css | 102 ++++++++++------- components/quill/colour-drop.tsx | 186 ++++++++++++++++++++++++++----- 2 files changed, 218 insertions(+), 70 deletions(-) diff --git a/app/globals.css b/app/globals.css index 3540dc0..dd069b5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -206,73 +206,91 @@ animation: inklings-pulse-soft 4s ease-in-out infinite; } - /* Colour-drop splash (Quill). Coordinates set left/top; the negative margins - centre each blob on its point and transforms animate the bloom. */ - @keyframes inklings-splash-land { - 0%, - 100% { - transform: scale(0.55); - opacity: 0.55; + /* Colour-drop ink splash (Quill). Each blot is a turbulence-displaced SVG + circle; these only animate scale/opacity. The big bloom overshoots then + lingers and fades slowly so it reads as ink soaking in, not a quick blip. */ + @keyframes inklings-ink-bloom { + 0% { + transform: scale(0); + opacity: 0; } - 50% { - transform: scale(1); - opacity: 0.85; + 18% { + opacity: 0.92; + } + 45% { + transform: scale(1.12); + opacity: 0.9; + } + 72% { + transform: scale(0.99); + opacity: 0.82; + } + 100% { + transform: scale(1.05); + opacity: 0; } } - @keyframes inklings-splash-main { + @keyframes inklings-ink-spatter { 0% { - transform: scale(0.1); - opacity: 0.85; + transform: scale(0) rotate(var(--spin, 0deg)); + opacity: 0; } - 60% { - opacity: 0.5; + 35% { + transform: scale(1.18) rotate(var(--spin, 0deg)); + opacity: 0.9; + } + 65% { + transform: scale(0.96) rotate(var(--spin, 0deg)); + opacity: 0.72; } 100% { - transform: scale(1); + transform: scale(1) rotate(var(--spin, 0deg)); opacity: 0; } } - @keyframes inklings-splash-ripple { + @keyframes inklings-ink-land { + 0%, + 100% { + transform: scale(0.8); + opacity: 0.72; + } + 50% { + transform: scale(1.06); + opacity: 0.95; + } + } + @keyframes inklings-ink-ring { 0% { - transform: scale(0.1); + transform: scale(0.35); opacity: 0.7; } 100% { - transform: scale(1); + transform: scale(1.3); opacity: 0; } } - .inklings-splash-land { - width: 16px; - height: 16px; - margin: -8px 0 0 -8px; - filter: blur(1px); - animation: inklings-splash-land 1.1s ease-in-out infinite; + .inklings-ink-bloom { + opacity: 0; + animation: inklings-ink-bloom 1.7s cubic-bezier(0.18, 0.85, 0.32, 1) forwards; } - .inklings-splash-main { - width: 150px; - height: 150px; - margin: -75px 0 0 -75px; + .inklings-ink-spatter { opacity: 0; - filter: blur(3px); - animation: inklings-splash-main 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation: inklings-ink-spatter 1.25s cubic-bezier(0.2, 0.8, 0.3, 1) both; + } + .inklings-ink-land { + animation: inklings-ink-land 1.3s ease-in-out infinite; } - .inklings-splash-ripple { - width: 64px; - height: 64px; - margin: -32px 0 0 -32px; - border-width: 2px; - border-style: solid; + .inklings-ink-ring { opacity: 0; - animation: inklings-splash-ripple 0.7s ease-out forwards; + animation: inklings-ink-ring 1.3s ease-out infinite; } @media (prefers-reduced-motion: reduce) { - .inklings-splash-land, - .inklings-splash-main, - .inklings-splash-ripple { + .inklings-ink-bloom, + .inklings-ink-spatter, + .inklings-ink-land, + .inklings-ink-ring { animation-duration: 0.3s; animation-iteration-count: 1; - filter: none; } } diff --git a/components/quill/colour-drop.tsx b/components/quill/colour-drop.tsx index ef8f012..c7f107c 100644 --- a/components/quill/colour-drop.tsx +++ b/components/quill/colour-drop.tsx @@ -110,12 +110,24 @@ export type SplashState = { phase: "landing" | "splash"; }; +// Short radial spray of droplets flung from the impact point — angle (deg), +// distance (px) and size factor. Deterministic so it doesn't reshuffle on +// re-render, but irregular enough to read as spatter, not a tidy ring. +const SPRAY = [ + { angle: 18, dist: 32, size: 0.5, spin: 40 }, + { angle: 96, dist: 48, size: 0.34, spin: 210 }, + { angle: 168, dist: 38, size: 0.62, spin: 120 }, + { angle: 232, dist: 54, size: 0.42, spin: 300 }, + { angle: 304, dist: 30, size: 0.55, spin: 70 }, +] as const; + /** - * The splash overlay, painted over the editor card. Step one (`landing`) is a - * single ripple at the drop point — the stone hitting the water — held while - * Claude rewrites. Step two (`splash`) blooms the main splash on the word, then - * radiates secondary splashes across the words about to change. Pure visual; - * the page owns the timing and clears it. + * The splash overlay, painted over the editor card. Step one (`landing`) is an + * ink drop quivering at the impact point with an expanding ring — the stone + * hitting the water — held while Claude rewrites. Step two (`splash`) blooms a + * big organic ink blot on the word, throws a radial spray, then spatters along + * the words about to change. Edges are irregular (turbulence-displaced circles), + * so it reads as ink, not a disc. Pure visual; the page owns the timing. */ export function ColourSplash({ splash }: { splash: SplashState }) { const ref = useRef(null); @@ -129,6 +141,7 @@ export function ColourSplash({ splash }: { splash: SplashState }) { const local = (p: { x: number; y: number }) => rect ? { x: p.x - rect.left, y: p.y - rect.top } : null; const origin = local(splash.origin); + const colour = splash.colourCss; return (