Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 7 additions & 33 deletions app/(tabs)/quill/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<number, string> = {
1: "Whisper — change at most one word every 2–3 sentences. Only the most natural synonym swap. The text must feel untouched.",
Expand All @@ -323,8 +300,9 @@ const INTENSITY_INSTRUCTIONS: Record<number, string> = {

/**
* 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
Expand All @@ -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() };
}

// ---------------------------------------------------------------------------
Expand Down
131 changes: 103 additions & 28 deletions app/(tabs)/quill/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,7 +76,7 @@ type PanelPreset = "essentials" | "analyse" | "rewrite" | "custom";
const PANEL_PRESETS: Record<Exclude<PanelPreset, "custom">, readonly string[]> = {
essentials: ["hue", "save"],
analyse: ["hue", "fingerprint", "arc", "neighbours", "save"],
rewrite: ["hue", "target", "save"],
rewrite: ["hue", "target", "colour", "save"],
};

const CUSTOM_PANEL_OPTIONS = [
Expand All @@ -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;

Expand Down Expand Up @@ -118,6 +130,9 @@ export default function QuillPage() {
const [rewriteError, setRewriteError] = useState<string | null>(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<SplashState | null>(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<DraftVersion[]>([]);
Expand All @@ -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
Expand Down Expand Up @@ -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+/)
Expand Down Expand Up @@ -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 && <ColourSplash splash={splash} />}
<CardContent className="p-6 sm:p-8">
{targetActive && rewrite ? (
<div className="flex flex-col gap-3">
{rewrite && (
<div>
<DiffActions
resolvedCount={diff.resolvedCount}
totalChanges={diff.totalChanges}
Expand All @@ -551,30 +621,33 @@ export default function QuillPage() {
onHighlightEnter={() => setHighlightPending(true)}
onHighlightLeave={() => setHighlightPending(false)}
/>
{committedSelection?.beforeText?.split("\n\n").map((para) => (
<p
key={para}
className="my-3 first:mt-0 font-serif text-lg leading-relaxed text-ink-deep/40 select-none"
>
{para}
</p>
))}
<DiffText
segments={diff.segments}
states={diff.states}
setHunkState={diff.setHunkState}
highlightPending={highlightPending}
/>
{committedSelection?.afterText?.split("\n\n").map((para) => (
<p
key={para}
className="my-3 first:mt-0 font-serif text-lg leading-relaxed text-ink-deep/40 select-none"
>
{para}
</p>
))}
{/* One block-flow prose container mirroring the editor exactly,
so before/diff/after paragraphs collapse margins uniformly. */}
<div className="mt-4 min-h-[400px] w-full font-serif text-lg leading-relaxed text-ink-deep">
{blockBefore.map((para) => (
<p key={para} className="my-3 first:mt-0 text-ink-deep/40 select-none">
{para}
</p>
))}
<DiffText
segments={diff.segments}
states={diff.states}
setHunkState={diff.setHunkState}
highlightPending={highlightPending}
leadIn={leadIn}
tailOut={tailOut}
/>
{blockAfter.map((para) => (
<p key={para} className="my-3 first:mt-0 text-ink-deep/40 select-none">
{para}
</p>
))}
</div>
</div>
) : (
)}
{/* Editor stays mounted under the diff (hidden) so editorRef stays
live — acceptRewrite's replaceRange splices into the real doc. */}
<div className={cn(rewrite && "hidden")}>
<Editor
key={editorKey}
ref={editorRef}
Expand All @@ -585,8 +658,9 @@ export default function QuillPage() {
onRewriteSelection={rewriteSelection}
onSelectionChange={(sel) => setLiveSelection(sel?.text ?? null)}
highlightBlock={highlight}
onColourDrop={handleColourDrop}
/>
)}
</div>
</CardContent>
</Card>
{stats.words > 0 && <WritingStatsBar stats={stats} />}
Expand Down Expand Up @@ -646,6 +720,7 @@ export default function QuillPage() {
/>
)}
{panelVisible("target") && <DriftMeter readout={readout} target={targetColour} />}
{panelVisible("colour") && <ColourPalette />}
{panelVisible("save") && (
<SaveSettings
cloudSave={cloudSave}
Expand Down
88 changes: 88 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,94 @@
animation: inklings-pulse-soft 4s ease-in-out infinite;
}

/* 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;
}
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-ink-spatter {
0% {
transform: scale(0) rotate(var(--spin, 0deg));
opacity: 0;
}
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) rotate(var(--spin, 0deg));
opacity: 0;
}
}
@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.35);
opacity: 0.7;
}
100% {
transform: scale(1.3);
opacity: 0;
}
}
.inklings-ink-bloom {
opacity: 0;
animation: inklings-ink-bloom 1.7s cubic-bezier(0.18, 0.85, 0.32, 1) forwards;
}
.inklings-ink-spatter {
opacity: 0;
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-ink-ring {
opacity: 0;
animation: inklings-ink-ring 1.3s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.inklings-ink-bloom,
.inklings-ink-spatter,
.inklings-ink-land,
.inklings-ink-ring {
animation-duration: 0.3s;
animation-iteration-count: 1;
}
}

/* EmoArc band → editor link (#1): the block under the hovered hue-band
segment, washed in that segment's own hue (--emoarc-tint, set inline by the
decoration) with a faint inset ring. Paint-only, so no reflow; matches the
Expand Down
Loading
Loading