Layout
Astro UI
Fine-tune Card.
Compact inspector the agent adjusts, then hands back to you.
AgentComponent1 dependency Beautiful UI, MIT
Install
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import FineTuneCard from "../components/viewership/fine-tune-card/FineTuneCard.astro"; --- <FineTuneCard />
Props 8
| Prop | Type | Default | Description |
|---|---|---|---|
fields | { key; label; name?; value; min; max; step?; suffix? }[] | · | Scrub-able number fields in pairs. `name` is the full accessible name when label is short. |
options | string[] | · | Type menu options. |
layout | 0 | 1 | 2 | 0 | Initial layout: row, column, grid. |
type | string | · | Initial type. Empty shows the placeholder. |
edits | { field?: string; value?: number; layout?: number; type?: string }[] | [] | Changes the agent makes, in order, when scrolled into view. Interaction takes over. |
autoplay | boolean | true | Play edits on scroll into view. |
loop | boolean | false | Replay after a 4s hold until interaction. |
labels | Partial<FineTuneCardLabels> | · | Text overrides: title, layout, type, placeholder, adjust, edited, layouts. |
Events
| Event | Detail | When |
|---|---|---|
vs-fine-tune-change | CustomEvent<{ layout; layoutName; values; type; source: "user" | "agent" }> | Fires on every change, by you or the agent. |
Source 1 file
---
/**
* FineTuneCard: a compact inspector the agent (and you) can adjust.
*
* Adapted from Beautiful UI FineTuneCard (MIT, © 2026 Shane Levine).
*
* A layout switch (row / column / grid), scrub-able number fields and a type
* menu. Drag a field's label sideways, use up/down arrows (Shift for x10), or
* type. Changed fields turn accent and the header flips from "Adjust" to
* "Edited".
*
* Pass `edits` to have the agent make changes when the card scrolls into
* view. Any interaction hands control back to you. Without JS or with
* reduced motion, the edits render already applied.
*
* Number fields are labelled text inputs with spinbutton semantics, the
* layout switch is a radio group, and the type menu is a listbox. Each has a
* `name` so the card can sit inside a form.
*
* Events (bubbling, on the root):
* vs-fine-tune-change detail: { layout, layoutName, values, type, source: "user" | "agent" }
*
* @example
* <FineTuneCard fields={[{ key: "radius", label: "Radius", value: 12, min: 0, max: 48 }]} options={["Standard", "Launch"]} />
*/
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";
export interface FineTuneField {
key: string;
/** Visible label (the scrub handle), e.g. "W". */
label: string;
/** Full accessible name when the label is abbreviated, e.g. "Width". */
name?: string;
/** Starting value; the field turns accent once it differs. */
value: number;
min: number;
max: number;
step?: number;
suffix?: string;
}
/** One agent step: set a field, the layout, or the type. */
export interface FineTuneEdit {
/** Field key to change (with `value`). */
field?: string;
value?: number;
/** Layout index: 0 row, 1 column, 2 grid. */
layout?: number;
/** Type option to pick. */
type?: string;
}
export interface FineTuneCardLabels {
title: string;
layout: string;
type: string;
placeholder: string;
adjust: string;
edited: string;
/** Names of the three layout options. */
layouts: [string, string, string];
}
type Props = HTMLAttributes<"div"> & {
fields: FineTuneField[];
/** Options in the Type menu. */
options: string[];
labels?: Partial<FineTuneCardLabels>;
/** Initial layout index. */
layout?: number;
/** Initial type (defaults to the placeholder). */
type?: string;
/** Changes the agent makes on its own. */
edits?: FineTuneEdit[];
/** Play `edits` when scrolled into view. */
autoplay?: boolean;
/** Replay the edits after a hold, until someone interacts. */
loop?: boolean;
};
const {
fields = [],
options = [],
labels,
layout = 0,
type,
edits = [],
autoplay = true,
loop = false,
class: className,
...rest
} = Astro.props;
const l: FineTuneCardLabels = {
title: "Card",
layout: "Layout",
type: "Type",
placeholder: "Select type",
adjust: "Adjust",
edited: "Edited",
layouts: ["Row", "Column", "Grid"],
...labels,
};
const uid = `vs-ftc-${Math.random().toString(36).slice(2, 8)}`;
/* The no-JS / reduced-motion render shows the agent's edits already applied. */
const initial = {
layout,
type: type ?? "",
values: Object.fromEntries(fields.map((f) => [f.key, f.value])) as Record<string, number>,
};
const final = { layout: initial.layout, type: initial.type, values: { ...initial.values } };
for (const e of edits) {
if (e.field !== undefined && e.value !== undefined && e.field in final.values) final.values[e.field] = e.value;
if (e.layout !== undefined) final.layout = e.layout;
if (e.type !== undefined) final.type = e.type;
}
const changed =
final.layout !== layout || final.type !== initial.type || fields.some((f) => final.values[f.key] !== f.value);
const pairs: FineTuneField[][] = [];
for (let i = 0; i < fields.length; i += 2) pairs.push(fields.slice(i, i + 2));
const config = { initial, edits, layouts: l.layouts, placeholder: l.placeholder };
---
<div
class:list={["vs-fine-tune-card", className]}
data-autoplay={autoplay && edits.length ? "" : undefined}
data-loop={loop ? "" : undefined}
data-changed={changed ? "" : undefined}
{...rest}
>
<script is:inline type="application/json" data-vs-config set:html={JSON.stringify(config).replace(/</g, "\\u003c")} />
<div class="bar">
<span class="title">{l.title}</span>
<span class="status" aria-live="polite">
<span class="adjust">
<span class="spark" aria-hidden="true">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z"></path></svg>
</span>
<span class="adjust-text">{l.adjust}</span>
</span>
<span class="edited">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"></path></svg>
{l.edited}
</span>
</span>
</div>
<div class="section">
<p class="section-title" id={`${uid}-layout`}>{l.layout}</p>
<div class="seg" role="radiogroup" aria-labelledby={`${uid}-layout`} style={`--vs-ftc-seg: ${final.layout}`} data-seg>
<span class="seg-thumb" aria-hidden="true"></span>
{[0, 1, 2].map((i) => (
<button
type="button"
role="radio"
class="seg-btn"
aria-checked={i === final.layout ? "true" : "false"}
aria-label={l.layouts[i]}
tabindex={i === final.layout ? 0 : -1}
data-seg-option={i}
>
{i === 0 && <span class="glyph glyph--row" aria-hidden="true"><span></span><span></span><span></span></span>}
{i === 1 && <span class="glyph glyph--col" aria-hidden="true"><span></span><span></span></span>}
{i === 2 && <span class="glyph glyph--grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>}
</button>
))}
<input type="hidden" name="layout" value={l.layouts[final.layout]} data-seg-input />
</div>
{pairs.map((pair) => (
<div class="pair">
{pair.map((f) => {
const id = `${uid}-${f.key}`;
const v = final.values[f.key] ?? f.value;
return (
<div
class="field"
data-field={f.key}
data-default={f.value}
data-min={f.min}
data-max={f.max}
data-step={f.step ?? 1}
data-suffix={f.suffix ?? ""}
data-active={v !== f.value ? "" : undefined}
>
<label class="handle" for={id} data-handle>
{f.label}
{f.name && f.name !== f.label && <span class="vh">, {f.name}</span>}
</label>
<input
class="num"
id={id}
name={f.key}
type="text"
inputmode="numeric"
role="spinbutton"
autocomplete="off"
aria-valuemin={f.min}
aria-valuemax={f.max}
aria-valuenow={v}
aria-valuetext={f.suffix ? `${v}${f.suffix}` : undefined}
value={String(v)}
/>
{f.suffix && <span class="suffix" aria-hidden="true">{f.suffix}</span>}
</div>
);
})}
</div>
))}
</div>
<div class="footer">
<span class="footer-label" id={`${uid}-type`}>{l.type}</span>
<div class="select" data-select>
<button
type="button"
class="trigger"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls={`${uid}-menu`}
aria-labelledby={`${uid}-type ${uid}-value`}
data-trigger
>
<span class="value" id={`${uid}-value`} data-placeholder={final.type ? undefined : ""} data-value>{final.type || l.placeholder}</span>
<svg class="chevron" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg>
</button>
<input type="hidden" name="type" value={final.type} data-type-input />
<div class="menu" hidden data-menu>
<GlideMenu class="menu-list" role="listbox" id={`${uid}-menu`} aria-labelledby={`${uid}-type`} highlightBg="var(--vs-field)" highlightRadius="6px" arrowKeys>
{options.map((o) => (
<div class="opt" role="option" tabindex="-1" aria-selected={o === final.type ? "true" : "false"} data-menu-row data-option={o}>
{o}
</div>
))}
</GlideMenu>
</div>
</div>
</div>
</div>
<script>
interface Edit {
field?: string;
value?: number;
layout?: number;
type?: string;
}
interface Config {
initial: { layout: number; type: string; values: Record<string, number> };
edits: Edit[];
layouts: string[];
placeholder: string;
}
const START_DELAY = 600;
const STEP_GAP = 650;
const TWEEN_MS = 450;
const LOOP_HOLD = 4000;
function init(root: HTMLElement) {
const config = JSON.parse(root.querySelector("[data-vs-config]")?.textContent || "{}") as Config;
const fields = [...root.querySelectorAll<HTMLElement>("[data-field]")];
const seg = root.querySelector<HTMLElement>("[data-seg]")!;
const segOptions = [...root.querySelectorAll<HTMLButtonElement>("[data-seg-option]")];
const segInput = root.querySelector<HTMLInputElement>("[data-seg-input]")!;
const trigger = root.querySelector<HTMLButtonElement>("[data-trigger]")!;
const valueEl = root.querySelector<HTMLElement>("[data-value]")!;
const typeInput = root.querySelector<HTMLInputElement>("[data-type-input]")!;
const menu = root.querySelector<HTMLElement>("[data-menu]")!;
const opts = [...root.querySelectorAll<HTMLElement>("[data-option]")];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const loop = root.hasAttribute("data-loop");
const num = (el: HTMLElement, k: string) => Number(el.dataset[k]);
const inputOf = (f: HTMLElement) => f.querySelector<HTMLInputElement>(".num")!;
const fieldByKey = (key: string) => fields.find((f) => f.dataset.field === key);
let layout = Number(seg.style.getPropertyValue("--vs-ftc-seg")) || 0;
let type = typeInput.value;
const values: Record<string, number> = Object.fromEntries(fields.map((f) => [f.dataset.field!, Number(inputOf(f).value)]));
/* ── state → DOM ── */
const refreshChanged = () => {
const changed =
layout !== config.initial.layout ||
type !== config.initial.type ||
fields.some((f) => values[f.dataset.field!] !== num(f, "default"));
root.toggleAttribute("data-changed", changed);
};
const emit = (source: "user" | "agent") => {
refreshChanged();
root.dispatchEvent(
new CustomEvent("vs-fine-tune-change", {
bubbles: true,
detail: { layout, layoutName: config.layouts[layout], values: { ...values }, type, source },
}),
);
};
const clamp = (f: HTMLElement, v: number) => Math.min(num(f, "max"), Math.max(num(f, "min"), Math.round(v)));
const paintField = (f: HTMLElement, v: number) => {
const input = inputOf(f);
if (document.activeElement !== input || input.value !== String(v)) input.value = String(v);
input.setAttribute("aria-valuenow", String(v));
if (f.dataset.suffix) input.setAttribute("aria-valuetext", `${v}${f.dataset.suffix}`);
f.toggleAttribute("data-active", v !== num(f, "default"));
};
const setField = (f: HTMLElement, v: number, source: "user" | "agent") => {
const next = clamp(f, v);
values[f.dataset.field!] = next;
paintField(f, next);
emit(source);
};
const paintLayout = (i: number) => {
layout = Math.min(2, Math.max(0, i));
seg.style.setProperty("--vs-ftc-seg", String(layout));
segOptions.forEach((o, j) => {
o.setAttribute("aria-checked", String(j === layout));
o.tabIndex = j === layout ? 0 : -1;
});
segInput.value = config.layouts[layout] ?? "";
};
const paintType = (value: string) => {
type = value;
valueEl.textContent = value || config.placeholder;
valueEl.toggleAttribute("data-placeholder", !value);
typeInput.value = value;
opts.forEach((o) => o.setAttribute("aria-selected", String(o.dataset.option === value)));
};
const setLayout = (i: number, source: "user" | "agent", focus = false) => {
paintLayout(i);
if (focus) segOptions[layout]!.focus();
emit(source);
};
const setType = (value: string, source: "user" | "agent") => {
paintType(value);
emit(source);
};
/* type menu */
const onOutside = (event: PointerEvent) => {
if (!root.querySelector("[data-select]")!.contains(event.target as Node)) closeMenu(false);
};
const openMenu = (focus: boolean) => {
if (!menu.hidden) return;
menu.hidden = false;
trigger.setAttribute("aria-expanded", "true");
document.addEventListener("pointerdown", onOutside);
if (focus) {
const target = opts.find((o) => o.getAttribute("aria-selected") === "true") ?? opts[0];
opts.forEach((o) => (o.tabIndex = o === target ? 0 : -1));
target?.focus();
}
};
function closeMenu(focusTrigger: boolean) {
if (menu.hidden) return;
menu.hidden = true;
trigger.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", onOutside);
if (focusTrigger) trigger.focus();
}
trigger.addEventListener("click", () => (menu.hidden ? openMenu(true) : closeMenu(false)));
trigger.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
openMenu(true);
}
});
menu.addEventListener("click", (event) => {
const opt = (event.target as Element).closest<HTMLElement>("[data-option]");
if (!opt) return;
setType(opt.dataset.option ?? "", "user");
closeMenu(true);
});
menu.addEventListener("keydown", (event) => {
const opt = (event.target as Element).closest<HTMLElement>("[data-option]");
if (event.key === "Escape") {
event.preventDefault();
closeMenu(true);
} else if ((event.key === "Enter" || event.key === " ") && opt) {
event.preventDefault();
setType(opt.dataset.option ?? "", "user");
closeMenu(true);
} else if (event.key === "Tab") closeMenu(false);
});
/* ── layout radio group ── */
seg.addEventListener("click", (event) => {
const o = (event.target as Element).closest<HTMLButtonElement>("[data-seg-option]");
if (o) setLayout(Number(o.dataset.segOption), "user");
});
seg.addEventListener("keydown", (event) => {
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (layout + 1) % 3;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (layout + 2) % 3;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = 2;
if (next === null) return;
event.preventDefault();
setLayout(next, "user", true);
});
/* ── number fields: scrub, arrows, typing ── */
fields.forEach((f) => {
const input = inputOf(f);
const handle = f.querySelector<HTMLElement>("[data-handle]")!;
const step = num(f, "step") || 1;
let drag: { x: number; v: number; moved: boolean } | null = null;
handle.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
handle.setPointerCapture(event.pointerId);
drag = { x: event.clientX, v: values[f.dataset.field!] ?? 0, moved: false };
});
handle.addEventListener("pointermove", (event) => {
if (!drag) return;
const dx = event.clientX - drag.x;
if (Math.abs(dx) > 2) drag.moved = true;
if (drag.moved) setField(f, drag.v + (dx / 2) * step, "user");
});
const endDrag = () => {
if (drag?.moved) handle.dataset.dragged = "";
drag = null;
};
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
// A drag should not also focus the input via the label.
handle.addEventListener("click", (event) => {
if (handle.hasAttribute("data-dragged")) {
event.preventDefault();
delete handle.dataset.dragged;
}
});
input.addEventListener("keydown", (event) => {
const mult = event.shiftKey ? 10 : 1;
const v = values[f.dataset.field!] ?? 0;
if (event.key === "ArrowUp") {
event.preventDefault();
setField(f, v + step * mult, "user");
} else if (event.key === "ArrowDown") {
event.preventDefault();
setField(f, v - step * mult, "user");
} else if (event.key === "PageUp") {
event.preventDefault();
setField(f, v + step * 10, "user");
} else if (event.key === "PageDown") {
event.preventDefault();
setField(f, v - step * 10, "user");
} else if (event.key === "Enter") {
input.blur();
}
});
input.addEventListener("input", () => {
const clean = input.value.replace(/[^\d-]/g, "");
if (clean !== input.value) input.value = clean;
const n = Number(clean);
// Live-apply while the typed value is in range; clamp on commit.
if (clean !== "" && clean !== "-" && n >= num(f, "min") && n <= num(f, "max")) {
values[f.dataset.field!] = n;
input.setAttribute("aria-valuenow", String(n));
f.toggleAttribute("data-active", n !== num(f, "default"));
emit("user");
}
});
input.addEventListener("change", () => {
const n = Number(input.value);
setField(f, Number.isFinite(n) && input.value !== "" ? n : values[f.dataset.field!] ?? 0, "user");
input.value = String(values[f.dataset.field!]);
});
// Clicking the chip's padding focuses its input.
f.addEventListener("click", (event) => {
if (event.target === f) input.focus();
});
});
/* ── the agent ── */
let timers: number[] = [];
let raf = 0;
let touched = false;
let visible = false;
let running = false;
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms));
const stop = () => {
timers.forEach((t) => window.clearTimeout(t));
timers = [];
cancelAnimationFrame(raf);
running = false;
root.removeAttribute("data-agent");
};
const tween = (f: HTMLElement, to: number) => {
const from = values[f.dataset.field!] ?? 0;
const t0 = performance.now();
const frame = (now: number) => {
const t = Math.min(1, (now - t0) / TWEEN_MS);
const eased = 1 - Math.pow(1 - t, 3);
values[f.dataset.field!] = clamp(f, from + (to - from) * eased);
paintField(f, values[f.dataset.field!]!);
if (t < 1) raf = requestAnimationFrame(frame);
else emit("agent");
};
raf = requestAnimationFrame(frame);
};
const reset = () => {
fields.forEach((f) => {
values[f.dataset.field!] = config.initial.values[f.dataset.field!] ?? num(f, "default");
paintField(f, values[f.dataset.field!]!);
});
paintLayout(config.initial.layout);
paintType(config.initial.type);
refreshChanged();
};
const play = () => {
stop();
reset();
running = true;
root.setAttribute("data-agent", "");
let at = START_DELAY;
for (const edit of config.edits) {
if (edit.field !== undefined && edit.value !== undefined) {
const f = fieldByKey(edit.field);
if (f) later(at, () => tween(f, edit.value!));
at += TWEEN_MS + STEP_GAP;
}
if (edit.layout !== undefined) {
const i = edit.layout;
later(at, () => setLayout(i, "agent"));
at += 300 + STEP_GAP;
}
if (edit.type !== undefined) {
const value = edit.type;
const opt = opts.find((o) => o.dataset.option === value);
later(at, () => openMenu(false));
if (opt) later(at + 350, () => opt.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
later(at + 800, () => {
setType(value, "agent");
closeMenu(false);
});
at += 800 + STEP_GAP;
}
}
later(at, () => {
running = false;
root.removeAttribute("data-agent");
if (loop) later(LOOP_HOLD, again);
});
};
function again() {
if (touched || !visible) return;
if (root.matches(":hover") || root.contains(document.activeElement)) return later(LOOP_HOLD, again);
play();
}
// Any hands-on interaction takes over from the agent, keeping what it has done so far.
const takeOver = () => {
if (touched) return;
touched = true;
stop();
refreshChanged();
};
root.addEventListener("pointerdown", (event) => event.isTrusted && takeOver());
root.addEventListener("keydown", (event) => event.isTrusted && takeOver());
refreshChanged();
if (reduce || !root.hasAttribute("data-autoplay")) return;
// Start from the untouched card; the agent works once it is on screen.
reset();
let started = false;
const io = new IntersectionObserver(
([entry]) => {
if (!root.isConnected) return teardown();
visible = Boolean(entry?.isIntersecting);
if (visible && !started && !touched) {
started = true;
play();
} else if (visible && started && !running && loop && !touched) {
later(LOOP_HOLD, again);
}
},
{ threshold: 0.4 },
);
io.observe(root);
function teardown() {
stop();
io.disconnect();
document.removeEventListener("pointerdown", onOutside);
}
document.addEventListener("astro:before-swap", teardown, { once: true });
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-fine-tune-card:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>
<style>
.vs-fine-tune-card {
position: relative;
width: 100%;
max-width: 240px;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-raised);
}
.vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ── header ── */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--vs-line);
}
.title {
font-size: 13px;
font-weight: 500;
color: var(--vs-ink);
}
.status {
display: flex;
}
.adjust,
.edited {
display: flex;
align-items: center;
gap: 6px;
}
.edited {
display: none;
font-size: 12px;
font-weight: 500;
color: var(--vs-green);
animation: vs-pop-in 250ms var(--vs-ease-out) both;
}
[data-changed]:not([data-agent]) .adjust {
display: none;
}
[data-changed]:not([data-agent]) .edited {
display: flex;
}
.spark {
display: flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--vs-accent) 30%, transparent);
border-radius: 5px;
background: var(--vs-accent-tint);
color: var(--vs-accent-ink);
box-sizing: border-box;
}
.adjust-text {
background-image: linear-gradient(90deg, var(--vs-accent) 35%, var(--vs-accent-ink) 50%, var(--vs-accent) 65%);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: 12px;
font-weight: 500;
animation: vs-shimmer-text 1.4s linear infinite;
}
/* ── layout section ── */
.section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--vs-line);
}
.section-title {
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--vs-ink);
}
.seg {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 2px;
border-radius: var(--vs-radius-control);
background: var(--vs-field);
}
.seg-thumb {
position: absolute;
top: 2px;
bottom: 2px;
left: 2px;
width: calc((100% - 4px) / 3);
border-radius: 6px;
background: var(--vs-surface);
box-shadow: var(--vs-shadow-btn);
transform: translateX(calc(var(--vs-ftc-seg, 0) * 100%));
transition: transform 300ms var(--vs-ease-out);
}
.seg-btn {
position: relative;
z-index: 1;
display: flex;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--vs-ink-3);
cursor: pointer;
transition: color 200ms ease-out;
}
.seg-btn[aria-checked="true"] {
color: var(--vs-accent-ink);
}
.glyph {
display: flex;
gap: 2px;
}
.glyph--col {
flex-direction: column;
}
.glyph--grid {
display: grid;
grid-template-columns: repeat(2, auto);
}
.glyph span {
width: 6px;
height: 6px;
box-sizing: border-box;
border: 1.2px solid currentColor;
border-radius: 2px;
}
.pair {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.field {
display: flex;
min-width: 0;
height: 26px;
box-sizing: border-box;
align-items: center;
gap: 4px;
padding: 4px 4px 4px 2px;
border-radius: var(--vs-radius-chip);
background: var(--vs-field);
cursor: text;
transition:
background-color 200ms ease-out,
box-shadow 200ms ease-out;
}
.field[data-active] {
background: var(--vs-accent-tint);
box-shadow: 0 0 0 1px var(--vs-accent);
}
.field:has(.num:focus-visible) {
box-shadow: var(--vs-focus-ring);
}
.handle {
display: flex;
height: 100%;
flex-shrink: 0;
align-items: center;
padding: 0 2px;
border-radius: 4px;
color: var(--vs-ink-3);
font-size: 12px;
cursor: ew-resize;
touch-action: none;
user-select: none;
transition: color var(--vs-duration-fast) ease-out;
}
.handle:hover {
color: var(--vs-ink-2);
}
.field:has(.num:focus-visible) .handle {
color: var(--vs-accent-ink);
}
.num {
min-width: 0;
flex: 1;
padding: 0;
border: none;
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 12px;
font-variant-numeric: tabular-nums;
outline: none;
}
.suffix {
flex-shrink: 0;
padding-right: 2px;
font-size: 11.5px;
color: var(--vs-ink-3);
}
/* type menu */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
}
.footer-label {
font-size: 12px;
color: var(--vs-ink-3);
}
.select {
position: relative;
width: 120px;
margin-right: -2px;
}
.trigger {
display: flex;
width: 100%;
height: 26px;
align-items: center;
justify-content: space-between;
padding: 4px 4px 4px 8px;
border: none;
border-radius: var(--vs-radius-chip);
background: var(--vs-inset);
box-shadow: var(--vs-shadow-hairline);
font: inherit;
cursor: pointer;
transition: box-shadow 200ms ease-out;
}
.trigger[aria-expanded="true"] {
box-shadow: 0 0 0 1px var(--vs-accent);
}
.trigger:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
.value {
font-size: 12px;
color: var(--vs-ink);
}
.value[data-placeholder] {
color: var(--vs-ink-3);
}
.chevron {
color: var(--vs-ink-3);
transition: transform 200ms ease-out;
}
.trigger[aria-expanded="true"] .chevron {
transform: rotate(180deg);
}
.menu {
position: absolute;
right: 0;
bottom: 32px;
z-index: 10;
width: 120px;
box-sizing: border-box;
padding: 4px;
border-radius: 10px;
background: var(--vs-surface);
box-shadow: var(--vs-shadow-raised);
transform-origin: bottom right;
animation: vs-pop-in 200ms var(--vs-ease-out) both;
}
.menu-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.opt {
position: relative;
z-index: 1;
display: flex;
height: 26px;
align-items: center;
padding: 0 8px;
border-radius: 6px;
color: var(--vs-ink);
font-size: 12.5px;
cursor: pointer;
outline: none;
}
.opt[aria-selected="true"] {
background: var(--vs-field);
}
.menu-list[data-glide-active] .opt[aria-selected="true"]:not([data-glide-current]) {
background: transparent;
}
.opt:focus-visible {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-accent) 60%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.adjust-text {
animation: none;
background: none;
color: var(--vs-accent-ink);
}
.edited,
.menu {
animation: none;
}
.seg-thumb,
.chevron,
.field {
transition: none;
}
}
@media (forced-colors: active) {
.seg-thumb {
outline: 2px solid Highlight;
}
.field[data-active] {
outline: 1px solid Highlight;
}
}
</style> Ported from Beautiful UI, MIT. Notices.