---
/**
* SelectionActions: select text and a floating toolbar rewrites it in place.
* Running an action shimmers a busy label, streams the rewrite, then asks
* to Keep, Discard or Try again.
*
* Ported from Beautiful UI `components/primitives/SelectionActions.tsx`
* (MIT, © 2026 Shane Levine). Icons are Lucide (ISC) equivalents.
*
* The passage is a list of segments (usually sentences). Mouse selection,
* tapping a sentence, or ←/→ (Shift to extend) snaps to whole segments.
* Tab enters the toolbar; Escape returns to the passage.
*
* Rewrites come from each segment's `rewrites` (keyed by action or "custom")
* or `rewrite`. To produce your own, listen for `vs-selection-action` and
* call `event.detail.respondWith(stringOrPromise)` synchronously.
*
* Events (bubbling, on the root):
* vs-selection-action detail: { action, prompt?, text, rewrite: boolean, respondWith(text | Promise<text>) }
* vs-selection-keep detail: { original, text }
* vs-selection-discard detail: { original }
*
* @example
* <SelectionActions passage={[{ text: "Lead with the offer. " }, { text: "Then add proof.", rewrite: "Follow with one proof point." }]} selected={1} />
*/
import type { HTMLAttributes } from "astro/types";
import Button from "../button/Button.astro";
import Shimmer from "../shimmer/Shimmer.astro";
export interface SelectionSegment {
text: string;
/** Rewrite per action name (e.g. "Improve", "Shorten", "Change tone", "Fix grammar", or "custom"). */
rewrites?: Record<string, string>;
/** Fallback rewrite for any action. */
rewrite?: string;
}
export interface SelectionAction {
/** Visible label. */
id: string;
/** Built-in icon (explain, improve, shorten, tone, grammar) or inner SVG markup. */
icon?: string;
/** Action name sent with the event and used to pick a rewrite. Omit for actions that don't rewrite (e.g. Explain). */
action?: string;
/** Gerund shown while it runs, e.g. "Improving". */
busyLabel?: string;
}
export interface SelectionActionSet {
primary: SelectionAction[];
more: SelectionAction[];
}
export interface SelectionLabels {
keep: string;
discard: string;
placeholder: string;
toolbar: string;
}
type Props = HTMLAttributes<"div"> & {
passage: SelectionSegment[];
/** Initially selected segment index, or an inclusive [start, end] range. */
selected?: number | [number, number];
actions?: SelectionActionSet;
labels?: Partial<SelectionLabels>;
/** Show the toolbar for the initial selection once in view. */
autoShow?: boolean;
};
const DEFAULT_ACTIONS: SelectionActionSet = {
primary: [
{ id: "Explain", icon: "explain" },
{ id: "Improve", icon: "improve", action: "Improve", busyLabel: "Improving" },
],
more: [
{ id: "Shorten", icon: "shorten", action: "Shorten", busyLabel: "Shortening" },
{ id: "Tone", icon: "tone", action: "Change tone", busyLabel: "Changing tone" },
{ id: "Grammar", icon: "grammar", action: "Fix grammar" },
],
};
const {
passage,
selected = 0,
actions = DEFAULT_ACTIONS,
labels = {},
autoShow = true,
class: className,
...rest
} = Astro.props;
const copy: SelectionLabels = { keep: "Keep", discard: "Discard", placeholder: "Describe edits", toolbar: "Edit selection", ...labels };
const uid = `vs-sa-${Math.random().toString(36).slice(2, 8)}`;
const [selStart, selEnd] = Array.isArray(selected) ? selected : [selected, selected];
/* Lucide (ISC) */
const ICONS: Record<string, string> = {
explain: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" />',
improve: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" /><path d="M20 3v4" /><path d="M22 5h-4" /><path d="M4 17v2" /><path d="M5 18H3" />',
shorten: '<circle cx="6" cy="6" r="3" /><path d="M8.12 8.12 12 12" /><path d="M20 4 8.12 15.88" /><circle cx="6" cy="18" r="3" /><path d="M14.8 14.8 20 20" />',
tone: '<circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><path d="M9 9h.01" /><path d="M15 9h.01" />',
grammar: '<path d="m6 16 6-12 6 12" /><path d="M8 12h8" /><path d="m16 20 2 2 4-4" />',
send: '<path d="m5 12 7-7 7 7" /><path d="M12 19V5" />',
chevron: '<path d="m9 18 6-6-6-6" />',
check: '<path d="M20 6 9 17l-5-5" />',
close: '<path d="M18 6 6 18" /><path d="m6 6 12 12" />',
retry: '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />',
};
const icon = (name: string | undefined, size = 14, sw = 1.8) => {
const inner = !name ? "" : name.includes("<") ? name : (ICONS[name] ?? "");
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
};
const clientConfig = {
segments: passage.map((s) => ({ text: s.text, rewrites: s.rewrites ?? {}, rewrite: s.rewrite ?? null })),
selected: [selStart, selEnd],
busy: Object.fromEntries([...actions.primary, ...actions.more].filter((a) => a.action).map((a) => [a.action, a.busyLabel ?? "Editing"])),
autoShow,
};
---
<div class:list={["vs-selection-actions", className]} {...rest}>
<script type="application/json" data-vs-config set:html={JSON.stringify(clientConfig)} />
<div class="sa-host" data-host>
<p class="sa-passage" data-passage aria-describedby={`${uid}-hint`}>
{
passage.map((seg, i) => {
if (i === selStart) {
return (
<span class="sa-sel" data-sel>{passage.slice(selStart, selEnd + 1).map((s, j) => <span class="sa-seg" data-seg={selStart + j}>{s.text}</span>)}</span>
);
}
if (i > selStart && i <= selEnd) return null;
return <span class="sa-seg" data-seg={i}>{seg.text}</span>;
})
}
</p>
<p class="sa-sr" id={`${uid}-hint`}>Select text, or use the arrow keys to choose a sentence (Shift extends), then press Tab to reach the edit toolbar.</p>
<div class="sa-anchor" data-anchor hidden>
<div class="sa-bar" role="toolbar" aria-label={copy.toolbar} data-bar data-mode="idle">
<div class="sa-content" data-content>
<span class="sa-busy" data-busy hidden>
<span class="sa-spinner" aria-hidden="true"></span>
<Shimmer class="sa-busy-label" data-busy-shimmer>Improving…</Shimmer>
<span class="sa-busy-label" data-busy-plain hidden>Improving…</span>
</span>
<span class="sa-result" data-result hidden>
<button type="button" class="sa-keep" data-keep>
<Fragment set:html={icon("check")} />
{copy.keep}
</button>
<Button variant="quiet" size="xs" class="sa-btn" data-discard>
<Fragment set:html={icon("close")} />
{copy.discard}
</Button>
<span class="sa-sep" aria-hidden="true"></span>
<button type="button" class="sa-round" data-retry aria-label="Try again">
<Fragment set:html={icon("retry")} />
</button>
</span>
<span class="sa-idle" data-idle>
<span class="sa-prompt-wrap" data-prompt-wrap>
<form class="sa-form" data-form>
<input class="sa-input" data-input aria-label={copy.placeholder} placeholder={copy.placeholder} autocomplete="off" />
</form>
</span>
<span class="sa-actions-wrap" data-actions-wrap>
<span class="sa-sep sa-sep--strong" data-lead-sep aria-hidden="true"></span>
{
actions.primary.map((a) => (
<Button variant="quiet" size="xs" class="sa-btn" data-act={a.action ?? ""} data-id={a.id}>
<Fragment set:html={icon(a.icon)} />
{a.id}
</Button>
))
}
<span class="sa-more" data-more inert>
{
actions.more.map((a) => (
<Button variant="quiet" size="xs" class="sa-btn" data-act={a.action ?? ""} data-id={a.id}>
<Fragment set:html={icon(a.icon)} />
{a.id}
</Button>
))
}
</span>
<span class="sa-sep" aria-hidden="true"></span>
<button type="button" class="sa-round sa-round--ink" data-expand aria-label="Show more actions" aria-expanded="false">
<span class="sa-chevron" set:html={icon("chevron")} />
</button>
</span>
<span class="sa-send-wrap" data-send-wrap inert>
<button type="button" class="sa-send" data-send aria-label="Send edit instruction">
<Fragment set:html={icon("send", 16, 2.4)} />
</button>
</span>
</span>
</div>
</div>
</div>
</div>
<p class="sa-sr" aria-live="polite" data-live></p>
</div>
<script>
import { streamText, type StreamTextHandle } from "../stream-text/stream-text";
interface Segment {
text: string;
rewrites: Record<string, string>;
rewrite: string | null;
}
interface Config {
segments: Segment[];
selected: [number, number];
busy: Record<string, string>;
autoShow: boolean;
}
type Mode = "idle" | "thinking" | "streaming" | "result";
const EASE = "cubic-bezier(0.23, 1, 0.32, 1)";
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function init(root: HTMLElement) {
const cfg = JSON.parse(root.querySelector("script[data-vs-config]")?.textContent || "{}") as Config;
const segments = cfg.segments.map((s) => ({ ...s }));
let [start, end] = cfg.selected;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const q = <T extends HTMLElement = HTMLElement>(sel: string) => root.querySelector<T>(sel)!;
const host = q("[data-host]");
const passageEl = q("[data-passage]");
const anchor = q("[data-anchor]");
const bar = q("[data-bar]");
const content = q("[data-content]");
const busyEl = q("[data-busy]");
const busyShimmer = q("[data-busy-shimmer]");
const busyPlain = q("[data-busy-plain]");
const resultEl = q("[data-result]");
const idleEl = q("[data-idle]");
const promptWrap = q("[data-prompt-wrap]");
const form = q<HTMLFormElement>("[data-form]");
const input = q<HTMLInputElement>("[data-input]");
const actionsWrap = q("[data-actions-wrap]");
const leadSep = q("[data-lead-sep]");
const more = q("[data-more]");
const expandBtn = q<HTMLButtonElement>("[data-expand]");
const sendWrap = q("[data-send-wrap]");
const live = q("[data-live]");
let mode: Mode = "idle";
let action = "Improve";
let pendingText = "";
let original: Segment[] = [];
let stream: StreamTextHandle | null = null;
let thinkTimer: number | undefined;
let showTimer: number | undefined;
let frame = 0;
let widthAnim: Animation | null = null;
let shown = false;
let runId = 0;
passageEl.tabIndex = 0;
/* ── render the passage with the selection wrapped in one span ── */
const selEl = () => passageEl.querySelector<HTMLElement>("[data-sel]");
const renderPassage = () => {
let html = "";
segments.forEach((s, i) => {
const seg = `<span class="sa-seg" data-seg="${i}">${escapeHtml(s.text)}</span>`;
if (i === start) html += '<span class="sa-sel" data-sel>';
html += seg;
if (i === end) html += "</span>";
});
passageEl.innerHTML = html;
place();
};
const selectedText = () => segments.slice(start, end + 1).map((s) => s.text).join("");
const rewriteFor = (name: string) =>
segments
.slice(start, end + 1)
.map((s) => s.rewrites[name] ?? s.rewrite ?? s.text)
.join("");
/* ── placement: centred on the selection, under its last line ── */
function place() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const sel = selEl();
if (!sel) return;
const lines = Array.from(sel.getClientRects());
const last = lines.at(-1);
const first = lines[0];
if (!last || !first) return;
const bounds = sel.getBoundingClientRect();
const hostRect = host.getBoundingClientRect();
const w = bar.offsetWidth;
const h = bar.offsetHeight || 36;
let x = bounds.left - hostRect.left + bounds.width / 2;
const minX = 8 - hostRect.left + w / 2;
const maxX = window.innerWidth - 8 - hostRect.left - w / 2;
x = minX > maxX ? (minX + maxX) / 2 : Math.min(Math.max(x, minX), maxX);
let y = last.bottom - hostRect.top + 8;
if (last.bottom + 8 + h > window.innerHeight - 8 && first.top - 8 - h > 8) y = first.top - hostRect.top - 8 - h;
anchor.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0) translateX(-50%)`;
});
}
/* ── modes + the width morph between them ──────────── */
const setMode = (next: Mode) => {
const previous = mode;
const fromWidth = bar.getBoundingClientRect().width;
mode = next;
bar.dataset.mode = next;
busyEl.hidden = !(next === "thinking" || next === "streaming");
busyShimmer.hidden = next !== "thinking";
busyPlain.hidden = next !== "streaming";
resultEl.hidden = next !== "result";
idleEl.hidden = next !== "idle";
const toWidth = Math.ceil(content.getBoundingClientRect().width) + 8;
if (shown && previous !== next && Math.abs(toWidth - fromWidth) > 1 && !reduce) {
widthAnim?.cancel();
widthAnim = bar.animate([{ width: `${fromWidth}px` }, { width: `${toWidth}px` }], { duration: 320, easing: EASE });
}
place();
};
const setExpanded = (open: boolean) => {
bar.toggleAttribute("data-expanded", open);
expandBtn.setAttribute("aria-expanded", String(open));
expandBtn.setAttribute("aria-label", open ? "Show fewer actions" : "Show more actions");
more.inert = !open;
promptWrap.inert = open;
leadSep.hidden = open;
place();
};
let typingWidth = 0;
const setTyping = (typing: boolean) => {
if (typing && !typingWidth) {
typingWidth = Math.ceil(bar.getBoundingClientRect().width);
bar.style.setProperty("--sa-typing", `${typingWidth}px`);
bar.toggleAttribute("data-typing", true);
} else if (!typing && typingWidth) {
typingWidth = 0;
bar.style.removeProperty("--sa-typing");
bar.toggleAttribute("data-typing", false);
}
actionsWrap.inert = typing;
sendWrap.inert = !typing;
};
/* ── running an action ─────────────────────────────── */
const run = (name: string, prompt?: string) => {
if (mode === "result") commit(false);
const id = ++runId;
action = name;
setExpanded(false);
original = segments.slice(start, end + 1).map((s) => ({ ...s }));
const text = selectedText();
let response: Promise<string> | null = null;
root.dispatchEvent(
new CustomEvent("vs-selection-action", {
bubbles: true,
detail: {
action: name,
prompt,
text,
rewrite: true,
respondWith: (value: string | Promise<string>) => {
response = Promise.resolve(value);
},
},
}),
);
const label = cfg.busy[name] ?? "Editing";
busyShimmer.textContent = `${label}…`;
busyPlain.textContent = `${label}…`;
live.textContent = `${label}…`;
setMode("thinking");
const fallback = rewriteFor(prompt ? "custom" : name);
const minWait = new Promise<void>((resolve) => (thinkTimer = window.setTimeout(resolve, reduce ? 0 : 700)));
Promise.all([response ?? Promise.resolve(fallback), minWait])
.then(([rewrite]) => id === runId && startStream(rewrite))
.catch(() => {
if (id !== runId) return;
live.textContent = "Couldn't rewrite the selection.";
setMode("idle");
});
};
const startStream = (rewrite: string) => {
setMode("streaming");
pendingText = rewrite;
const sel = selEl();
if (!sel) return;
passageEl.setAttribute("aria-busy", "true");
stream?.stop();
stream = streamText(sel, rewrite, {
charsPerTick: 2,
tickMs: 9,
onProgress: place,
onDone: () => {
passageEl.removeAttribute("aria-busy");
// Settle into plain text: no caret once the result is offered.
sel.textContent = rewrite;
setMode("result");
live.textContent = `Rewrite ready: ${rewrite} Keep or discard it.`;
},
});
};
/** Commit (keep) or revert (discard) the streamed rewrite. */
const commit = (keep: boolean) => {
runId += 1;
stream?.stop();
stream = null;
window.clearTimeout(thinkTimer);
passageEl.removeAttribute("aria-busy");
if (keep && pendingText) {
segments.splice(start, end - start + 1, { text: pendingText, rewrites: {}, rewrite: null });
end = start;
root.dispatchEvent(new CustomEvent("vs-selection-keep", { bubbles: true, detail: { original: original.map((s) => s.text).join(""), text: pendingText } }));
} else if (original.length) {
root.dispatchEvent(new CustomEvent("vs-selection-discard", { bubbles: true, detail: { original: original.map((s) => s.text).join("") } }));
}
pendingText = "";
original = [];
renderPassage();
};
const reset = (keep: boolean) => {
commit(keep);
input.value = "";
setTyping(false);
setExpanded(false);
setMode("idle");
live.textContent = keep ? "Rewrite kept." : "Rewrite discarded.";
focusFirst();
};
/* ── wiring ────────────────────────────────────────── */
root.querySelectorAll<HTMLButtonElement>("[data-act]").forEach((btn) => {
btn.addEventListener("click", () => {
const name = btn.dataset.act;
if (name) run(name);
else root.dispatchEvent(new CustomEvent("vs-selection-action", { bubbles: true, detail: { action: btn.dataset.id, text: selectedText(), rewrite: false, respondWith: () => {} } }));
});
});
expandBtn.addEventListener("click", () => setExpanded(!bar.hasAttribute("data-expanded")));
input.addEventListener("input", () => setTyping(input.value.trim().length > 0));
form.addEventListener("submit", (e) => {
e.preventDefault();
const prompt = input.value.trim();
if (prompt) run(prompt, prompt);
else run("Improve");
});
q("[data-send]").addEventListener("click", () => {
const prompt = input.value.trim();
if (prompt) run(prompt, prompt);
});
q("[data-keep]").addEventListener("click", () => reset(true));
q("[data-discard]").addEventListener("click", () => reset(false));
q("[data-retry]").addEventListener("click", () => {
const name = action;
commit(false);
run(name, cfg.busy[name] ? undefined : name);
});
/* toolbar keyboard: ←/→ between controls, Escape back to the passage */
const controls = () =>
Array.from(bar.querySelectorAll<HTMLElement>("button, input")).filter(
(el) => !el.closest("[inert], [hidden]") && el.getClientRects().length > 0,
);
function focusFirst() {
requestAnimationFrame(() => controls()[0]?.focus());
}
bar.addEventListener("keydown", (e) => {
const target = e.target as HTMLElement;
if (e.key === "Escape") {
e.preventDefault();
if (bar.hasAttribute("data-expanded")) setExpanded(false);
else passageEl.focus();
return;
}
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
if (target === input) {
const atStart = input.selectionStart === 0 && input.selectionEnd === 0;
const atEnd = input.selectionStart === input.value.length;
if ((e.key === "ArrowLeft" && !atStart) || (e.key === "ArrowRight" && !atEnd)) return;
}
const list = controls();
const i = list.indexOf(target);
if (i === -1) return;
e.preventDefault();
list[(i + (e.key === "ArrowRight" ? 1 : -1) + list.length) % list.length]?.focus();
});
/* ── choosing a selection ──────────────────────────── */
const select = (a: number, b: number) => {
if (mode === "thinking" || mode === "streaming") return;
if (mode === "result") {
commit(true);
setMode("idle");
}
start = Math.max(0, Math.min(a, b));
end = Math.min(segments.length - 1, Math.max(a, b));
renderPassage();
show();
live.textContent = `Selected: ${selectedText()}`;
};
const segIndex = (node: Node | null) => {
const el = node instanceof Element ? node : node?.parentElement;
const seg = el?.closest<HTMLElement>("[data-seg]");
return seg && passageEl.contains(seg) ? Number(seg.dataset.seg) : -1;
};
passageEl.addEventListener("pointerup", (e) => {
// Let the browser settle the selection first.
requestAnimationFrame(() => {
const s = window.getSelection();
if (s && !s.isCollapsed && s.rangeCount) {
const range = s.getRangeAt(0);
if (!passageEl.contains(range.commonAncestorContainer)) return;
const hits = Array.from(passageEl.querySelectorAll<HTMLElement>("[data-seg]"))
.filter((seg) => range.intersectsNode(seg) && range.toString().trim().length > 0)
.map((seg) => Number(seg.dataset.seg));
// Drop segments the range only grazes at its very edge.
let a = segIndex(range.startContainer);
let b = segIndex(range.endContainer);
if (range.endOffset === 0 && b > a) b -= 1;
if (a === -1 || b === -1) [a, b] = [Math.min(...hits), Math.max(...hits)];
s.removeAllRanges();
if (Number.isFinite(a) && Number.isFinite(b)) select(a, b);
return;
}
const i = segIndex(e.target as Node);
if (i !== -1) select(i, i);
});
});
passageEl.addEventListener("keydown", (e) => {
const last = segments.length - 1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
if (e.shiftKey) select(start, Math.min(last, end + 1));
else select(Math.min(last, end + 1), Math.min(last, end + 1));
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
if (e.shiftKey) select(Math.max(0, start - 1), end > start ? end - 1 : end);
else select(Math.max(0, start - 1), Math.max(0, start - 1));
} else if (e.key === "Home") {
e.preventDefault();
select(0, 0);
} else if (e.key === "End") {
e.preventDefault();
select(last, last);
}
});
/* ── appearance ────────────────────────────────────── */
function show() {
if (shown) return;
shown = true;
anchor.hidden = false;
place();
requestAnimationFrame(() => {
anchor.classList.add("is-visible");
if (!reduce) bar.classList.add("is-popping");
});
}
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && cfg.autoShow && !shown) {
showTimer = window.setTimeout(show, reduce ? 0 : 280);
io.disconnect();
}
});
io.observe(root);
const ro = new ResizeObserver(place);
ro.observe(host);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, { passive: true, capture: true });
renderPassage();
setMode("idle");
setExpanded(false);
setTyping(false);
return () => {
runId += 1;
stream?.stop();
window.clearTimeout(thinkTimer);
window.clearTimeout(showTimer);
cancelAnimationFrame(frame);
widthAnim?.cancel();
io.disconnect();
ro.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, { capture: true });
};
}
const teardowns = new Set<() => void>();
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-selection-actions:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
teardowns.add(init(root));
});
boot();
document.addEventListener("astro:page-load", boot);
document.addEventListener("astro:before-swap", () => {
teardowns.forEach((fn) => fn());
teardowns.clear();
});
</script>
<style>
.vs-selection-actions {
width: 100%;
max-width: 460px;
font-family: var(--vs-font-sans);
text-align: left;
}
.sa-host {
position: relative;
padding-bottom: 48px;
}
.sa-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sa-passage {
margin: 0;
border-radius: 4px;
color: var(--vs-ink);
font-size: 13px;
line-height: 1.625;
cursor: text;
-webkit-user-select: text;
user-select: text;
}
.sa-passage:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
.sa-passage :global(.sa-sel) {
border-radius: 3px;
background: color-mix(in srgb, var(--vs-accent) 14%, var(--vs-surface));
color: var(--vs-ink);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
:global(:root[data-theme="dark"]) .sa-passage :global(.sa-sel) {
background: var(--vs-accent-tint);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .sa-passage :global(.sa-sel) {
background: var(--vs-accent-tint);
}
}
/* ── the floating bar ───────────────────────────────── */
.sa-anchor {
position: absolute;
z-index: 10;
top: 0;
left: 0;
opacity: 0;
pointer-events: none;
will-change: transform;
transition:
transform 320ms var(--vs-ease-in-out),
opacity 180ms ease-out;
}
.sa-anchor.is-visible {
opacity: 1;
pointer-events: auto;
}
/* A 36px pill wraps 28px controls at a 4px inset: concentric 14px radii. */
.sa-bar {
display: flex;
width: fit-content;
max-width: calc(100vw - 48px);
height: 36px;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px;
overflow: hidden;
border-radius: var(--vs-radius-pill);
background: var(--vs-surface);
color: var(--vs-ink);
box-shadow: var(--vs-shadow-overlay);
font-weight: 400;
}
.sa-bar.is-popping {
animation: vs-pop-in 220ms var(--vs-ease-out) both;
}
.sa-bar[data-typing] {
width: var(--sa-typing);
}
.sa-content {
display: flex;
width: fit-content;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 2px;
}
.sa-bar[data-typing] .sa-content {
width: calc(var(--sa-typing) - 8px);
}
.sa-idle,
.sa-result {
display: flex;
align-items: center;
gap: 2px;
}
.sa-busy {
display: inline-flex;
height: 28px;
align-items: center;
gap: 6px;
padding: 0 10px;
color: var(--vs-ink-2);
font-size: 12.5px;
white-space: nowrap;
}
.sa-spinner {
width: 12px;
height: 12px;
flex-shrink: 0;
border: 1.5px solid var(--vs-line-strong);
border-top-color: var(--vs-ink-2);
border-radius: 50%;
animation: vs-spin 700ms linear infinite;
}
.sa-busy :global(.sa-busy-label) {
font-size: 12.5px;
font-weight: 400;
}
button {
font: inherit;
border: 0;
padding: 0;
cursor: pointer;
}
button:focus-visible,
.sa-input:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
.sa-bar :global(.sa-btn) {
flex-shrink: 0;
}
.sa-keep {
display: inline-flex;
height: 28px;
flex-shrink: 0;
align-items: center;
gap: 4px;
padding: 0 10px;
border-radius: var(--vs-radius-pill);
background: var(--vs-ink);
color: var(--vs-canvas);
box-shadow: var(--vs-shadow-hairline);
font-size: 12.5px;
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
.sa-keep:hover {
opacity: 0.9;
}
.sa-keep:active {
transform: scale(0.96);
}
.sa-sep {
width: 1px;
height: 16px;
flex-shrink: 0;
margin: 0 2px;
background: var(--vs-line);
}
.sa-sep--strong {
margin: 0 4px;
background: var(--vs-line-strong);
}
.sa-round {
display: flex;
width: 28px;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--vs-radius-pill);
background: transparent;
color: var(--vs-ink-3);
transition: background-color 150ms ease-out, color 150ms ease-out, transform 150ms ease-out;
}
.sa-round:hover {
background: var(--vs-hover-2);
color: var(--vs-ink-2);
}
.sa-round--ink {
color: var(--vs-ink);
}
.sa-round--ink:hover {
background: var(--vs-hover);
color: var(--vs-ink);
}
.sa-round:active {
transform: scale(0.96);
}
.sa-chevron {
display: flex;
transition: transform 400ms var(--vs-ease-out);
}
.sa-bar[data-expanded] .sa-chevron {
transform: rotate(180deg);
}
/* idle: prompt field · actions (+ more) · send */
.sa-prompt-wrap,
.sa-actions-wrap,
.sa-more,
.sa-send-wrap {
display: flex;
min-width: 0;
align-items: center;
overflow: hidden;
transition-duration: 400ms;
transition-timing-function: var(--vs-ease-out);
}
.sa-prompt-wrap {
max-width: 145px;
transition-property: max-width, opacity, transform;
}
.sa-bar[data-typing] .sa-prompt-wrap {
max-width: calc(var(--sa-typing) - 40px);
}
.sa-bar[data-expanded] .sa-prompt-wrap {
max-width: 0;
opacity: 0;
transform: translateX(-8px);
}
.sa-form {
display: flex;
width: 145px;
height: 28px;
flex-shrink: 0;
align-items: center;
margin: 0;
transition: width 400ms var(--vs-ease-out);
}
.sa-bar[data-typing] .sa-form {
width: calc(var(--sa-typing) - 40px);
}
.sa-input {
width: 100%;
height: 28px;
padding: 0 10px 0 12px;
border: 0;
border-radius: var(--vs-radius-pill);
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 12.5px;
}
.sa-input::placeholder {
color: var(--vs-ink-3);
}
.sa-actions-wrap {
gap: 2px;
max-width: 224px;
transition-property: max-width, opacity, transform;
}
.sa-bar[data-expanded] .sa-actions-wrap {
max-width: 462px;
}
.sa-bar[data-typing] .sa-actions-wrap {
max-width: 0;
opacity: 0;
transform: translateX(-8px);
}
.sa-more {
gap: 2px;
max-width: 0;
margin-left: 0;
opacity: 0;
transition-property: max-width, opacity, margin;
}
.sa-bar[data-expanded] .sa-more {
max-width: 262px;
margin-left: 2px;
opacity: 1;
}
.sa-send-wrap {
max-width: 0;
opacity: 0;
transform: scale(0.88);
transition-property: max-width, opacity, transform;
}
.sa-bar[data-typing] .sa-send-wrap {
max-width: 30px;
opacity: 1;
transform: scale(1);
}
.sa-send {
display: flex;
width: 28px;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--vs-radius-pill);
background: var(--vs-ink);
color: var(--vs-surface);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
.sa-send:active {
transform: scale(0.94);
}
@media (prefers-reduced-motion: reduce) {
.sa-anchor,
.sa-prompt-wrap,
.sa-actions-wrap,
.sa-more,
.sa-send-wrap,
.sa-form,
.sa-chevron {
transition: none;
}
.sa-bar.is-popping {
animation: none;
}
.sa-spinner {
animation: none;
border-top-color: var(--vs-line-strong);
}
}
@media (forced-colors: active) {
.sa-bar {
outline: 1px solid CanvasText;
}
.sa-passage :global(.sa-sel) {
forced-color-adjust: none;
background: Highlight;
color: HighlightText;
}
}
</style>