diff --git a/app/(tabs)/quill/page.tsx b/app/(tabs)/quill/page.tsx index 8c1ff18..2db03b8 100644 --- a/app/(tabs)/quill/page.tsx +++ b/app/(tabs)/quill/page.tsx @@ -1,19 +1,22 @@ "use client"; import { - Check, + BarChart3, + BookOpen, ChevronDown, - ClipboardCopy, - Cloud, - CloudOff, + ChevronLeft, + ChevronRight, + Clipboard, Download, + Eye, + EyeOff, History, Lightbulb, + Pencil, + Trash2, } from "lucide-react"; -import { Popover } from "radix-ui"; import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; -import { ArcChart } from "@/components/quill/arc-chart"; import { type CapturedHue, ColourSplash, @@ -38,22 +41,16 @@ import { useDiff } from "@/components/quill/use-diff"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { hueFromHSL } from "@/lib/colour/placeholder"; -import { driftToTarget } from "@/lib/quill/colour-distance"; import { type FingerprintMetric, toFingerprint } from "@/lib/quill/fingerprint"; import { type DraftVersion, pushVersion } from "@/lib/quill/history"; import { htmlToMarkdown } from "@/lib/quill/markdown"; -import { nameToHsl } from "@/lib/quill/named-colours"; import { splitParagraphs } from "@/lib/quill/paragraphs"; import { computeWritingStats, type WritingStats } from "@/lib/quill/stats"; import { type WidgetSelection, widgetsToTarget } from "@/lib/quill/widgets"; import { cn } from "@/lib/utils"; import { - type DraftArc, - deleteCloudDraft, - deriveDraftArc, deriveDraftStylometry, deriveParagraphHues, - deriveTargetColour, deriveTextColour, explainHue, type HueSegment, @@ -68,47 +65,22 @@ import { } from "./actions"; type BandSegment = { id: string; text: string; colour: TextColour | null }; +type LeftPanel = "hue" | "fingerprint" | "neighbours" | null; +type RightPanel = "versions" | "colour" | "words" | null; const LOCAL_DRAFT_KEY = "inklings-quill-draft"; const CLOUD_PREF_KEY = "inklings-quill-cloud-save"; const CLOUD_SAVE_DEBOUNCE_MS = 2000; -const PANEL_PRESET_KEY = "inklings-quill-panel-preset"; -const PANEL_CUSTOM_KEY = "inklings-quill-panel-custom"; const BRUSH_SIZE_KEY = "inklings-quill-brush-size"; const ANIMATE_KEY = "inklings-quill-animate-rewrite"; // Splash blot size multiplier per brush size — a bigger brush throws more ink. const BRUSH_SPLASH_SCALE: Record = { 1: 0.7, 3: 1, 7: 1.4 }; -type PanelPreset = "essentials" | "analyse" | "rewrite" | "custom"; - -// Which optional panels each preset activates. -const PANEL_PRESETS: Record, readonly string[]> = { - essentials: ["hue", "save"], - analyse: ["hue", "fingerprint", "band", "arc", "neighbours"], - rewrite: ["target", "version"], -}; - -const CUSTOM_PANEL_OPTIONS = [ - { key: "hue", label: "Hue readout" }, - { key: "fingerprint", label: "Style fingerprint" }, - { key: "band", label: "Hue band" }, - { key: "arc", label: "Story shape" }, - { key: "neighbours", label: "Nearest authors" }, - { key: "version", label: "Versions" }, - { key: "target", label: "Rewrite" }, - { key: "save", label: "Save to scribe" }, -] as const; - -// Default custom panels — on for new users; existing saved sets load from localStorage. -const DEFAULT_CUSTOM_PANELS = ["hue", "save"]; - export default function QuillPage() { - const [panelPreset, setPanelPreset] = useState("essentials"); - const [customPanels, setCustomPanels] = useState>(new Set()); // Local-first per the #45 privacy decision — the draft lives in // localStorage by default and only round-trips to the server when the - // writer opts in via the SaveSettings toggle below. + // writer opts in via the saved cloud preference. const [draft, setDraft] = useState(""); const [readout, setReadout] = useState(null); const [isPending, startReadout] = useTransition(); @@ -122,7 +94,7 @@ export default function QuillPage() { // Cloud-save opt-in (#71). Both pieces of state are mirrored to // localStorage so the preference + the draft survive refreshes. const [cloudSave, setCloudSave] = useState(false); - const [cloudSavedAt, setCloudSavedAt] = useState(null); + const [_cloudSavedAt, setCloudSavedAt] = useState(null); // Block the autosave effects until the localStorage hydration pass // runs — otherwise the first render would wipe a saved draft with // the empty default and immediately delete the cloud row. @@ -132,8 +104,6 @@ export default function QuillPage() { // into one target string (PromptCanvas, idea #3) — backend unchanged. const [target, setTarget] = useState(""); const [widgetSelection, setWidgetSelection] = useState({}); - // The target descriptor resolved to a colour, for the drift meter (#5). - const [targetColour, setTargetColour] = useState(null); // Intensity slider (1–5) replaces the 3-variant fan. const [intensity, setIntensity] = useState(3); // Single rewrite result — inline diff replaces the modal RewritePanel. @@ -147,8 +117,8 @@ export default function QuillPage() { // timing; the overlay just paints whatever phase it's handed. const [splash, setSplash] = useState(null); // Fused rewrite controls: an optionally-selected mood colour folded into the - // target, the brush size (sentences a colour drop covers), and whether a - // button-triggered rewrite plays the splash. Brush + animate persist. + // target, the brush size (sentence / passage / whole) for colour drops, and + // whether a button-triggered rewrite plays the splash. Brush + animate persist. const [selectedColour, setSelectedColour] = useState(null); const [brushSize, setBrushSize] = useState(3); const [animateOnRewrite, setAnimateOnRewrite] = useState(true); @@ -159,9 +129,12 @@ export default function QuillPage() { // Pre-rewrite snapshots. Accepting a rewrite remounts the editor and wipes // TipTap's undo stack — this is the way back. const [versions, setVersions] = useState([]); - // Live selection drives the sidebar indicator + persistent editor mark; - // committed selection is - // frozen at request-time and used to splice the rewrite back in on accept. + const [versionLabels, setVersionLabels] = useState>({}); + const [currentVersionKey, setCurrentVersionKey] = useState(null); + const [leftPanel, setLeftPanel] = useState("hue"); + const [rightPanel, setRightPanel] = useState("colour"); + // Live selection drives the sidebar indicator; committed selection is frozen + // at request-time and used to keep the loading mark connected to the rewrite. const [liveSelection, setLiveSelection] = useState(null); const [committedSelection, setCommittedSelection] = useState(null); // Diff state — computed from the active rewrite vs the committed text. @@ -188,31 +161,42 @@ export default function QuillPage() { // or we restore a draft from storage. const [editorKey, setEditorKey] = useState(0); - // Panel visibility — resolved from preset each render. - const panelVisible = (key: string): boolean => { - if (panelPreset === "custom") return customPanels.has(key); - return PANEL_PRESETS[panelPreset].includes(key); + const targetActive = true; + + const updateDraft = (nextDraft: string) => { + setDraft(nextDraft); + if (!currentVersionKey) return; + setVersions((stack) => + stack.map((entry) => + versionMatchesKey(entry, currentVersionKey) ? { ...entry, html: nextDraft } : entry, + ), + ); }; - const bandActive = panelVisible("band"); - const targetActive = panelVisible("target"); - // EmoArc hue band (B5). Cache hues by paragraph text so a typing burst only - // re-derives the block that actually changed; the band shows the arc across + // Paragraph hue strip. Cache hues by paragraph text so a typing burst only + // re-derives the block that actually changed; the strip shows the hue trail across // the whole draft in Readout mode. const hueCacheRef = useRef>({}); const [band, setBand] = useState([]); - // EmoArc band → editor link (#1). Hovering a band segment highlights its + const [bandPending, setBandPending] = useState(false); + const [showRail, setShowRail] = useState(false); + const blockHues = useMemo( + () => + band.map((s) => + s.colour ? hueFromHSL(s.colour.hue, s.colour.saturation, s.colour.lightness).css : null, + ), + [band], + ); + // Paragraph hue strip → editor link. Hovering a band segment highlights its // paragraph in the editor (tinted in that segment's own hue); clicking one // jumps to it. The editor handle lets the click select the block imperatively. const editorRef = useRef(null); + const diffScrollRef = useRef(null); const [highlight, setHighlight] = useState<{ index: number; tint: string | null } | null>(null); // Live stylometric fingerprint of the draft (style-level). Cheap CPU-only // derivation, so we can recompute on the same cadence as the hue readout. const [fingerprint, setFingerprint] = useState(null); - // Emotional arc of the draft (research idea C) — lexicon valence per - // sentence, matched to Reagan et al.'s six Gutenberg story shapes. - const [arc, setArc] = useState(null); // Corpus authors closest to the draft's fingerprint (style-level, S4). const [neighbours, setNeighbours] = useState([]); @@ -237,9 +221,6 @@ export default function QuillPage() { const w = customSwatches.find((w) => w.id === key); return w ? capturedHueCss(w) : NEUTRAL_INK_CSS; }; - // Brush size (sentences) → window radius each side: 1→0, 3→1, 7→3. - const brushRadius = Math.floor((brushSize - 1) / 2); - // Live writing stats (F1) — cheap, derived from the draft's plain text. const stats = useMemo( () => computeWritingStats(draft.replace(/<[^>]+>/g, " ").replace(/ /g, " ")), @@ -255,15 +236,8 @@ export default function QuillPage() { try { const localDraft = window.localStorage.getItem(LOCAL_DRAFT_KEY) ?? ""; const cloudPref = window.localStorage.getItem(CLOUD_PREF_KEY) === "true"; - const savedPreset = (window.localStorage.getItem(PANEL_PRESET_KEY) ?? - "essentials") as PanelPreset; - const savedCustom = JSON.parse( - window.localStorage.getItem(PANEL_CUSTOM_KEY) ?? JSON.stringify(DEFAULT_CUSTOM_PANELS), - ) as string[]; setDraft(localDraft); setCloudSave(cloudPref); - setPanelPreset(savedPreset); - setCustomPanels(new Set(savedCustom)); const savedBrush = Number(window.localStorage.getItem(BRUSH_SIZE_KEY)); if ([1, 3, 7].includes(savedBrush)) setBrushSize(savedBrush); const savedAnimate = window.localStorage.getItem(ANIMATE_KEY); @@ -294,6 +268,24 @@ export default function QuillPage() { wasRewritingRef.current = isRewriting; }, [isRewriting, rewrite]); + useEffect(() => { + if (!rewrite) return; + const frame = window.requestAnimationFrame(() => { + const scroller = diffScrollRef.current; + const firstHunk = scroller?.querySelector("[data-diff-hunk='true']"); + if (!scroller || !firstHunk) return; + + const scrollerTop = scroller.getBoundingClientRect().top; + const hunkTop = firstHunk.getBoundingClientRect().top; + scroller.scrollTo({ + top: Math.max(0, scroller.scrollTop + hunkTop - scrollerTop - 28), + behavior: "smooth", + }); + }); + + return () => window.cancelAnimationFrame(frame); + }, [rewrite]); + // Mirror every draft change to localStorage — implicit, no UI signal // needed since this is the privacy default. useEffect(() => { @@ -305,6 +297,10 @@ export default function QuillPage() { } }, [draft, hydrated]); + useEffect(() => { + if (versions.length === 0 && rightPanel === "versions") setRightPanel("colour"); + }, [versions.length, rightPanel]); + // Debounced cloud autosave when the toggle is on. The 2 s wait keeps a // burst of typing from firing dozens of writes. useEffect(() => { @@ -321,63 +317,66 @@ export default function QuillPage() { }; }, [draft, cloudSave, hydrated]); - const toggleCloudSave = async (next: boolean) => { - setCloudSave(next); + const changeBrushSize = (size: number) => { + setBrushSize(size); try { - window.localStorage.setItem(CLOUD_PREF_KEY, String(next)); + window.localStorage.setItem(BRUSH_SIZE_KEY, String(size)); } catch { // Storage can throw in private mode / quota-full; we tolerate it. } - if (!next) { - // Privacy: when the toggle goes off, the cloud row goes too. - await deleteCloudDraft(); - setCloudSavedAt(null); - } }; - const changePanelPreset = (preset: PanelPreset) => { - setPanelPreset(preset); - try { - window.localStorage.setItem(PANEL_PRESET_KEY, preset); - } catch { - // Storage can throw in private mode / quota-full; we tolerate it. - } - }; + // Tap a swatch to fold its mood into the target (toggle on/off). + const toggleColour = (key: string) => setSelectedColour((prev) => (prev === key ? null : key)); - const toggleCustomPanel = (key: string) => { - setCustomPanels((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - try { - window.localStorage.setItem(PANEL_CUSTOM_KEY, JSON.stringify([...next])); - } catch { - // Storage can throw in private mode / quota-full; we tolerate it. - } - return next; + const renameVersionLabel = (key: string, label: string) => { + const next = label.trim(); + setVersionLabels((labels) => { + const copy = { ...labels }; + if (next) copy[key] = next; + else delete copy[key]; + return copy; }); }; - const changeBrushSize = (size: number) => { - setBrushSize(size); - try { - window.localStorage.setItem(BRUSH_SIZE_KEY, String(size)); - } catch { - // Storage can throw in private mode / quota-full; we tolerate it. - } - }; + const deleteVersion = (version: DraftVersion) => { + const key = versionKey(version); + const index = versions.findIndex((entry) => versionKey(entry) === key); + if (index === -1) return; - const changeAnimate = (next: boolean) => { - setAnimateOnRewrite(next); - try { - window.localStorage.setItem(ANIMATE_KEY, String(next)); - } catch { - // Storage can throw in private mode / quota-full; we tolerate it. + const isOriginal = index === versions.length - 1; + const isCurrent = versionMatchesKey(version, currentVersionKey) || draft === version.html; + const nextVersions = versions.filter((entry) => versionKey(entry) !== key); + + if (isOriginal) { + setDraft(""); + setEditorKey((k) => k + 1); + setCurrentVersionKey(null); + } else if (isCurrent) { + const previousVersion = versions[index + 1] ?? nextVersions[nextVersions.length - 1] ?? null; + if (previousVersion) { + setDraft(previousVersion.html); + setCurrentVersionKey(versionKey(previousVersion)); + setEditorKey((k) => k + 1); + } else { + setDraft(""); + setCurrentVersionKey(null); + setEditorKey((k) => k + 1); + } } - }; - // Tap a swatch to fold its mood into the target (toggle on/off). - const toggleColour = (key: string) => setSelectedColour((prev) => (prev === key ? null : key)); + setVersions(nextVersions); + setVersionLabels((labels) => { + const copy = { ...labels }; + delete copy[key]; + return copy; + }); + setRewrite(null); + setRewriteError(null); + setShowNudgeReady(false); + setCommittedSelection(null); + setLiveSelection(null); + }; // Add a new custom swatch from a capture (right-click / Hue-band drag) or a mix. const addSwatch = (hue: CapturedHue) => @@ -478,24 +477,6 @@ export default function QuillPage() { }; }, [draft]); - // Debounced emotional arc — same cadence as the fingerprint; CPU-only - // lexicon work, no model call. Null (draft too short / no shape) hides it. - useEffect(() => { - let cancelled = false; - const handle = setTimeout(async () => { - try { - const result = await deriveDraftArc(draft); - if (!cancelled) setArc(result); - } catch { - // Tolerate a failed derivation — keep the previous arc. - } - }, 700); - return () => { - cancelled = true; - clearTimeout(handle); - }; - }, [draft]); - // Debounced nearest-author match — longer 1500 ms window since it reads the // corpus. Empty (too short, or no corpus loaded) hides the card. useEffect(() => { @@ -514,17 +495,19 @@ export default function QuillPage() { }; }, [draft]); - // Debounced EmoArc band — 800 ms after the last keystroke, derive a hue per - // paragraph (only the uncached ones) and lay them out as an arc. Single- - // paragraph drafts fall back to the global swatch, so we only build a band - // once there are at least two blocks to compare. + // Debounced paragraph hue strip — 800 ms after the last keystroke, derive a hue per + // paragraph (only the uncached ones) and lay them out in the Hue card. useEffect(() => { - if (!bandActive) return; let cancelled = false; + const parasBeforeDebounce = splitParagraphs(draft); + setBandPending(parasBeforeDebounce.length >= 2); const handle = setTimeout(async () => { const paras = splitParagraphs(draft); if (paras.length < 2) { - if (!cancelled) setBand([]); + if (!cancelled) { + setBand([]); + setBandPending(false); + } return; } const cache = hueCacheRef.current; @@ -550,51 +533,22 @@ export default function QuillPage() { colour: hueCacheRef.current[p] ?? null, })), ); + setBandPending(false); }, 800); return () => { cancelled = true; clearTimeout(handle); }; - }, [draft, bandActive]); + }, [draft]); - // Clear the EmoArc highlight whenever the band isn't on screen (mode switch, + // Clear the paragraph-strip highlight whenever the band isn't on screen, // or the draft dropped below two paragraphs) — the band's own mouse-leave // can't fire once it has unmounted, so a stale highlight would otherwise stick. - const bandVisible = bandActive && band.length >= 2; + const bandVisible = band.length >= 2; useEffect(() => { if (!bandVisible) setHighlight(null); }, [bandVisible]); - // Resolve the target descriptor to a colour for the drift meter (#5). Common - // colour/mood words map locally and for free; anything else the model derives - // once, debounced so a burst of typing in the target field is a single call. - useEffect(() => { - if (!targetActive) return; - const aim = composedTarget.trim(); - if (!aim) { - setTargetColour(null); - return; - } - const named = nameToHsl(aim); - if (named) { - setTargetColour({ ...named, justification: aim }); - return; - } - let cancelled = false; - const handle = setTimeout(async () => { - try { - const colour = await deriveTargetColour(aim); - if (!cancelled) setTargetColour(colour); - } catch { - // Keep the last resolved target colour on a transient failure. - } - }, 600); - return () => { - cancelled = true; - clearTimeout(handle); - }; - }, [composedTarget, targetActive]); - // The single rewrite flow behind every entry point — a colour drag, the // Rewrite button, a selection. `span` null means "the whole draft". When // `coords` is set the splash plays over them and the diff is revealed only @@ -688,19 +642,21 @@ export default function QuillPage() { coords: { origin: detail.origin, ripples: detail.ripples }, colourCss: resolved.css, scale: BRUSH_SPLASH_SCALE[brushSize] ?? 1, - tooShort: "Drop on a fuller passage — the rewrite needs at least 8 words around the word.", + tooShort: "Write a little more before dropping a colour — rewrites need at least 8 words.", }); }; const acceptRewrite = (text: string) => { // Snapshot the draft being replaced — the remount below wipes TipTap undo. - setVersions((stack) => - pushVersion(stack, { html: draft, sourceTarget: composedTarget, takenAt: Date.now() }), - ); + const takenAt = Date.now(); + const previousVersion: DraftVersion = { html: draft, sourceTarget: "", takenAt }; + let acceptedHtml: string | null = null; if (committedSelection) { // The editor splices as an open ProseMirror slice, so the rewrite merges // at the cut points (no host-paragraph splitting), whatever the span. - editorRef.current?.replaceRange(committedSelection.from, committedSelection.to, text); + acceptedHtml = + editorRef.current?.replaceRange(committedSelection.from, committedSelection.to, text) ?? + null; } else { const asBlocks = text .split(/\n\s*\n+/) @@ -708,9 +664,19 @@ export default function QuillPage() { .filter(Boolean) .map((p) => `

${escapeHtml(p)}

`) .join(""); + acceptedHtml = asBlocks; setDraft(asBlocks); setEditorKey((k) => k + 1); } + const acceptedVersion = acceptedHtml + ? { html: acceptedHtml, sourceTarget: composedTarget, takenAt: takenAt + 1 } + : null; + setVersions((stack) => { + let next = pushVersionOnce(stack, previousVersion); + if (acceptedVersion) next = pushVersionOnce(next, acceptedVersion); + return next; + }); + if (acceptedVersion) setCurrentVersionKey(versionKey(acceptedVersion)); setRewrite(null); setShowNudgeReady(false); setCommittedSelection(null); @@ -718,11 +684,19 @@ export default function QuillPage() { }; const restoreVersion = (version: DraftVersion) => { + const key = versionKey(version); + if (versionMatchesKey(version, currentVersionKey) || draft === version.html) { + setCurrentVersionKey(key); + return; + } // The current draft becomes a version too, so a restore is reversible. setVersions((stack) => - pushVersion(stack, { html: draft, sourceTarget: "", takenAt: Date.now() }), + stack.some((entry) => entry.html === draft) + ? [...stack] + : pushVersion(stack, { html: draft, sourceTarget: "", takenAt: Date.now() }), ); setDraft(version.html); + setCurrentVersionKey(key); setRewrite(null); setRewriteError(null); setShowNudgeReady(false); @@ -738,297 +712,204 @@ export default function QuillPage() { }; return ( -
-
-
-

- The Quill -

-

- Write, and watch the hue of your prose surface. Target a colour to receive nudges. -

-
- {stats.words > 0 && } -
- -
-
- {bandVisible && ( - setHighlight(index == null ? null : { index, tint })} - onActivate={(index) => editorRef.current?.focusBlock(index)} - /> +
+
+ + +
+
+
+

+ The Quill +

+

+ Write, and watch the hue of your prose surface. Target a colour to receive nudges. +

+
+ {stats.words > 0 && } +
+ +
+ + {stats.words > 0 && } - {explain && ( - - )} -
- -
+ +
); } -// --------------------------------------------------------------------------- -// Panel selector — preset tabs + custom toggles at the top of the sidebar -// --------------------------------------------------------------------------- - -const PRESET_LABELS: Record = { - essentials: "Essentials", - analyse: "Analyse", - rewrite: "Rewrite", - custom: "Custom", -}; - -function PanelSelector({ - preset, - customPanels, - onPresetChange, - onCustomToggle, -}: { - preset: PanelPreset; - customPanels: Set; - onPresetChange: (p: PanelPreset) => void; - onCustomToggle: (key: string) => void; -}) { - const presets = Object.keys(PRESET_LABELS) as PanelPreset[]; - return ( -
-
- {presets.map((p) => ( - - ))} -
- {preset === "custom" && ( - - - - Panels - - - {customPanels.size} active - - - - {CUSTOM_PANEL_OPTIONS.map((opt) => { - const on = customPanels.has(opt.key); - return ( - - ); - })} - - - - )} -
- ); -} - function countWords(html: string): number { // Strip tags, collapse whitespace, count non-empty words. Good enough as a // running indicator until #37 wires the real hue readout. @@ -1050,6 +931,8 @@ function plainText(html: string): string { } function HueReadout({ + open, + onToggleOpen, targetActive, hasText, wordCount, @@ -1057,7 +940,16 @@ function HueReadout({ isPending, explain, onToggleExplain, + band, + showBand, + bandPending, + onBandHover, + onBandActivate, + showRail, + onToggleRail, }: { + open: boolean; + onToggleOpen: () => void; targetActive: boolean; hasText: boolean; wordCount: number; @@ -1065,6 +957,13 @@ function HueReadout({ isPending: boolean; explain: boolean; onToggleExplain: () => void; + band: BandSegment[]; + showBand: boolean; + bandPending: boolean; + onBandHover?: (index: number | null, tint: string | null) => void; + onBandActivate?: (index: number) => void; + showRail: boolean; + onToggleRail: () => void; }) { const swatchCss = readout ? hueFromHSL(readout.hue, readout.saturation, readout.lightness).css @@ -1073,66 +972,165 @@ function HueReadout({ return ( - -
- - -
- - your current hue - - {label} + +
+
+ + +
+ + your current hue + + {label} +
+
-

- {readout - ? !targetActive - ? "Keep writing — the hue updates as the ink dries." - : "Aim for the target. Suggestions will appear inline." - : wordCount < 8 - ? "Write a few words and the hue will surface." - : isPending - ? "Reading the ink…" - : "Keep writing."} -

- {wordCount > 0 && ( -

- {wordCount} {wordCount === 1 ? "word" : "words"} -

- )} - {readout && ( - )} - > - - {explain ? "Hide why" : "Why this colour?"} - + {(showBand || bandPending) && ( +
+ {showBand ? ( + + ) : ( + + )} +
+ )} + )}
); } -/** - * The EmoArc hue band — a horizontal arc of one hue per paragraph, so the - * writer sees how the stylistic temperature rises and falls across the whole - * draft, not just its average (Amin's "Interactive Emotion Graph"). Hovering a - * segment dims the rest and surfaces that paragraph's reading below — the band - * is an index into the text, not just decoration. - */ -function HueBand({ +function CollapseButton({ + open, + label, + onToggle, +}: { + open: boolean; + label: string; + onToggle: () => void; +}) { + return ( + + ); +} + +function HueArcStripShimmer({ + showRail, + onToggleRail, +}: { + showRail: boolean; + onToggleRail: () => void; +}) { + const pieces = [ + { id: "opening", width: "flex-[1.2]" }, + { id: "turn", width: "flex-[0.9]" }, + { id: "middle", width: "flex-[1.4]" }, + { id: "lift", width: "flex-[1]" }, + { id: "tail", width: "flex-[0.8]" }, + ]; + return ( +
+
+

+ across paragraphs +

+ +
+
+ {pieces.map((piece, index) => ( +
+
+
+ ))} +
+

+ Reading paragraph hues… +

+
+ ); +} + +function HueArcStrip({ segments, onHover, onActivate, + showRail, + onToggleRail, }: { segments: BandSegment[]; /** Hovered/focused segment index + its hue (null on leave) — drives the @@ -1140,9 +1138,24 @@ function HueBand({ onHover?: (index: number | null, tint: string | null) => void; /** Clicked/activated segment index — jumps the editor to that paragraph. */ onActivate?: (index: number) => void; + showRail: boolean; + onToggleRail: () => void; }) { + const pageSize = 10; + const [page, setPage] = useState(0); + const pageCount = Math.max(1, Math.ceil(segments.length / pageSize)); + const currentPage = Math.min(page, pageCount - 1); + const start = currentPage * pageSize; + const visibleSegments = segments.slice(start, start + pageSize); + const end = start + visibleSegments.length; const [hovered, setHovered] = useState(null); const active = hovered != null ? segments[hovered] : null; + const hasPrevious = currentPage > 0; + const hasNext = currentPage < pageCount - 1; + + useEffect(() => { + if (page !== currentPage) setPage(currentPage); + }, [currentPage, page]); const enter = (index: number, tint: string | null) => { setHovered(index); @@ -1154,68 +1167,116 @@ function HueBand({ }; return ( - - -
-

Hue band

- - {segments.length} paragraphs +
+
+

+ across paragraphs +

+ +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only cleanup; keyboard clears via each button's onBlur. Leave lives on the row, not per-segment, so crossing the gap between buttons keeps the last highlight instead of flickering to null. */} +
+ {visibleSegments.map((seg, visibleIndex) => { + const absoluteIndex = start + visibleIndex; + const css = seg.colour + ? hueFromHSL(seg.colour.hue, seg.colour.saturation, seg.colour.lightness).css + : undefined; + return ( +
+
+
+ + {start + 1}-{end} + of {segments.length}
-
- {segments.map((seg, i) => { - const css = seg.colour - ? hueFromHSL(seg.colour.hue, seg.colour.saturation, seg.colour.lightness).css - : undefined; - return ( -
-

+ {pageCount > 1 && ( +

+ + +
+ )} +
+
+

{active ? active.colour ? `"${truncate(active.text)}" — ${active.colour.justification}` : `"${truncate(active.text)}" — too short to read` - : "Hover the arc to read each paragraph’s hue."} + : "Hover the strip to read each paragraph’s hue."}

- - +
+
); } @@ -1243,9 +1304,9 @@ function ExportControls({ markdown }: { markdown: string }) { toast.success("Downloaded inkling-draft.md"); }; return ( -
+
); @@ -1294,47 +1355,79 @@ function WritingStatsBar({ stats }: { stats: WritingStats }) { // unless it's an exact match. const closeness = (d: number) => Math.max(0, 1 - d); -function NeighbourAuthors({ neighbours }: { neighbours: StyleNeighbour[] }) { +function NeighbourAuthors({ + open, + onToggleOpen, + neighbours, +}: { + open: boolean; + onToggleOpen: () => void; + neighbours: StyleNeighbour[]; +}) { return ( - -

- Closest in the Inkwell -

-
    - {neighbours.map((n) => { - const css = n.hue - ? hueFromHSL(n.hue.hue, n.hue.saturation, n.hue.lightness).css - : "var(--ink-faded)"; - return ( -
  • -
    -
    -
    -
    -
    -
  • - ); - })} -
-

- By classical stylometry — the same distance the Inkwell is laid out on. -

+ +
+
+

+

+

+ Nearby voices by classical stylometry. +

+
+ +
+ {open && ( + <> + {neighbours.length > 0 ? ( +
    + {neighbours.map((n) => { + const css = n.hue + ? hueFromHSL(n.hue.hue, n.hue.saturation, n.hue.lightness).css + : "var(--ink-faded)"; + return ( +
  • +
    +
    +
    +
    +
    +
  • + ); + })} +
+ ) : ( +

+ Write a little more and the nearest voices will surface here. +

+ )} +

+ By classical stylometry — the same distance the Inkwell is laid out on. +

+ + )}
); @@ -1346,103 +1439,55 @@ function NeighbourAuthors({ neighbours }: { neighbours: StyleNeighbour[] }) { * ease toward their value so each keystroke nudges the shape rather than * snapping it, making the numbers feel like a live reading of the ink. */ -function StyleFingerprint({ metrics }: { metrics: FingerprintMetric[] }) { - return ( - - -

- Style fingerprint -

-
    - {metrics.map((m) => ( -
  • -
    - {m.label} - - {m.value.toFixed(2)} - -
    -
    -
    -
    -
  • - ))} -
-
-
- ); -} - -/** - * Drift-to-target meter (#5) — a live bar showing how close the draft's hue has - * drifted toward the target's. Both sit on the same HSL scale, so the gap is - * meaningful; the bar fills as the writer edits toward the target, turning the - * abstract "style space" into direct, playful feedback. Shown only in target - * mode, once a target colour has resolved. - */ -function DriftMeter({ - readout, - target, +function StyleFingerprint({ + open, + onToggleOpen, + metrics, }: { - readout: TextColour | null; - target: TextColour | null; + open: boolean; + onToggleOpen: () => void; + metrics: FingerprintMetric[] | null; }) { - if (!target) return null; - const proximity = readout ? driftToTarget(readout, target) : 0; - const pct = Math.round(proximity * 100); - const targetCss = hueFromHSL(target.hue, target.saturation, target.lightness).css; - const draftCss = readout - ? hueFromHSL(readout.hue, readout.saturation, readout.lightness).css - : "var(--muted)"; - return ( - -
-

- Drift to target -

- - {readout ? `${pct}%` : "—"} - -
-
-