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 +8 -0
- package/README.md +1 -1
- package/SPEC.md +8 -20
- package/editing.js +4 -0
- package/package.json +1 -1
- package/public/app.js +273 -277
- package/public/index.html +0 -18
- package/public/style.css +174 -105
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
|
-

|
|
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.
|
|
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,
|
|
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 card — it 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
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" &&
|
|
18440
|
-
|
|
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)
|
|
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
|
-
|
|
18472
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18931
|
-
var
|
|
18932
|
-
constructor(text,
|
|
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
|
|
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 =
|
|
18945
|
-
node.
|
|
18928
|
+
node.className = "cm-variant-plain";
|
|
18929
|
+
node.textContent = this.text;
|
|
18946
18930
|
return node;
|
|
18947
18931
|
}
|
|
18948
|
-
|
|
18949
|
-
|
|
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
|
-
|
|
18990
|
-
|
|
18991
|
-
var
|
|
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(
|
|
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
|
-
|
|
19002
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19062
|
-
|
|
19063
|
-
|
|
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
|
-
|
|
19069
|
-
|
|
19070
|
-
|
|
19071
|
-
|
|
19072
|
-
|
|
19073
|
-
|
|
19074
|
-
|
|
19075
|
-
|
|
19076
|
-
|
|
19077
|
-
|
|
19078
|
-
|
|
19079
|
-
|
|
19080
|
-
|
|
19081
|
-
|
|
19082
|
-
|
|
19083
|
-
|
|
19084
|
-
|
|
19085
|
-
|
|
19086
|
-
|
|
19087
|
-
|
|
19088
|
-
|
|
19089
|
-
|
|
19090
|
-
|
|
19091
|
-
|
|
19092
|
-
|
|
19093
|
-
|
|
19094
|
-
|
|
19095
|
-
|
|
19096
|
-
|
|
19097
|
-
|
|
19098
|
-
|
|
19099
|
-
|
|
19100
|
-
|
|
19101
|
-
|
|
19102
|
-
|
|
19103
|
-
|
|
19104
|
-
|
|
19105
|
-
|
|
19106
|
-
|
|
19107
|
-
|
|
19108
|
-
|
|
19109
|
-
|
|
19110
|
-
|
|
19111
|
-
|
|
19112
|
-
|
|
19113
|
-
|
|
19114
|
-
|
|
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
|
-
|
|
19118
|
-
|
|
19119
|
-
|
|
19120
|
-
|
|
19121
|
-
|
|
19122
|
-
|
|
19123
|
-
|
|
19124
|
-
|
|
19125
|
-
|
|
19126
|
-
|
|
19127
|
-
|
|
19128
|
-
|
|
19129
|
-
|
|
19130
|
-
|
|
19131
|
-
|
|
19132
|
-
|
|
19133
|
-
|
|
19134
|
-
|
|
19135
|
-
|
|
19136
|
-
|
|
19137
|
-
|
|
19138
|
-
|
|
19139
|
-
|
|
19140
|
-
|
|
19141
|
-
|
|
19142
|
-
|
|
19143
|
-
|
|
19144
|
-
|
|
19145
|
-
|
|
19146
|
-
|
|
19147
|
-
|
|
19148
|
-
|
|
19149
|
-
|
|
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
|
-
|
|
19213
|
-
|
|
19214
|
-
|
|
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 (
|
|
19217
|
-
|
|
19218
|
-
|
|
19219
|
-
|
|
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
|
-
|
|
19232
|
-
if (card)
|
|
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
|
-
|
|
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 (
|
|
19256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19398
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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(> #
|
|
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
|
|
961
|
-
/*
|
|
962
|
-
cards use, not another accent
|
|
963
|
-
|
|
964
|
-
.
|
|
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
|
-
/*
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
animation:
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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;
|