From f0cbea73fdba12154b7cf2d7759189068feebc3a Mon Sep 17 00:00:00 2001 From: jovanadinic Date: Mon, 29 Jun 2026 03:54:33 +0200 Subject: [PATCH 1/3] Polish Quill layout panels --- app/(tabs)/quill/page.tsx | 1369 ++++++++++++--------------- app/globals.css | 17 + components/quill/editor.tsx | 99 +- components/quill/hue-explainer.tsx | 2 +- components/quill/rewrite-panel.tsx | 309 +++--- components/quill/target-widgets.tsx | 9 +- 6 files changed, 878 insertions(+), 927 deletions(-) diff --git a/app/(tabs)/quill/page.tsx b/app/(tabs)/quill/page.tsx index 8c1ff18..789c741 100644 --- a/app/(tabs)/quill/page.tsx +++ b/app/(tabs)/quill/page.tsx @@ -1,19 +1,18 @@ "use client"; import { - Check, + BarChart3, + BookOpen, ChevronDown, + ChevronLeft, + ChevronRight, ClipboardCopy, - Cloud, - CloudOff, Download, History, Lightbulb, } 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 +37,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 +61,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 +90,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 +100,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. @@ -159,6 +125,8 @@ 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([]); + const [leftPanel, setLeftPanel] = useState("hue"); + const [rightPanel, setRightPanel] = useState("words"); // 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. @@ -188,20 +156,22 @@ 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 bandActive = panelVisible("band"); - const targetActive = panelVisible("target"); + const targetActive = true; - // 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 [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); @@ -210,9 +180,6 @@ export default function QuillPage() { // 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([]); @@ -255,15 +222,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); @@ -321,43 +281,6 @@ export default function QuillPage() { }; }, [draft, cloudSave, hydrated]); - const toggleCloudSave = async (next: boolean) => { - setCloudSave(next); - try { - window.localStorage.setItem(CLOUD_PREF_KEY, String(next)); - } 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. - } - }; - - 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 changeBrushSize = (size: number) => { setBrushSize(size); try { @@ -367,15 +290,6 @@ export default function QuillPage() { } }; - 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. - } - }; - // Tap a swatch to fold its mood into the target (toggle on/off). const toggleColour = (key: string) => setSelectedColour((prev) => (prev === key ? null : key)); @@ -478,24 +392,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,12 +410,9 @@ 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 handle = setTimeout(async () => { const paras = splitParagraphs(draft); @@ -555,46 +448,16 @@ export default function QuillPage() { 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 @@ -738,297 +601,194 @@ 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 +810,8 @@ function plainText(html: string): string { } function HueReadout({ + open, + onToggleOpen, targetActive, hasText, wordCount, @@ -1057,7 +819,15 @@ function HueReadout({ isPending, explain, onToggleExplain, + band, + showBand, + onBandHover, + onBandActivate, + showRail, + onToggleRail, }: { + open: boolean; + onToggleOpen: () => void; targetActive: boolean; hasText: boolean; wordCount: number; @@ -1065,6 +835,12 @@ function HueReadout({ isPending: boolean; explain: boolean; onToggleExplain: () => void; + band: BandSegment[]; + showBand: 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 +849,109 @@ 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 && ( - + )} +
+ {showBand && ( +
+ +
)} - > - - {explain ? "Hide why" : "Why this colour?"} - + )} ); } -/** - * 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 HueArcStrip({ segments, onHover, onActivate, + showRail, + onToggleRail, }: { segments: BandSegment[]; /** Hovered/focused segment index + its hue (null on leave) — drives the @@ -1140,9 +959,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 +988,109 @@ function HueBand({ }; return ( - - -
-

Hue band

- - {segments.length} paragraphs +
+
+

+ across paragraphs +

+
+ {pageCount > 1 && ( + + )} + + {start + 1}-{end} + {pageCount > 1 && ( + + )}
-
- {segments.map((seg, i) => { - const css = seg.colour - ? hueFromHSL(seg.colour.hue, seg.colour.saturation, seg.colour.lightness).css - : undefined; - return ( -
-

+

+ {/* 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 ( +
+
+

{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."}

- - + +
+
); } @@ -1294,47 +1169,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 +1253,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}%` : "—"} - -
-
-