litura-app 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ number carries breaking changes.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.3.6 — 2026-09-07
9
+
10
+ - Published the latest editor interface and behavior updates together with the product audit and local draft history.
11
+
12
+ ## 0.3.5 — 2026-09-07
13
+
14
+ - Refreshed the README preview with natural generic-writing highlights and meaningful rewrite options in a full-screen review flow.
15
+
8
16
  ## 0.3.4 — 2026-09-07
9
17
 
10
18
  - Refreshed the README preview with a full-screen Litura review flow and three rewrite options.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Litura is a local, AI-assisted text editor for making prose sharper and less generic without taking control away from its author. The name comes from Latin: a correction, erasure, or visible revision in a manuscript.
4
4
 
5
- ![Litura editor with review marks and rewrite options](docs/screenshot.png)
5
+ ![Litura editor with review highlights and rewrite options](docs/screenshot.png)
6
6
 
7
7
  ## What it does
8
8
 
package/SPEC.md CHANGED
@@ -89,8 +89,8 @@ The panel keeps what is about the draft as a whole: typed messages, replies, `Re
89
89
  - One field, no mode control. What the message does is decided by what is attached: a passage the writer selected is a rewrite target, a finding is something to ask about, and nothing attached is a conversation about the draft. The placeholder says which of the three is active.
90
90
  - `Enter` sends; `Shift+Enter` inserts a newline.
91
91
  - `Escape` cancels an active request or detaches the current selection.
92
- - A single control above the stream closes what the assistant is showing and opens it again: `×` over an open conversation, `Conversation` over a closed one. One press puts away both the turns and the finding cards standing in the rail — two `×` in two places for one idea is two presses to get a clean screen. The composer is not part of it: it is how the writer talks to the draft at all, so it is always there.
93
- - Closing is a view, not an edit. Nothing is discarded: the turns stay in memory and in storage, the findings and their marks stay in the draft, and clicking a mark builds its card again without reopening the conversation. Anything new arriving opens the stream by itself, so a reply never lands somewhere the writer cannot see. There is no control that discards the conversation; replacing the document does that, and the server only ever reads the last 20 turns.
92
+ - A single control above the stream closes what the assistant is showing and opens it again: `×` over an open conversation, `Conversation` over a closed one — and no control at all while the stream is empty, so the toggle never flips over an empty panel. One press puts away both the turns and the finding cards standing in the rail — two `×` in two places for one idea is two presses to get a clean screen. The composer is not part of it: it is how the writer talks to the draft at all, so it is always there.
93
+ - Closing is a view, not an edit. The turns stay in memory and in storage, applied options stay with their `Undo`, and the findings and their marks stay in the draft clicking a mark builds its card again. Remark cards and undecided option groups are put away. Anything new arriving opens the stream by itself, so a reply never lands somewhere the writer cannot see; merely moving cards between the rail and the stream on resize never opens it. There is no control that discards the conversation; replacing the document does that, and the server only ever reads the last 20 turns.
94
94
  - A failed model call is one short sentence naming the cause — a rejected key, a rate limit, an overloaded provider — and one button doing something about it. The sentence never carries the remedy as a second clause: `Try again` is the remedy. Where retrying cannot help there is no retry, and a key problem offers `Open settings` instead. The provider's payload goes to the tooltip and the console, where it is there for a bug report and nowhere else.
95
95
 
96
96
  ## 3. Writing workflows
@@ -167,27 +167,15 @@ There is one replacement scope, and it is the quoted passage. The tint in the dr
167
167
 
168
168
  For an ordinary selection, the next composer message becomes the rewrite instruction.
169
169
 
170
- The server returns exactly three strings, and they are tried on rather than listed. One at a time, the option is substituted into the draft where the passage sits, so it is read with the sentences around it which is what decides whether it works. It arrives on a plain white sheet rather than the accent tint a mark or an attached passage carries: inside a preview, colour belongs to the edit, and a wash underneath drowns it. Nothing is ranked or reordered by a local metric.
170
+ The server returns exactly three strings, shown as a stack of option cards in the chat stream. Clicking a card replaces only the attached range; the remaining cards then retire. Requesting the options hides the finding cardit has said its piece and a failed request brings it back. The group carries a refresh-icon `Try again` control that discards the three and re-requests them with the same instruction and the same attached passage.
171
171
 
172
- A finding card holds one thing at a time: the remark, then the request in flight, then the strip. Clicking `Options` replaces the remark with `Looking for options…`; when the answer lands the same slot holds the strip instead. Cancelling `Escape`, editing, `Try again` finding nothing puts the remark back exactly as it was.
172
+ The client ranks what the model already produced by the resulting whole-document local score and shows that score delta on each card for Latin-script drafts. Colour marks only a material move; a point or two on an already-clean draft stays uncoloured so the options do not all read as bad. The score is never fed to the model.
173
173
 
174
- Only the remark is worth a box. For the two states after it the card keeps its place in the rail and drops its walls: a bordered panel around a status line, or around a row of stepper controls, is more furniture than a passing moment deserves. The strip carries no border, background or padding of its own the controls are the interface, and nothing needs to hold them. Without a card an ordinary selection, or `/idea` the strip has no block to sit in and stands on its own: in the rail beside the passage, or above the composer in place of `Review draft` when there is no rail.
174
+ While the options are up the attached passage stays highlighted in the draft, and hovering or focusing a card previews that option where it will live substituted into the paragraph with the sentences around it, on a plain white sheet with no red/green diff. The document itself is untouched while the writer is choosing, so nothing is autosaved, no finding is re-anchored, and undo stays clean until the writer keeps one. `Enter` or `Space` on a focused card applies it; `Escape` dismisses the hover preview. Clicking into the draft only drops the hover preview the cards stay, because choosing is a click on a card.
175
175
 
176
- The substitution itself is a decoration, not an edit: the document is untouched while the writer is choosing, so nothing is autosaved, no finding is re-anchored, and undo stays clean.
176
+ A coloured word-level diff (added/removed runs animated in reading order, via `wordDiff`) is deliberately not on this path. It is kept in the tree as a noted option for another place (e.g. history compare), where showing what changed word by word is the job.
177
177
 
178
- - `‹ N of 3 ›` steps through the options and the writer's own wording as one ring `Original`, then 1, 2, 3, and round again also with `←` and `→`;
179
- - an option is shown as a word-level edit script against the writer's own wording. What the edit *is* stays on the line for as long as the option is up: cut words stay struck through, added words stay where they landed. What is temporary is the colour. It walks through the changed runs in reading order, ninety milliseconds apart, holds for about a second and a half, and leaves the same way — so the passage announces what changed and then settles into something readable as a sentence rather than as a coloured diff. Nothing about the highlight animates size; the line's reserved height is measured against this, and a highlight that moved the text would make that measurement a lie. A cut longer than 120 characters is not struck through: a rewritten paragraph in strikethrough is a wall, not a diff;
180
- - every option is a different length, so the paragraph would rewrap and step the rest of the draft up and down as the writer flips through. The tallest of the stops — the writer's own wording included — is measured once against a copy of the real line when the options arrive, and reserved as a minimum height on that line. Shorter options leave a little slack rather than dragging the page up. A rewrite spanning whole paragraphs is not reserved: it already replaces blocks, where there is nothing to hold still;
181
- - stepping back to `Original` runs the same close-up in reverse — the option's words are the ones that go, the writer's own come back untinted — and only then does the sheet leave, so nothing about the passage ever cuts;
182
- - `Try again` discards the three and re-requests them with the same instruction and the same passage.
183
-
184
- There is no confirm button. Whatever stop the writer leaves the ring on is what the draft keeps, because they have been reading that stop in place the whole time and `Original` is one of the stops. Leaving happens by pressing `Enter`, clicking into the draft, or turning to another finding; the replacement covers the range the answer was generated for, and only while that range still holds its original text. `Escape` returns the ring to `Original` and closes, so cancelling is still cancelling. Focus moves to the strip when the options arrive: at that moment the writer is choosing, not typing.
185
-
186
- An edit nobody pressed a button for has to leave a way back in sight. After a replacement a single `Undo` stands for eight seconds in the same card the options sat in, beside the sentence that changed; there is no word for what happened, because the sentence says it. It stands on its own, with no panel around one button, and the countdown is in the button itself: a bar across its foot drains over those eight seconds, so the control is also the clock, and the writer sees the offer expiring rather than guessing at it. `Undo` puts back exactly what was replaced and only while it is still there to put back — it is not the editor's undo stack, which by then may belong to something the writer typed afterwards. The History snapshot taken before every replacement is the longer way back.
187
-
188
- Nothing commits on a timer. A document that changes while its author is looking out of the window is the one thing the product principles rule out.
189
-
190
- A finding card is hidden while its own options are on screen — it has said its piece, and the strip owns the decision. Leaving on `Original` or pressing `Escape` brings it back; keeping an option removes it, because the passage it remarked on is gone.
178
+ Keeping an option leaves only that option in the chat the other cards step aside with a single `Undo` on the kept card itself, with no timer: it stays good until the passage changes under it. `Undo` puts back exactly what was replaced and only while it is still there to put back; keeping also takes a History snapshot, which is the longer way back. Choosing an option closes the chat stream; the kept card and its `Undo` survive in the stream history. Closing discards remark cards and undecided option groups but keeps applied ones. Nothing commits on a timer.
191
179
 
192
180
  ### 3.5 Draft chat
193
181
 
@@ -216,7 +204,7 @@ A failed continuation says so in the status line rather than going quiet: a feat
216
204
 
217
205
  ### 3.7 `/idea` expansion
218
206
 
219
- Typing `/idea <instruction>` on a line and pressing `Enter` streams the expansion into the assistant panel, then offers it as a preview over the command line — the same try-on strip as a rewrite, with one option and no `Try again`. The command line is replaced only if the writer keeps it.
207
+ Typing `/idea <instruction>` on a line and pressing `Enter` streams the expansion into the assistant panel, then offers it as a single option card — the same cards as a rewrite, with one option and no `Try again`. The command line is replaced only if the writer keeps it.
220
208
 
221
209
  If the request fails or is stopped, whatever arrived stays in the panel as text and the command line is untouched.
222
210
 
package/editing.js CHANGED
@@ -18,6 +18,10 @@ export function newerVersion(latest, current) {
18
18
  // reading order, so the change can be shown one word at a time instead of as
19
19
  // one opaque block. Spaces the rewrite did not touch stay untouched.
20
20
  //
21
+ // NOTE: kept as a noted option for another place (e.g. history compare or a
22
+ // future coloured diff). Rewrite variants currently preview on a plain white
23
+ // sheet with no red/green — this helper is not on that path right now.
24
+ //
21
25
  // ponytail: plain LCS table, O(words²) — a sentence or a paragraph, not a
22
26
  // book. Past the guard it falls back to a single changed run, which is what
23
27
  // the coarse version always did.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litura-app",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "A local AI-assisted editor for sharper, more human writing",
5
5
  "keywords": [
6
6
  "writing",
package/public/app.js CHANGED
@@ -16866,42 +16866,6 @@
16866
16866
  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
16867
16867
  return false;
16868
16868
  }
16869
- function wordDiff(before, after) {
16870
- const a = before.match(/\s+|\S+/g) ?? [], b = after.match(/\s+|\S+/g) ?? [];
16871
- if (a.length * b.length > 4e4) return coarseDiff(a, b);
16872
- const lcs = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
16873
- for (let i2 = a.length - 1; i2 >= 0; i2--) {
16874
- for (let j2 = b.length - 1; j2 >= 0; j2--) {
16875
- lcs[i2][j2] = a[i2] === b[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
16876
- }
16877
- }
16878
- const ops = [];
16879
- const push = (type, text) => {
16880
- const last = ops[ops.length - 1];
16881
- if (last && last.type === type) last.text += text;
16882
- else ops.push({ type, text });
16883
- };
16884
- let i = 0, j = 0;
16885
- while (i < a.length && j < b.length) {
16886
- if (a[i] === b[j]) push("keep", a[i++]), j++;
16887
- else if (lcs[i + 1][j] >= lcs[i][j + 1]) push("del", a[i++]);
16888
- else push("ins", b[j++]);
16889
- }
16890
- while (i < a.length) push("del", a[i++]);
16891
- while (j < b.length) push("ins", b[j++]);
16892
- return ops;
16893
- }
16894
- function coarseDiff(a, b) {
16895
- let start = 0, end = 0;
16896
- while (start < a.length && start < b.length && a[start] === b[start]) start++;
16897
- while (end < a.length - start && end < b.length - start && a[a.length - end - 1] === b[b.length - end - 1]) end++;
16898
- return [
16899
- { type: "keep", text: a.slice(0, start).join("") },
16900
- { type: "del", text: a.slice(start, a.length - end).join("") },
16901
- { type: "ins", text: b.slice(start, b.length - end).join("") },
16902
- { type: "keep", text: end ? a.slice(-end).join("") : "" }
16903
- ].filter((op) => op.text);
16904
- }
16905
16869
 
16906
16870
  // node_modules/@codemirror/search/dist/index.js
16907
16871
  var basicNormalize = typeof String.prototype.normalize == "function" ? (x) => x.normalize("NFKD") : (x) => x;
@@ -18436,9 +18400,8 @@
18436
18400
  event.preventDefault();
18437
18401
  jumpToNextFinding(event.shiftKey ? -1 : 1);
18438
18402
  }
18439
- if (event.key === "Escape" && preview) {
18440
- preview.index = -1;
18441
- endPreview();
18403
+ if (event.key === "Escape" && plainPreview) {
18404
+ clearPlainPreview();
18442
18405
  workView.focus();
18443
18406
  }
18444
18407
  });
@@ -18448,10 +18411,16 @@
18448
18411
  if (finding) dismissed.push({ code: finding.code, quote: finding.quote, document: workView.state.doc.toString() });
18449
18412
  docStorage.setItem("dismissed", JSON.stringify(dismissed.slice(-100)));
18450
18413
  reviewFindings = reviewFindings.filter((finding2) => finding2.id !== id);
18451
- findingCards.get(id)?.remove();
18414
+ const card = findingCards.get(id);
18415
+ if (card) {
18416
+ railAnchors.delete(card);
18417
+ railHomes.delete(card);
18418
+ card.remove();
18419
+ }
18452
18420
  findingCards.delete(id);
18453
18421
  workView.dispatch({ effects: dropReviewFx.of(id) });
18454
18422
  if (activeFinding?.id === id) detach();
18423
+ else clearPlainPreview();
18455
18424
  saveFindings();
18456
18425
  syncReviewLabel();
18457
18426
  }
@@ -18468,8 +18437,9 @@
18468
18437
  if (saved.document === workView.state.doc.toString() && saved.findings?.length && mergeFindings(saved.findings)) syncReviewLabel();
18469
18438
  }
18470
18439
  function clearReview() {
18471
- for (const card of findingCards.values()) card.remove();
18472
- findingCards.clear();
18440
+ discardFindingCards();
18441
+ for (const stale of document.querySelectorAll(".chat-variants")) stale.remove();
18442
+ clearPlainPreview();
18473
18443
  reviewFindings = [];
18474
18444
  checkedSentences.clear();
18475
18445
  workView.dispatch({ effects: setReviewFx.of([]) });
@@ -18774,7 +18744,7 @@
18774
18744
  }
18775
18745
  if (!answer.trim()) throw new Error("The model returned no passage");
18776
18746
  bubble.remove();
18777
- showPreview([answer], null, target);
18747
+ showVariants([answer], null, target, null);
18778
18748
  } catch (error) {
18779
18749
  bubble.title = error.message;
18780
18750
  bubble.textContent = (answer ? answer + "\n\n" : "") + (job.signal.aborted ? "Stopped. " : `${modelError(error).say} `) + "The original idea is unchanged.";
@@ -18846,7 +18816,7 @@
18846
18816
  }
18847
18817
  function placeHome(node) {
18848
18818
  const home = railHomes.get(node) ?? chatStream;
18849
- if (home === chatStream) chatAdd(node);
18819
+ if (home === chatStream) streamAppend(node);
18850
18820
  else home.insertBefore(node, document.getElementById("review-status"));
18851
18821
  }
18852
18822
  function railAdd(node, anchor, home = chatStream) {
@@ -18884,16 +18854,32 @@
18884
18854
  chatClear.setAttribute("aria-label", open ? "Close the conversation" : "Show the conversation");
18885
18855
  chatClear.title = open ? "Close \u2014 nothing is discarded" : "Show the conversation";
18886
18856
  if (open) {
18857
+ chatClear.hidden = false;
18887
18858
  chatScroll(true);
18888
18859
  return;
18889
18860
  }
18890
18861
  chatStream.hidden = true;
18891
18862
  detach();
18892
- for (const card of findingCards.values()) card.remove();
18863
+ discardFindingCards();
18864
+ for (const stale of document.querySelectorAll(".chat-variants")) {
18865
+ if (!stale.querySelector(".variant-card.is-applied")) stale.remove();
18866
+ }
18867
+ chatClear.hidden = chatStream.children.length === 0;
18868
+ layoutRail();
18869
+ }
18870
+ function discardFindingCards() {
18871
+ for (const card of findingCards.values()) {
18872
+ railAnchors.delete(card);
18873
+ railHomes.delete(card);
18874
+ card.remove();
18875
+ }
18893
18876
  findingCards.clear();
18894
18877
  }
18895
18878
  function chatAdd(node) {
18896
18879
  setChatOpen(true);
18880
+ return streamAppend(node);
18881
+ }
18882
+ function streamAppend(node) {
18897
18883
  const stick = chatAtBottom();
18898
18884
  chatStream.append(node);
18899
18885
  chatScroll(stick);
@@ -18911,7 +18897,7 @@
18911
18897
  if (focusComposer) chatInput.focus();
18912
18898
  }
18913
18899
  function detach() {
18914
- cancelPreview();
18900
+ clearPlainPreview();
18915
18901
  attached = null;
18916
18902
  activeFinding = null;
18917
18903
  chatChip.classList.add("hidden");
@@ -18927,94 +18913,69 @@
18927
18913
  });
18928
18914
  return range;
18929
18915
  }
18930
- var REVERT_MS = 700;
18931
- var VariantWidget = class extends WidgetType {
18932
- constructor(text, original, block2, reverting) {
18916
+ var DELTA_MATERIAL = 5;
18917
+ var PlainVariantWidget = class extends WidgetType {
18918
+ constructor(text, block2) {
18933
18919
  super();
18934
18920
  this.text = text;
18935
- this.original = original;
18936
18921
  this.block = block2;
18937
- this.reverting = reverting;
18938
18922
  }
18939
18923
  eq(other) {
18940
- return other.text === this.text && other.block === this.block && other.reverting === this.reverting;
18924
+ return other.text === this.text && other.block === this.block;
18941
18925
  }
18942
18926
  toDOM() {
18943
18927
  const node = document.createElement(this.block ? "div" : "span");
18944
- node.className = this.reverting ? "cm-variant is-reverting" : "cm-variant";
18945
- node.append(variantNodes(this.original, this.text));
18928
+ node.className = "cm-variant-plain";
18929
+ node.textContent = this.text;
18946
18930
  return node;
18947
18931
  }
18948
- };
18949
- var WORD_STEP = 90;
18950
- var WORD_STEPS_MAX = 10;
18951
- function variantNodes(original, text) {
18952
- const fragment = document.createDocumentFragment();
18953
- let step = 0;
18954
- for (const op of wordDiff(original, text)) {
18955
- if (op.type === "keep") {
18956
- fragment.append(op.text);
18957
- continue;
18958
- }
18959
- if (op.type === "del" && op.text.length > 120) continue;
18960
- const part = document.createElement(op.type === "del" ? "del" : "ins");
18961
- part.className = op.type === "del" ? "cm-variant-old" : "cm-variant-new";
18962
- part.textContent = op.text;
18963
- part.style.animationDelay = `${Math.min(step++, WORD_STEPS_MAX) * WORD_STEP}ms`;
18964
- fragment.append(part);
18965
- }
18966
- return fragment;
18967
- }
18968
- function reservedHeight(target, variants) {
18969
- const line = workView.state.doc.lineAt(target.from);
18970
- if (line.number !== workView.state.doc.lineAt(target.to).number) return 0;
18971
- const at = workView.domAtPos(line.from).node;
18972
- const lineEl = (at.nodeType === 1 ? at : at.parentElement)?.closest(".cm-line");
18973
- if (!lineEl) return 0;
18974
- const probe = document.createElement("div");
18975
- probe.className = lineEl.className;
18976
- probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;width:${lineEl.clientWidth}px`;
18977
- lineEl.parentElement.append(probe);
18978
- const before = workView.state.sliceDoc(line.from, target.from);
18979
- const after = workView.state.sliceDoc(target.to, line.to);
18980
- let tallest = 0;
18981
- try {
18982
- for (const option of [target.text, ...variants]) {
18983
- probe.replaceChildren(before, variantNodes(target.text, option), after);
18984
- tallest = Math.max(tallest, probe.offsetHeight);
18985
- }
18986
- } finally {
18987
- probe.remove();
18932
+ ignoreEvent() {
18933
+ return true;
18988
18934
  }
18989
- return tallest;
18990
- }
18991
- var setVariantFx = StateEffect.define();
18992
- var variantField = StateField.define({
18935
+ };
18936
+ var setPlainVariantFx = StateEffect.define();
18937
+ var variantPlainField = StateField.define({
18993
18938
  create: () => null,
18994
18939
  update(value, tr) {
18995
18940
  if (tr.docChanged) return null;
18996
- for (const effect of tr.effects) if (effect.is(setVariantFx)) return effect.value;
18941
+ for (const effect of tr.effects) if (effect.is(setPlainVariantFx)) return effect.value;
18997
18942
  return value;
18998
18943
  },
18999
18944
  provide: (field) => EditorView.decorations.from(field, (value) => {
19000
18945
  if (!value) return Decoration.none;
19001
- const marks2 = [];
19002
- if (value.reserve) {
19003
- marks2.push(Decoration.line({ attributes: { style: `min-height:${value.reserve}px` } }).range(value.lineFrom));
19004
- }
19005
- marks2.push(Decoration.replace({
19006
- widget: new VariantWidget(value.text, value.original, value.block, value.reverting),
18946
+ return Decoration.set([Decoration.replace({
18947
+ widget: new PlainVariantWidget(value.text, value.block),
19007
18948
  block: value.block
19008
- }).range(value.from, value.to));
19009
- return Decoration.set(marks2, true);
18949
+ }).range(value.from, value.to)], true);
19010
18950
  })
19011
18951
  });
19012
- function applyText(text, target) {
18952
+ var plainPreview = null;
18953
+ function showPlainPreview(text, target) {
18954
+ plainPreview = { target };
18955
+ workView.dispatch({
18956
+ effects: [setPlainVariantFx.of({
18957
+ from: target.from,
18958
+ to: target.to,
18959
+ text,
18960
+ block: workView.state.doc.lineAt(target.from).number !== workView.state.doc.lineAt(target.to).number
18961
+ })]
18962
+ });
18963
+ }
18964
+ function clearPlainPreview() {
18965
+ if (!plainPreview) return;
18966
+ plainPreview = null;
18967
+ try {
18968
+ workView.dispatch({ effects: setPlainVariantFx.of(null) });
18969
+ } catch {
18970
+ }
18971
+ }
18972
+ function applyText(text, target, wrap = null, card = null) {
19013
18973
  if (!replacementTarget(workView.state.doc.toString(), target) || workView.state.readOnly) {
19014
18974
  chatAdd(chatEl("div", "chat-error", "The draft changed since this answer. Select the passage and request new options."));
19015
18975
  return;
19016
18976
  }
19017
18977
  const range = target;
18978
+ clearPlainPreview();
19018
18979
  saveSnapshot("Before AI replacement");
19019
18980
  workView.dispatch({
19020
18981
  changes: { from: range.from, to: range.to, insert: text },
@@ -19023,6 +18984,48 @@
19023
18984
  workView.focus();
19024
18985
  save();
19025
18986
  detach();
18987
+ if (card) card.classList.add("is-applied");
18988
+ if (wrap) {
18989
+ wrap.classList.add("is-spent");
18990
+ for (const sibling of wrap.querySelectorAll(".variant-card")) {
18991
+ if (sibling !== card) sibling.hidden = true;
18992
+ }
18993
+ const foot = wrap.querySelector(".variant-foot");
18994
+ if (foot) foot.hidden = true;
18995
+ const soloHint = wrap.querySelector(":scope > .variant-hint");
18996
+ if (soloHint) soloHint.hidden = true;
18997
+ attachUndo(card, text, target);
18998
+ setChatOpen(false);
18999
+ }
19000
+ }
19001
+ function attachUndo(card, text, target) {
19002
+ if (!card) return;
19003
+ let row = card.querySelector(".variant-undo-row");
19004
+ if (!row) {
19005
+ row = chatEl("div", "variant-undo-row");
19006
+ const undo3 = chatEl("button", "chat-again", "Undo");
19007
+ undo3.type = "button";
19008
+ undo3.title = "Put the passage back as it was";
19009
+ undo3.addEventListener("click", () => {
19010
+ const at = Number(undo3.dataset.from);
19011
+ const { applied, previous } = undo3.dataset;
19012
+ if (workView.state.sliceDoc(at, at + applied.length) !== applied) {
19013
+ chatAdd(chatEl("div", "chat-error", "That passage has changed since. Use the editor\u2019s undo instead."));
19014
+ return;
19015
+ }
19016
+ workView.dispatch({ changes: { from: at, to: at + applied.length, insert: previous }, selection: { anchor: at + previous.length } });
19017
+ workView.focus();
19018
+ save();
19019
+ undo3.closest(".chat-variants")?.remove();
19020
+ if (chatStream.children.length === 0) setChatOpen(false);
19021
+ });
19022
+ row.append(undo3);
19023
+ card.append(row);
19024
+ }
19025
+ const undo2 = row.querySelector("button");
19026
+ undo2.dataset.from = String(target.from);
19027
+ undo2.dataset.applied = text;
19028
+ undo2.dataset.previous = target.text;
19026
19029
  }
19027
19030
  function markFor(id) {
19028
19031
  return workView.dom.querySelector(`.cm-slop[data-slop-id="${id}"]`);
@@ -19030,7 +19033,7 @@
19030
19033
  function syncActiveCard() {
19031
19034
  for (const [id, card] of findingCards) card.classList.toggle("is-active", id === activeFinding?.id);
19032
19035
  }
19033
- function findingCard(finding) {
19036
+ function findingCard(finding, instruction) {
19034
19037
  const card = chatEl("div", "chat-card");
19035
19038
  card.addEventListener("mouseenter", () => markFor(finding.id)?.classList.add("is-hot"));
19036
19039
  card.addEventListener("mouseleave", () => markFor(finding.id)?.classList.remove("is-hot"));
@@ -19040,9 +19043,8 @@
19040
19043
  dismiss.setAttribute("aria-label", "Dismiss this finding");
19041
19044
  dismiss.addEventListener("click", () => {
19042
19045
  chatAbort?.abort();
19046
+ clearPlainPreview();
19043
19047
  dismissFinding(finding.id);
19044
- cancelPreview();
19045
- card.remove();
19046
19048
  });
19047
19049
  card.append(
19048
19050
  dismiss,
@@ -19058,167 +19060,131 @@
19058
19060
  if (!card.querySelector(".chat-error")) card.append(chatEl("div", "chat-error", "This passage has changed. Run Review again."));
19059
19061
  return;
19060
19062
  }
19061
- const target = { ...live, text: workView.state.sliceDoc(live.from, live.to) };
19062
- attach(target, finding, false);
19063
- requestVariants(`Fix ${finding.pattern}: ${finding.fix}. Preserve facts and voice. Replace only the quoted passage.`, null, card);
19063
+ offer.disabled = true;
19064
+ offer.textContent = "Looking\u2026";
19065
+ if (activeFinding?.id !== finding.id) {
19066
+ const target = { ...live, text: workView.state.sliceDoc(live.from, live.to) };
19067
+ attach(target, finding, false);
19068
+ }
19069
+ requestVariants(instruction, null, card);
19064
19070
  });
19065
19071
  card.append(offer);
19066
19072
  return card;
19067
19073
  }
19068
- var previewStrip = document.getElementById("chat-preview");
19069
- var cardContent = /* @__PURE__ */ new WeakMap();
19070
- function cardShow(card, ...nodes) {
19071
- if (!cardContent.has(card)) cardContent.set(card, [...card.childNodes]);
19072
- card.classList.add("is-bare");
19073
- card.replaceChildren(...nodes);
19074
- }
19075
- function cardRestore(card) {
19076
- const saved = cardContent.get(card);
19077
- if (!saved) return;
19078
- card.classList.remove("is-bare");
19079
- card.replaceChildren(...saved);
19080
- cardContent.delete(card);
19081
- }
19082
- var previewCount = document.getElementById("preview-count");
19083
- var previewKept = document.getElementById("preview-kept");
19084
- var preview = null;
19085
- var keptTimer = null;
19086
- var revertTimer = null;
19087
- function renderPreview() {
19088
- reviewButton.hidden = !!preview;
19089
- previewStrip.hidden = !preview;
19090
- if (!preview) {
19091
- clearTimeout(revertTimer);
19092
- workView.dispatch({ effects: setVariantFx.of(null) });
19093
- return;
19094
- }
19095
- const { target, index, variants } = preview;
19096
- const original = index < 0;
19097
- previewCount.textContent = original ? "Original" : `${index + 1} of ${variants.length}`;
19098
- document.getElementById("preview-again").hidden = preview.instruction === null;
19099
- const text = original ? target.text : variants[index];
19100
- const against = original ? variants[preview.shown] : target.text;
19101
- if (!original) preview.shown = index;
19102
- workView.dispatch({
19103
- effects: [
19104
- setVariantFx.of({
19105
- from: target.from,
19106
- to: target.to,
19107
- lineFrom: workView.state.doc.lineAt(target.from).from,
19108
- reserve: preview.reserve,
19109
- text,
19110
- original: against,
19111
- reverting: original,
19112
- block: workView.state.doc.lineAt(target.from).number !== workView.state.doc.lineAt(target.to).number
19113
- }),
19114
- EditorView.scrollIntoView(target.from, { y: "center" })
19115
- ]
19074
+ function variantCards(variants, instruction, target, card = null) {
19075
+ const doc2 = workView.state.doc.toString();
19076
+ const measurable = isLatinScript(doc2);
19077
+ const base2 = measurable ? styleScore(doc2).score : null;
19078
+ const scored = variants.map((text) => ({
19079
+ text,
19080
+ score: measurable ? styleScore(doc2.slice(0, target.from) + text + doc2.slice(target.to)).score : null
19081
+ }));
19082
+ if (measurable) scored.sort((a, b) => a.score - b.score);
19083
+ const wrap = chatEl("div", "chat-variants");
19084
+ wrap.setAttribute("role", "list");
19085
+ wrap.dataset.targetFrom = String(target.from);
19086
+ scored.forEach(({ text, score }, index) => {
19087
+ const item = chatEl("div", "variant-card");
19088
+ item.setAttribute("role", "listitem");
19089
+ item.tabIndex = 0;
19090
+ item.setAttribute("role", "button");
19091
+ item.setAttribute("aria-label", `Apply option ${index + 1}`);
19092
+ const label = chatEl("div", "variant-label", `Option ${index + 1}`);
19093
+ if (score !== null) {
19094
+ const move = score - base2;
19095
+ const tone = move <= -DELTA_MATERIAL ? " is-better" : move >= DELTA_MATERIAL ? " is-worse" : "";
19096
+ const delta = chatEl("span", `variant-delta${tone}`, `${base2} \u2192 ${score}`);
19097
+ delta.title = "Local AI-tell score for the whole draft if you pick this variant";
19098
+ label.append(delta);
19099
+ }
19100
+ const body = chatEl("div", "variant-text", text);
19101
+ item.append(label, body);
19102
+ const previewIt = () => {
19103
+ if (wrap.classList.contains("is-spent")) return;
19104
+ if (!replacementTarget(workView.state.doc.toString(), target)) return;
19105
+ showPlainPreview(text, target);
19106
+ };
19107
+ const unpreview = () => clearPlainPreview();
19108
+ item.addEventListener("mouseenter", previewIt);
19109
+ item.addEventListener("mouseleave", unpreview);
19110
+ item.addEventListener("focus", previewIt);
19111
+ item.addEventListener("blur", unpreview);
19112
+ const apply = () => {
19113
+ if (!wrap.classList.contains("is-spent")) applyText(text, target, wrap, item);
19114
+ };
19115
+ item.addEventListener("click", apply);
19116
+ item.addEventListener("keydown", (event) => {
19117
+ if (event.key === "Enter" || event.key === " ") {
19118
+ event.preventDefault();
19119
+ apply();
19120
+ } else if (event.key === "Escape") {
19121
+ event.preventDefault();
19122
+ unpreview();
19123
+ item.blur();
19124
+ workView.focus();
19125
+ }
19126
+ });
19127
+ wrap.append(item);
19116
19128
  });
19117
- clearTimeout(revertTimer);
19118
- if (original) {
19119
- revertTimer = setTimeout(() => {
19120
- if (preview?.index === -1) workView.dispatch({ effects: setVariantFx.of(null) });
19121
- }, REVERT_MS);
19122
- }
19123
- }
19124
- function showPreview(variants, instruction, target, card = null) {
19125
- hideKept();
19126
- preview = { variants, instruction, target, index: 0, shown: 0, card };
19127
- preview.reserve = reservedHeight(target, variants);
19128
- previewStrip.hidden = false;
19129
- if (card) cardShow(card, previewStrip);
19130
- else railAdd(previewStrip, () => preview?.target.from ?? null, document.querySelector(".chat-recipes"));
19131
- renderPreview();
19132
- document.getElementById("preview-next").focus();
19133
- }
19134
- function cancelPreview() {
19135
- if (!preview) return;
19136
- const { card } = preview;
19137
- preview = null;
19138
- railAnchors.delete(previewStrip);
19139
- previewStrip.style.top = "";
19140
- previewStrip.style.visibility = "";
19141
- document.querySelector(".chat-recipes").insertBefore(previewStrip, document.getElementById("review-status"));
19142
- if (card) cardRestore(card);
19143
- renderPreview();
19144
- }
19145
- function endPreview() {
19146
- if (!preview) return;
19147
- const { variants, index, target, card } = preview;
19148
- cancelPreview();
19149
- if (index < 0) return;
19150
- applyText(variants[index], target);
19151
- card?.remove();
19152
- showKept(variants[index], target);
19153
- }
19154
- function stepPreview(delta) {
19155
- if (!preview) return;
19156
- const stops = preview.variants.length + 1;
19157
- preview.index = (preview.index + 1 + delta + stops) % stops - 1;
19158
- renderPreview();
19159
- }
19160
- var KEPT_MS = 8e3;
19161
- function showKept(text, target) {
19162
- clearTimeout(keptTimer);
19163
- railAdd(previewKept, () => target.from, document.querySelector(".chat-recipes"));
19164
- previewKept.hidden = false;
19165
- previewKept.dataset.from = target.from;
19166
- previewKept.dataset.applied = text;
19167
- previewKept.dataset.previous = target.text;
19168
- previewKept.style.setProperty("--kept-ms", `${KEPT_MS}ms`);
19169
- previewKept.classList.remove("is-counting");
19170
- void previewKept.offsetWidth;
19171
- previewKept.classList.add("is-counting");
19172
- keptTimer = setTimeout(hideKept, KEPT_MS);
19173
- }
19174
- function hideKept() {
19175
- clearTimeout(keptTimer);
19176
- previewKept.hidden = true;
19177
- previewKept.classList.remove("is-counting");
19178
- railAnchors.delete(previewKept);
19179
- previewKept.style.top = "";
19180
- previewKept.style.visibility = "";
19181
- document.querySelector(".chat-recipes").insertBefore(previewKept, document.getElementById("review-status"));
19182
- }
19183
- document.getElementById("preview-undo").addEventListener("click", () => {
19184
- const from = Number(previewKept.dataset.from);
19185
- const { applied, previous } = previewKept.dataset;
19186
- hideKept();
19187
- if (workView.state.sliceDoc(from, from + applied.length) !== applied) {
19188
- chatAdd(chatEl("div", "chat-error", "That passage has changed since. Use the editor\u2019s undo instead."));
19189
- return;
19190
- }
19191
- workView.dispatch({ changes: { from, to: from + applied.length, insert: previous }, selection: { anchor: from + previous.length } });
19192
- workView.focus();
19193
- save();
19194
- });
19195
- document.getElementById("preview-prev").addEventListener("click", () => stepPreview(-1));
19196
- document.getElementById("preview-next").addEventListener("click", () => stepPreview(1));
19197
- document.getElementById("preview-again").addEventListener("click", () => {
19198
- if (!preview) return;
19199
- const { instruction, target, card } = preview;
19200
- cancelPreview();
19201
- if (!replacementTarget(workView.state.doc.toString(), target)) {
19202
- chatAdd(chatEl("div", "chat-error", "This answer is out of date. Select the passage again."));
19203
- return;
19204
- }
19205
- requestVariants(instruction, target, card);
19206
- });
19207
- previewStrip.addEventListener("keydown", (event) => {
19208
- if (event.key === "ArrowLeft") {
19209
- event.preventDefault();
19210
- stepPreview(-1);
19129
+ if (instruction !== null) {
19130
+ const foot = chatEl("div", "variant-foot");
19131
+ const again = chatEl("button", "chat-again is-icon");
19132
+ again.type = "button";
19133
+ again.setAttribute("aria-label", "Try again \u2014 ask for three more");
19134
+ again.title = "Try again \u2014 ask for three more";
19135
+ again.innerHTML = '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="19.17 3.33 19.17 8.33 14.17 8.33"/><path d="M17.07 12.5a7.5 7.5 0 1 1-1.77-7.8l3.87 3.63"/></svg>';
19136
+ again.addEventListener("click", () => {
19137
+ if (wrap.classList.contains("is-spent")) return;
19138
+ clearPlainPreview();
19139
+ wrap.remove();
19140
+ requestVariants(instruction, target, card);
19141
+ });
19142
+ const hint = chatEl("span", "variant-hint", "Hover to preview \xB7 Click to apply \xB7 Esc to dismiss");
19143
+ foot.append(hint, again);
19144
+ wrap.append(foot);
19145
+ } else {
19146
+ const hint = chatEl("div", "variant-hint", "Hover to preview \xB7 Click to apply \xB7 Esc to dismiss");
19147
+ wrap.append(hint);
19148
+ }
19149
+ return wrap;
19150
+ }
19151
+ function skeletonCards(count = 3) {
19152
+ const wrap = chatEl("div", "chat-variants is-loading");
19153
+ wrap.setAttribute("aria-busy", "true");
19154
+ for (let i = 0; i < count; i++) {
19155
+ const item = chatEl("div", "variant-card is-loading");
19156
+ item.append(
19157
+ chatEl("div", "skeleton skeleton-label"),
19158
+ chatEl("div", "skeleton skeleton-line"),
19159
+ chatEl("div", "skeleton skeleton-line is-short")
19160
+ );
19161
+ wrap.append(item);
19211
19162
  }
19212
- if (event.key === "ArrowRight") {
19213
- event.preventDefault();
19214
- stepPreview(1);
19163
+ return wrap;
19164
+ }
19165
+ function hideFindingCard(card) {
19166
+ if (!card || card.hidden) return;
19167
+ card.hidden = true;
19168
+ layoutRail();
19169
+ }
19170
+ function restoreFindingCard(card) {
19171
+ if (!card) return;
19172
+ const offer = card.querySelector(".chat-offer:disabled");
19173
+ if (offer) {
19174
+ offer.disabled = false;
19175
+ offer.textContent = "Options";
19215
19176
  }
19216
- if (event.key === "Enter") {
19217
- event.preventDefault();
19218
- endPreview();
19219
- workView.focus();
19177
+ if (!card.hidden) return;
19178
+ card.hidden = false;
19179
+ layoutRail();
19180
+ }
19181
+ function showVariants(variants, instruction, target) {
19182
+ chatAdd(variantCards(variants, instruction, target));
19183
+ try {
19184
+ workView.dispatch({ effects: EditorView.scrollIntoView(target.from, { y: "center" }) });
19185
+ } catch {
19220
19186
  }
19221
- });
19187
+ }
19222
19188
  async function requestVariants(instruction, existingTarget = null, card = null) {
19223
19189
  const range = existingTarget ?? attachedRange();
19224
19190
  const target = existingTarget ?? (range && { ...range, document: workView.state.doc.toString() });
@@ -19228,8 +19194,9 @@
19228
19194
  }
19229
19195
  if (!await ensureAgent()) return;
19230
19196
  if (!replacementTarget(workView.state.doc.toString(), target)) return;
19231
- cancelPreview();
19232
- if (card) cardShow(card, chatEl("div", "card-working", "Looking for options\u2026"));
19197
+ clearPlainPreview();
19198
+ if (card) hideFindingCard(card);
19199
+ const placeholder2 = chatAdd(skeletonCards());
19233
19200
  chatAbort?.abort();
19234
19201
  const job = startJob();
19235
19202
  chatAbort = job;
@@ -19250,11 +19217,24 @@
19250
19217
  const data = await res.json();
19251
19218
  if (!res.ok || data.error) throw new Error(data.error || `Server error ${res.status}`);
19252
19219
  if (!replacementTarget(workView.state.doc.toString(), target)) throw new Error("The draft changed while the rewrites were coming back. Select the passage again.");
19253
- showPreview(data.variants, instruction, target, card);
19220
+ const stick = chatAtBottom();
19221
+ const wrap = variantCards(data.variants, instruction, target);
19222
+ if (placeholder2.isConnected) {
19223
+ placeholder2.replaceWith(wrap);
19224
+ chatScroll(stick);
19225
+ } else {
19226
+ placeholder2.remove();
19227
+ chatAdd(wrap);
19228
+ }
19254
19229
  } catch (error) {
19255
- if (card) cardRestore(card);
19256
- if (error.name === "AbortError") return;
19230
+ if (error.name === "AbortError") {
19231
+ placeholder2.remove();
19232
+ restoreFindingCard(card);
19233
+ return;
19234
+ }
19257
19235
  console.error("[/rewrite]", error);
19236
+ placeholder2.remove();
19237
+ restoreFindingCard(card);
19258
19238
  chatAdd(errorCard(modelError(error), () => requestVariants(instruction, target, card)));
19259
19239
  } finally {
19260
19240
  finishJob(job);
@@ -19263,7 +19243,7 @@
19263
19243
  }
19264
19244
  var findingCards = /* @__PURE__ */ new Map();
19265
19245
  function openFinding(finding, center = false) {
19266
- endPreview();
19246
+ clearPlainPreview();
19267
19247
  const range = findingRange(finding.id);
19268
19248
  if (!range) return;
19269
19249
  attach({ ...range, text: workView.state.sliceDoc(range.from, range.to) }, finding, false);
@@ -19272,7 +19252,10 @@
19272
19252
  chatScroll(true);
19273
19253
  return;
19274
19254
  }
19275
- findingCards.set(finding.id, railAdd(findingCard(finding), () => findingRange(finding.id)?.from ?? null));
19255
+ const instruction = `Fix ${finding.pattern}: ${finding.fix}. Preserve facts and voice. Replace only the quoted passage.`;
19256
+ const card = findingCard(finding, instruction);
19257
+ findingCards.set(finding.id, railAdd(card, () => card.hidden ? null : findingRange(finding.id)?.from ?? null));
19258
+ if (card.parentElement === chatStream) setChatOpen(true);
19276
19259
  syncActiveCard();
19277
19260
  }
19278
19261
  async function chatSend() {
@@ -19393,9 +19376,11 @@ Editing direction: ${activeFinding.fix}`,
19393
19376
  detach();
19394
19377
  chatInput.focus();
19395
19378
  });
19379
+ chatClear.addEventListener("mousedown", (event) => event.preventDefault());
19396
19380
  chatClear.addEventListener("click", () => {
19397
- setChatOpen(chatStream.hidden);
19398
- chatInput.focus();
19381
+ const open = chatStream.hidden;
19382
+ setChatOpen(open);
19383
+ (open ? chatInput : workView).focus();
19399
19384
  });
19400
19385
  document.addEventListener("keydown", (event) => {
19401
19386
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
@@ -19502,6 +19487,15 @@ Editing direction: ${activeFinding.fix}`,
19502
19487
  transition: "background .12s ease"
19503
19488
  },
19504
19489
  ".cm-slop:hover": { background: "var(--slop-tint-hover)" },
19490
+ // Hover preview of a variant: a plain white sheet over the passage, no
19491
+ // colours. The attached tint stays underneath for the passage itself; this
19492
+ // replace decoration only shows while a card is hovered or focused.
19493
+ ".cm-variant-plain": {
19494
+ background: "var(--overlay)",
19495
+ borderRadius: "2px",
19496
+ boxShadow: "0 0 0 2px var(--overlay)",
19497
+ borderBottom: "1px solid var(--border-strong)"
19498
+ },
19505
19499
  // Slightly dim the content while /idea is streaming
19506
19500
  "&.streaming .cm-content": { opacity: "0.8" },
19507
19501
  // Placeholder text (shown when doc is empty)
@@ -19560,7 +19554,7 @@ Editing direction: ${activeFinding.fix}`,
19560
19554
  ghostField,
19561
19555
  reviewField,
19562
19556
  attachField,
19563
- variantField,
19557
+ variantPlainField,
19564
19558
  // Read-only compartment — toggled during /idea streaming
19565
19559
  readonlyComp.of(EditorState.readOnly.of(true)),
19566
19560
  // Word wrap (essential for prose)
@@ -19586,9 +19580,10 @@ Editing direction: ${activeFinding.fix}`,
19586
19580
  openFinding(finding);
19587
19581
  return false;
19588
19582
  },
19589
- // Reaching for the text means the writer is done choosing.
19583
+ // Reaching for the text dismisses a hover preview; the cards stay
19584
+ // choosing is a click on a card, not a click in the draft.
19590
19585
  mousedown() {
19591
- endPreview();
19586
+ clearPlainPreview();
19592
19587
  return false;
19593
19588
  },
19594
19589
  contextmenu(event, view) {
@@ -19614,7 +19609,7 @@ Editing direction: ${activeFinding.fix}`,
19614
19609
  detach();
19615
19610
  });
19616
19611
  }
19617
- if (preview) queueMicrotask(cancelPreview);
19612
+ if (plainPreview) queueMicrotask(clearPlainPreview);
19618
19613
  if (reviewFindings.length) {
19619
19614
  syncReviewLabel();
19620
19615
  saveFindings();
@@ -19682,6 +19677,7 @@ Editing direction: ${activeFinding.fix}`,
19682
19677
  findingCards.clear();
19683
19678
  docStorage.removeItem("wa-chat");
19684
19679
  docStorage.removeItem("dismissed");
19680
+ setChatOpen(false);
19685
19681
  loadingDocument = false;
19686
19682
  }
19687
19683
  function saveToDisk() {
package/public/index.html CHANGED
@@ -73,24 +73,6 @@
73
73
  <!-- Actions that spend a model call sit with the assistant, not in the
74
74
  header: the draft's frame stays the draft's. -->
75
75
  <div class="chat-recipes">
76
- <!-- Rewrites are tried on in the draft itself; this only steps through
77
- them. display:contents keeps the buttons in the row above. -->
78
- <span class="preview-controls" id="chat-preview" hidden>
79
- <span class="preview-step">
80
- <button type="button" class="recipe is-icon" id="preview-prev" aria-label="Previous option" title="Previous option (←)">‹</button>
81
- <span id="preview-count"></span>
82
- <button type="button" class="recipe is-icon" id="preview-next" aria-label="Next option" title="Next option (→)">›</button>
83
- </span>
84
- <button type="button" class="recipe is-icon" id="preview-again" aria-label="Try again" title="Try again — ask for three more">
85
- <svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="19.17 3.33 19.17 8.33 14.17 8.33"/><path d="M17.07 12.5a7.5 7.5 0 1 1-1.77-7.8l3.87 3.63"/></svg>
86
- </button>
87
- <small class="preview-hint">← → step · leaving keeps what you see · Esc restores</small>
88
- </span>
89
- <!-- A replacement nobody pressed a button for has to leave a way back
90
- where the writer is already looking. -->
91
- <span class="preview-controls" id="preview-kept" hidden>
92
- <button type="button" class="recipe" id="preview-undo" title="Put the passage back as it was">Undo</button>
93
- </span>
94
76
  <span id="review-status" role="status"></span>
95
77
  </div>
96
78
  <div class="chat-composer">
package/public/style.css CHANGED
@@ -68,7 +68,7 @@
68
68
  /* Review actions live in the assistant panel. The status line is the last
69
69
  item and takes the leftover width, truncating rather than wrapping. */
70
70
  .chat-recipes { display: flex; align-items: center; gap: 8px; padding: 0 4px 8px; }
71
- .chat-recipes:not(:has(> #chat-preview:not([hidden]), > #ai-stop:not([hidden]), > #review-status:not(:empty))) { display: none; }
71
+ .chat-recipes:not(:has(> #review-status:not(:empty))) { display: none; }
72
72
  .recipe {
73
73
  padding: 5px 12px;
74
74
  border: 1px solid var(--border);
@@ -84,8 +84,6 @@
84
84
  .recipe:hover { background: var(--surface-3); }
85
85
  .recipe.has-findings { color: var(--accent); border-color: var(--accent); }
86
86
  .recipe.is-icon { display: inline-flex; align-items: center; justify-content: center; min-width: 30px; padding: 5px 6px; text-align: center; }
87
- .preview-step { display: inline-flex; align-items: center; gap: 6px; }
88
- .preview-hint { display: none; }
89
87
  #review-status { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); font-size: 12px; }
90
88
 
91
89
  #document-menu-button, .document-dialog button {
@@ -396,37 +394,8 @@ html, body {
396
394
 
397
395
  #rail .chat-offer { margin-top: 7px; padding: 3px 10px; font-size: 12px; }
398
396
 
399
- /* A card is a box while it is a remark. What comes after — waiting for
400
- options, stepping through them, the way back — is passing state, and a
401
- bordered panel around a status line or one button is more furniture than
402
- the moment deserves. The card keeps its place in the rail and gives up its
403
- walls. */
404
- .chat-card.is-bare {
405
- padding: 0;
406
- border: 0;
407
- background: none;
408
- }
409
397
  .card-working { color: var(--muted); font-size: 13px; }
410
398
 
411
- /* Bare too: the controls are the interface, nothing needs to hold them. */
412
- #rail #chat-preview {
413
- position: relative;
414
- display: flex;
415
- flex-wrap: wrap;
416
- align-items: center;
417
- gap: 8px;
418
- }
419
- /* Asking for three more is a corner action, not a step in the ring: it sits
420
- out of the reading order, at the end of the row the stepper starts. */
421
- #rail #preview-again {
422
- margin-left: auto;
423
- border: 0;
424
- background: none;
425
- color: var(--muted);
426
- }
427
- #rail #preview-again:hover { color: var(--text); background: none; }
428
- #rail .preview-hint { display: block; flex: 1 0 100%; color: var(--muted); font-size: 11px; }
429
-
430
399
  .pane-header {
431
400
  position: relative;
432
401
  height: 48px;
@@ -686,6 +655,20 @@ html, body {
686
655
  .chat-again:hover { background: var(--surface-2); color: var(--text); }
687
656
  .chat-error .chat-again { margin-left: auto; align-self: center; }
688
657
 
658
+ /* Icon-only retry under the options: a quiet refresh mark, not a second pill
659
+ shouting next to the cards. Text retries elsewhere keep the pill. */
660
+ .chat-again.is-icon {
661
+ display: inline-flex;
662
+ align-items: center;
663
+ justify-content: center;
664
+ min-width: 30px;
665
+ min-height: 30px;
666
+ padding: 5px 6px;
667
+ color: var(--muted);
668
+ }
669
+ .chat-again.is-icon:hover { color: var(--text); }
670
+ .chat-again.is-icon svg { display: block; }
671
+
689
672
  /* Rendered reply: block spacing comes from the tags, not from pre-wrap */
690
673
  .chat-message.is-markdown { white-space: normal; }
691
674
  .chat-message.is-markdown > * + * { margin-top: 8px; }
@@ -957,90 +940,176 @@ html, body {
957
940
  its content, so the reserved space is measured, not guessed. */
958
941
  #editor-wrapper .cm-content { padding-bottom: var(--chat-height, 140px); }
959
942
 
960
- /* ── Rewrite preview ──────────────────────────────────── */
961
- /* The proposal is a clean sheet laid over the draft: the same white the chat
962
- cards use, not another accent. Colour in here belongs to the edit itself —
963
- what was added and what was cut and a blue wash underneath drowns both. */
964
- .cm-variant {
943
+ /* ── Rewrite variants (0.2 cards, tightened) ────────────────────────── */
944
+ /* A hover/focus preview is a clean sheet laid over the draft: the same white
945
+ the chat cards use, not another accent and deliberately no red/green.
946
+ NOTE: the coloured word-diff (cm-variant-old/new + wordDiff) is kept in the
947
+ tree as a noted option for another place (e.g. history compare); it is not
948
+ used on this path. */
949
+ .cm-variant-plain {
965
950
  background: var(--overlay);
966
951
  box-shadow: 0 0 0 2px var(--overlay);
967
952
  border-bottom: 1px solid var(--border-strong);
953
+ border-radius: 2px;
968
954
  white-space: pre-wrap;
969
955
  }
970
956
 
971
- /* What this option changes stays on the line struck words stay struck, added
972
- words stay added. Only the colour is temporary: it walks through the edit in
973
- reading order, holds for a couple of seconds, and leaves the same way, so
974
- the sentence ends up readable as a sentence instead of as a coloured diff.
975
- Nothing here animates size: the line's reserved height is measured against
976
- this, and a highlight that moved the text would make that measurement a
977
- lie. */
978
- .cm-variant-new {
979
- border-radius: var(--r-xs);
980
- text-decoration: none;
981
- animation: word-add 2.6s var(--ease) both;
957
+ /* A pressed toggle has to look pressed wherever it lives. */
958
+ .recipe[aria-pressed="true"] { background: var(--surface-3); border-color: var(--border-strong); }
959
+
960
+ /* Stack of options under a finding card (or in the stream for a selection).
961
+ One quiet column: the option owns the card, the score is metadata. */
962
+ .chat-variants {
963
+ display: flex;
964
+ flex-direction: column;
965
+ gap: 8px;
982
966
  }
983
967
 
984
- .cm-variant-old {
968
+ /* An option reads as a small card, not as running chat text: a firm header
969
+ row (Option N + score delta), then the prose itself. The whole card is the
970
+ button — hover previews it in place, click keeps it. */
971
+ .variant-card {
972
+ padding: 10px 14px 11px;
973
+ border-radius: var(--r-field);
974
+ border: 1px solid var(--border-subtle);
975
+ border-left: 2px solid var(--border-strong);
976
+ background: var(--overlay);
977
+ cursor: pointer;
978
+ color: var(--text);
979
+ font-size: 14px;
980
+ line-height: 1.55;
981
+ white-space: pre-wrap;
982
+ overflow-wrap: anywhere;
983
+ transition: border-color .12s ease, background .12s ease, box-shadow .12s ease, translate .12s var(--ease);
984
+ animation: chat-in .2s var(--ease);
985
+ }
986
+
987
+ .variant-card:hover,
988
+ .variant-card:focus-visible {
989
+ background: var(--surface-2);
990
+ border-left-color: var(--accent);
991
+ box-shadow: var(--shadow-sm);
992
+ outline: none;
993
+ }
994
+
995
+ .variant-card:focus-visible {
996
+ outline: 2px solid var(--accent);
997
+ outline-offset: 2px;
998
+ }
999
+
1000
+ .variant-label {
1001
+ display: flex;
1002
+ align-items: baseline;
1003
+ justify-content: space-between;
1004
+ gap: 8px;
1005
+ margin-bottom: 5px;
1006
+ color: var(--muted);
1007
+ font-family: var(--ui-font);
1008
+ font-size: 11px;
1009
+ font-weight: 600;
1010
+ letter-spacing: .06em;
1011
+ text-transform: uppercase;
1012
+ }
1013
+
1014
+ /* Draft score if you pick this variant. Informational — never fed to the model. */
1015
+ .variant-delta {
1016
+ font-weight: 500;
1017
+ font-variant-numeric: tabular-nums;
1018
+ letter-spacing: 0;
1019
+ text-transform: none;
1020
+ }
1021
+
1022
+ .variant-delta.is-better { color: var(--ok); }
1023
+ .variant-delta.is-worse { color: var(--danger); }
1024
+
1025
+ .variant-text { color: var(--text); }
1026
+
1027
+ .variant-card.is-loading { cursor: default; border-left-color: var(--border-subtle); }
1028
+ .variant-card.is-loading:hover { background: var(--overlay); box-shadow: none; }
1029
+
1030
+ /* Once one option is applied the others point at a range that is gone. Stop
1031
+ advertising a click that would silently do nothing. */
1032
+ .chat-variants.is-spent .variant-card { cursor: default; opacity: .45; }
1033
+ .chat-variants.is-spent .variant-card:hover,
1034
+ .chat-variants.is-spent .variant-card:focus-visible {
1035
+ background: var(--overlay);
1036
+ border-left-color: var(--border-subtle);
1037
+ box-shadow: none;
1038
+ }
1039
+
1040
+ .chat-variants.is-spent .variant-card.is-applied {
1041
+ opacity: 1;
1042
+ border-left-color: var(--accent);
1043
+ }
1044
+
1045
+ .variant-card.is-applied .variant-label::after {
1046
+ content: 'applied';
1047
+ margin-left: auto;
1048
+ color: var(--accent);
1049
+ font-weight: 500;
1050
+ letter-spacing: .06em;
1051
+ text-transform: uppercase;
1052
+ }
1053
+
1054
+ /* Footer: hint left (what hover/click do), Try again right. Quiet by design —
1055
+ three disliked options are a dead end without the button, but the button
1056
+ must not shout louder than the options. */
1057
+ .variant-foot {
1058
+ display: flex;
1059
+ align-items: center;
1060
+ justify-content: space-between;
1061
+ gap: 10px;
1062
+ }
1063
+
1064
+ .variant-hint {
1065
+ color: var(--muted);
1066
+ font-family: var(--ui-font);
1067
+ font-size: 11px;
1068
+ line-height: 1.4;
1069
+ }
1070
+
1071
+ .chat-variants > .variant-hint { padding: 0 2px; }
1072
+
1073
+ /* The way back on the kept card: one quiet Undo with no timer, sitting under
1074
+ the applied text. It stays good until the passage changes under it. */
1075
+ .variant-undo-row { display: flex; margin-top: 8px; }
1076
+ .variant-undo-row .chat-again { align-self: flex-start; }
1077
+
1078
+ /* Rail: same cards, tighter — they are margin notes, not the conversation. */
1079
+ #rail .chat-variants { gap: 6px; }
1080
+ #rail .variant-card { padding: 9px 12px 10px; font-size: 13px; }
1081
+ #rail .variant-hint { font-size: 11px; }
1082
+
1083
+ /* Loading skeletons: same shape as the cards coming, so nothing jumps. */
1084
+ .skeleton {
985
1085
  border-radius: var(--r-xs);
986
- text-decoration: line-through;
987
- text-decoration-thickness: 1px;
988
- animation: word-cut 2.6s var(--ease) both;
989
- }
990
-
991
- @keyframes word-add {
992
- 0% { background: transparent; }
993
- 10%, 65% { background: var(--added-tint); }
994
- 100% { background: transparent; }
995
- }
996
-
997
- @keyframes word-cut {
998
- 0% { background: transparent; color: inherit; }
999
- 10%, 65% { background: var(--removed-tint); color: var(--danger); }
1000
- 100% { background: transparent; color: var(--muted); }
1001
- }
1002
-
1003
- /* The cut word and the word taking its place are adjacent in the edit script
1004
- — the space between them belongs to neither, so it has to be drawn. */
1005
- .cm-variant-old + .cm-variant-new { margin-left: .28em; }
1006
-
1007
- /* Stepping back to the writer's wording: the option's words are the ones
1008
- struck, and the words coming back are the writer's own — nothing lights up
1009
- for them, they were never a proposal. */
1010
- .is-reverting .cm-variant-new { animation: none; background: transparent; }
1011
-
1012
- /* The controls belong to the row above the composer, not to a box of their
1013
- own — display:contents lets them sit in that flex row directly. */
1014
- .preview-controls { display: contents; }
1015
- #preview-count { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; }
1016
- #rail #preview-kept .recipe { box-shadow: var(--shadow-sm); }
1017
- /* The same card the options sat in, holding the way back instead: the box
1018
- stays put, only what is inside it changes. The bar drains over the same
1019
- window Undo is good for, so the writer sees the time running out rather
1020
- than guessing when the offer expires. */
1021
- /* One button, no panel around it. The countdown lives in the button itself:
1022
- the control is the timer, so there is nothing to draw but the control. */
1023
- #rail #preview-kept { display: flex; }
1024
- #rail #preview-undo { position: relative; overflow: hidden; }
1025
- /* Gated behind a class, not the bare selector: JS restarts the drain by
1026
- toggling it off and on across a reflow, which a plain animation on the
1027
- selector itself would not replay. */
1028
- #rail #preview-kept.is-counting #preview-undo::after {
1029
- content: '';
1030
- position: absolute;
1031
- left: 0; right: 0; bottom: 0;
1032
- height: 2px;
1033
- background: var(--muted);
1034
- transform-origin: left;
1035
- animation: kept-countdown linear forwards;
1036
- animation-duration: var(--kept-ms, 8000ms);
1086
+ background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 37%, var(--surface-3) 63%);
1087
+ background-size: 400% 100%;
1088
+ animation: skeleton-sheen 1.4s ease-in-out infinite;
1037
1089
  }
1038
- @keyframes kept-countdown {
1039
- from { transform: scaleX(1); }
1040
- to { transform: scaleX(0); }
1090
+
1091
+ .skeleton-label {
1092
+ width: 72px;
1093
+ height: 10px;
1094
+ margin-bottom: 8px;
1095
+ }
1096
+
1097
+ .skeleton-line {
1098
+ height: 12px;
1099
+ margin-top: 7px;
1100
+ }
1101
+
1102
+ .skeleton-line.is-short { width: 62%; }
1103
+
1104
+ @keyframes skeleton-sheen {
1105
+ from { background-position: 100% 50%; }
1106
+ to { background-position: 0 50%; }
1107
+ }
1108
+
1109
+ @media (prefers-reduced-motion: reduce) {
1110
+ .skeleton { animation: none; }
1111
+ .variant-card { animation: none; transition: none; }
1041
1112
  }
1042
- /* A pressed toggle has to look pressed wherever the strip is living. */
1043
- .recipe[aria-pressed="true"] { background: var(--surface-3); border-color: var(--border-strong); }
1044
1113
 
1045
1114
  .error-msg {
1046
1115
  padding: 12px 14px;