+
Skip to workspace
diff --git a/ui/src/WorkspacePanels.tsx b/ui/src/WorkspacePanels.tsx
index d6405f6..10704f3 100644
--- a/ui/src/WorkspacePanels.tsx
+++ b/ui/src/WorkspacePanels.tsx
@@ -8,7 +8,8 @@ import {
} from "lucide-react";
import type { Contribution } from "../../src/workspace-contract.js";
import { useCommand } from "./api.js";
-import { Avatar, Dialog, Empty, Mark, relativeTime } from "./components.js";
+import { Avatar, Empty, Mark, relativeTime } from "./components.js";
+import { BlobDialog, Button, Input } from "./grove/Grove.js";
export function Nav({
icon,
@@ -89,24 +90,40 @@ export function Create({
const [task, setTask] = useState("");
const [session] = useState(() => crypto.randomUUID());
const command = useCommand(refresh);
+ const submit = () => {
+ void command.send(session, { kind: "create", title, task }).then((sent) => {
+ if (sent) created(session);
+ });
+ };
return (
-
+
+ Cancel
+
+ Create contribution
+
+ >
+ }
+ >
-
+
);
}
export function Activity({
diff --git a/ui/src/grove/Grove.tsx b/ui/src/grove/Grove.tsx
new file mode 100644
index 0000000..cd3f0b4
--- /dev/null
+++ b/ui/src/grove/Grove.tsx
@@ -0,0 +1,229 @@
+import { useEffect, useRef } from "react";
+import type {
+ ButtonHTMLAttributes,
+ InputHTMLAttributes,
+ ReactNode,
+ TextareaHTMLAttributes,
+} from "react";
+import "./grove.css";
+
+/* Grove: thin React wrappers over the classes in grove.css. Anything a plain
+ * element can do, the class does; these exist so call sites read as
+ * components and so the dialog blob clip-path is mounted once. */
+
+export function GroveDefs() {
+ return (
+
+
+ {/* Organic blob for dialogs; objectBoundingBox so it scales with the box. */}
+
+
+
+
+
+ );
+}
+
+type Variant = "default" | "primary" | "danger" | "ghost";
+type Size = "sm" | "md" | "lg";
+
+export function Button({
+ variant = "default",
+ size = "md",
+ block,
+ icon,
+ loading,
+ className = "",
+ ...rest
+}: ButtonHTMLAttributes
& {
+ variant?: Variant;
+ size?: Size;
+ block?: boolean;
+ icon?: boolean;
+ loading?: boolean;
+}) {
+ const classes = [
+ "grove-btn",
+ variant !== "default" ? `grove-btn-${variant}` : "",
+ size !== "md" ? `grove-btn-${size}` : "",
+ block ? "grove-btn-block" : "",
+ icon ? "grove-btn-icon" : "",
+ loading ? "is-loading" : "",
+ className,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return ;
+}
+
+export function Input(props: InputHTMLAttributes) {
+ return (
+
+ );
+}
+
+export function Textarea(props: TextareaHTMLAttributes) {
+ return (
+
+ );
+}
+
+export function Switch({
+ checked,
+ onChange,
+ labels = ["On", "Off"],
+ "aria-label": ariaLabel,
+}: {
+ checked: boolean;
+ onChange: (next: boolean) => void;
+ labels?: [string, string];
+ "aria-label": string;
+}) {
+ return (
+
+ onChange(event.target.checked)}
+ />
+
+ {labels[0]}
+ {labels[1]}
+
+ );
+}
+
+export function Checkbox({
+ checked,
+ onChange,
+ children,
+}: {
+ checked: boolean;
+ onChange: (next: boolean) => void;
+ children: ReactNode;
+}) {
+ return (
+
+ onChange(event.target.checked)}
+ />
+
+
+
+
+
+ {children}
+
+ );
+}
+
+export type Activity =
+ "working" | "permission" | "review" | "ready" | "waiting" | "archived";
+
+export function Tag({
+ activity,
+ children,
+}: {
+ activity?: Activity;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function Ribbon({
+ paper,
+ children,
+}: {
+ paper?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function Speech({
+ who,
+ agent,
+ children,
+}: {
+ who: string;
+ agent?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {who}
+ {children}
+
+ );
+}
+
+/** A native clipped to the blob. Closes on Escape and on clicks outside. */
+export function BlobDialog({
+ title,
+ description,
+ close,
+ children,
+ actions,
+}: {
+ title: string;
+ description?: string;
+ close: () => void;
+ children?: ReactNode;
+ actions?: ReactNode;
+}) {
+ const ref = useRef(null);
+ useEffect(() => {
+ const node = ref.current!;
+ node.showModal();
+ return () => node.close();
+ }, []);
+ return (
+ {
+ if (event.target === ref.current) close();
+ }}
+ >
+
+
+
{title}
+ {description &&
{description}
}
+ {children}
+ {actions &&
{actions}
}
+
+
+
+ );
+}
+
+export function Vine() {
+ return
;
+}
diff --git a/ui/src/grove/grove.css b/ui/src/grove/grove.css
new file mode 100644
index 0000000..87860e2
--- /dev/null
+++ b/ui/src/grove/grove.css
@@ -0,0 +1,637 @@
+/* Grove — AXP's component kit.
+ *
+ * Built on the Animal Island design language (MIT, lifeodyssey/animal-island-ui-tailwind,
+ * itself after guokaigdg/animal-island-ui) and re-based on AXP's palette: paper
+ * instead of parchment, sage and leaf instead of mint teal, warm charcoal ink.
+ * What we kept from the foundation because it is the point of it:
+ * - every interactive thing is a capsule or a soft rounded shape, nothing sharp
+ * - depth is a flat bottom shadow, the game-button press: rest 5px, hover 6px
+ * with a 1px lift, active 1px with a 2px drop
+ * - 2px borders, chunky and friendly
+ * - a yellow focus ring, unmistakable on a green page
+ * - organic radii on titles, a blob clip on dialogs
+ * What is ours: a speech bubble for agent turns, a vine divider, status tags
+ * mapped to the workspace's activity states, and the tokens below. */
+
+:root {
+ --grove-paper: #f6f7f4;
+ --grove-paper-2: #fbfcf8;
+ --grove-ink: #252c28;
+ --grove-ink-2: #3f4a42;
+ --grove-ink-3: #6b766e;
+ --grove-ink-4: #9aa59c;
+ --grove-sage: #b7c4b2;
+ --grove-sage-2: #dfe6db;
+ --grove-sand: #b9c6b0; /* the press shadow */
+ --grove-sand-2: #d3dccd; /* the input shadow */
+ --grove-leaf: #58aa72;
+ --grove-leaf-hover: #6dbb85;
+ --grove-leaf-active: #3d7a52;
+ --grove-leaf-bg: #eaf3e9;
+ --grove-leaf-shadow: #3b7a52;
+ --grove-danger: #c9564b;
+ --grove-danger-bg: #faefec;
+ --grove-danger-shadow: #9b3d34;
+ --grove-focus: #ffcc00;
+ --grove-focus-2: #e0b800;
+ --grove-radius: 20px;
+ --grove-pill: 999px;
+ --grove-ease: cubic-bezier(0.4, 0, 0.2, 1);
+ --grove-fast: 0.15s;
+ --grove-base: 0.25s;
+}
+
+/* ------------------------------------------------------------------ button */
+.grove-btn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ height: 40px;
+ padding: 0 20px;
+ border: 2px solid var(--grove-sage);
+ border-radius: var(--grove-pill);
+ background: var(--grove-paper-2);
+ color: var(--grove-ink-2);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+ user-select: none;
+ cursor: pointer;
+ box-shadow: 0 5px 0 0 var(--grove-sand);
+ transition:
+ transform var(--grove-base) var(--grove-ease),
+ box-shadow var(--grove-base) var(--grove-ease),
+ background var(--grove-base) var(--grove-ease);
+}
+.grove-btn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 0 0 var(--grove-sand);
+}
+.grove-btn:active:not(:disabled) {
+ transform: translateY(2px);
+ box-shadow: 0 1px 0 0 var(--grove-sand);
+ transition-duration: var(--grove-fast);
+}
+.grove-btn:focus-visible {
+ outline: 2px solid var(--grove-focus);
+ outline-offset: 2px;
+}
+.grove-btn:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ box-shadow: 0 3px 0 0 var(--grove-sand);
+}
+.grove-btn-primary {
+ background: var(--grove-leaf);
+ border-color: var(--grove-leaf);
+ color: #fff;
+ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.12);
+ box-shadow: 0 5px 0 0 var(--grove-leaf-shadow);
+}
+.grove-btn-primary:hover:not(:disabled) {
+ background: var(--grove-leaf-hover);
+ border-color: var(--grove-leaf-hover);
+ box-shadow: 0 6px 0 0 var(--grove-leaf-shadow);
+}
+.grove-btn-primary:active:not(:disabled) {
+ background: var(--grove-leaf-active);
+ border-color: var(--grove-leaf-active);
+ box-shadow: 0 1px 0 0 var(--grove-leaf-shadow);
+}
+.grove-btn-danger {
+ background: var(--grove-danger);
+ border-color: var(--grove-danger);
+ color: #fff;
+ box-shadow: 0 5px 0 0 var(--grove-danger-shadow);
+}
+.grove-btn-danger:hover:not(:disabled) {
+ box-shadow: 0 6px 0 0 var(--grove-danger-shadow);
+}
+.grove-btn-danger:active:not(:disabled) {
+ box-shadow: 0 1px 0 0 var(--grove-danger-shadow);
+}
+.grove-btn-ghost {
+ background: transparent;
+ border-color: transparent;
+ box-shadow: none;
+}
+.grove-btn-ghost:hover:not(:disabled) {
+ background: var(--grove-sage-2);
+ transform: none;
+ box-shadow: none;
+}
+.grove-btn-sm {
+ height: 30px;
+ padding: 0 14px;
+ font-size: 11px;
+ border-radius: 12px;
+ box-shadow: 0 3px 0 0 var(--grove-sand);
+}
+.grove-btn-sm:hover:not(:disabled) {
+ box-shadow: 0 4px 0 0 var(--grove-sand);
+}
+.grove-btn-sm.grove-btn-primary {
+ box-shadow: 0 3px 0 0 var(--grove-leaf-shadow);
+}
+.grove-btn-sm.grove-btn-primary:hover:not(:disabled) {
+ box-shadow: 0 4px 0 0 var(--grove-leaf-shadow);
+}
+.grove-btn-lg {
+ height: 48px;
+ padding: 0 30px;
+ font-size: 14px;
+ border-radius: 24px;
+}
+.grove-btn-block {
+ width: 100%;
+}
+/* icon-only round button */
+.grove-btn-icon {
+ width: 40px;
+ padding: 0;
+}
+.grove-btn-icon.grove-btn-sm {
+ width: 30px;
+ border-radius: var(--grove-pill);
+}
+/* the foundation's striped loading state, in our greens */
+.grove-btn.is-loading {
+ color: transparent;
+ pointer-events: none;
+ background-image: repeating-linear-gradient(
+ -45deg,
+ var(--grove-leaf),
+ var(--grove-leaf) 10px,
+ var(--grove-leaf-active) 10px,
+ var(--grove-leaf-active) 20px
+ );
+ background-size: 28.28px 28.28px;
+ animation: grove-stripes 1s linear infinite;
+}
+@keyframes grove-stripes {
+ to {
+ background-position: -28.28px 0;
+ }
+}
+
+/* ------------------------------------------------------------------- input */
+.grove-input,
+.grove-textarea {
+ width: 100%;
+ border: 2.5px solid var(--grove-sage);
+ background: #fff;
+ color: var(--grove-ink-2);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ box-shadow: 0 3px 0 0 var(--grove-sand-2);
+ transition:
+ border-color var(--grove-base) var(--grove-ease),
+ box-shadow var(--grove-base) var(--grove-ease);
+}
+.grove-input {
+ height: 40px;
+ padding: 0 18px;
+ border-radius: var(--grove-pill);
+}
+.grove-textarea {
+ padding: 12px 16px;
+ border-radius: 18px;
+ resize: vertical;
+ line-height: 1.6;
+}
+.grove-input::placeholder,
+.grove-textarea::placeholder {
+ color: var(--grove-ink-4);
+ font-weight: 400;
+}
+.grove-input:hover,
+.grove-textarea:hover {
+ border-color: var(--grove-ink-4);
+}
+.grove-input:focus,
+.grove-textarea:focus {
+ outline: none;
+ border-color: var(--grove-focus);
+ box-shadow:
+ 0 3px 0 0 var(--grove-focus-2),
+ 0 0 0 3px rgba(255, 204, 0, 0.15);
+}
+.grove-input:disabled,
+.grove-textarea:disabled {
+ background: var(--grove-paper);
+ color: var(--grove-ink-4);
+ box-shadow: none;
+}
+.grove-label {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--grove-ink-2);
+ margin: 0 0 8px 6px;
+}
+.grove-hint {
+ font-size: 10px;
+ color: var(--grove-ink-3);
+ margin: 6px 0 0 6px;
+}
+
+/* ------------------------------------------------------------------ switch */
+.grove-switch {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ min-width: 52px;
+ height: 28px;
+ border: 2.5px solid var(--grove-sage);
+ border-radius: var(--grove-pill);
+ background: var(--grove-sand-2);
+ box-shadow: inset 0 2px 4px rgba(60, 74, 60, 0.15);
+ cursor: pointer;
+ transition:
+ background var(--grove-base) var(--grove-ease),
+ border-color var(--grove-base) var(--grove-ease);
+}
+.grove-switch input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ margin: 0;
+ cursor: pointer;
+}
+.grove-switch .handle {
+ position: absolute;
+ top: 1px;
+ left: 2px;
+ width: 21px;
+ height: 21px;
+ border-radius: 50%;
+ background: #fff;
+ /* the handle floats above the track */
+ transform: translateY(-2px);
+ box-shadow: 0 3px 0 0 var(--grove-sand);
+ transition:
+ left var(--grove-base) var(--grove-ease),
+ box-shadow var(--grove-base) var(--grove-ease);
+ pointer-events: none;
+}
+.grove-switch:has(input:checked) {
+ background: #8fd398;
+ border-color: var(--grove-leaf);
+ box-shadow: inset 0 2px 4px rgba(61, 122, 82, 0.2);
+}
+.grove-switch:has(input:checked) .handle {
+ left: calc(100% - 24px);
+ box-shadow: 0 3px 0 0 var(--grove-leaf-active);
+}
+.grove-switch:has(input:focus-visible) {
+ outline: 2px solid var(--grove-focus);
+ outline-offset: 2px;
+}
+.grove-switch .on,
+.grove-switch .off {
+ font-size: 10px;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: 0.02em;
+ pointer-events: none;
+ padding: 0 8px 0 28px;
+ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.12);
+}
+.grove-switch .on {
+ display: none;
+ padding: 0 28px 0 8px;
+}
+.grove-switch:has(input:checked) .on {
+ display: inline;
+}
+.grove-switch:has(input:checked) .off {
+ display: none;
+}
+
+/* ---------------------------------------------------------------- checkbox */
+.grove-check {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+ color: var(--grove-ink-2);
+ cursor: pointer;
+ position: relative;
+}
+.grove-check input {
+ position: absolute;
+ opacity: 0;
+ width: 22px;
+ height: 22px;
+ margin: 0;
+}
+.grove-check .box {
+ display: inline-grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border: 2px solid var(--grove-sage);
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: 0 2px 0 0 var(--grove-sand-2);
+ color: #fff;
+ transition: all var(--grove-fast) var(--grove-ease);
+}
+.grove-check .box svg {
+ width: 14px;
+ height: 14px;
+ opacity: 0;
+ transform: scale(0.6);
+ transition: all var(--grove-fast) var(--grove-ease);
+}
+.grove-check:has(input:checked) .box {
+ background: var(--grove-leaf);
+ border-color: var(--grove-leaf);
+ box-shadow: 0 2px 0 0 var(--grove-leaf-active);
+}
+.grove-check:has(input:checked) .box svg {
+ opacity: 1;
+ transform: scale(1);
+}
+.grove-check:has(input:focus-visible) .box {
+ outline: 2px solid var(--grove-focus);
+ outline-offset: 2px;
+}
+
+/* --------------------------------------------------------------------- tag */
+.grove-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 24px;
+ padding: 0 10px;
+ border: 2px solid transparent;
+ border-radius: var(--grove-pill);
+ background: var(--grove-sage-2);
+ color: var(--grove-ink-2);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+}
+.grove-tag::before {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+.grove-tag-working {
+ background: #e9eff7;
+ color: #4d6a8c;
+}
+.grove-tag-permission {
+ background: #fbf1de;
+ color: #946127;
+}
+.grove-tag-review {
+ background: #efe9f5;
+ color: #6e567a;
+}
+.grove-tag-ready {
+ background: var(--grove-leaf-bg);
+ color: var(--grove-leaf-active);
+}
+.grove-tag-waiting {
+ background: var(--grove-paper-2);
+ color: var(--grove-ink-3);
+ border-color: var(--grove-sage-2);
+}
+.grove-tag-archived {
+ background: #ecedeb;
+ color: var(--grove-ink-3);
+}
+
+/* -------------------------------------------------------------------- card */
+.grove-card {
+ background: var(--grove-paper-2);
+ border: 2px solid var(--grove-sage-2);
+ border-radius: var(--grove-radius);
+ padding: 20px;
+ box-shadow: 0 4px 12px rgba(60, 74, 60, 0.08);
+ transition:
+ transform var(--grove-base) var(--grove-ease),
+ box-shadow var(--grove-base) var(--grove-ease),
+ border-color var(--grove-base) var(--grove-ease);
+}
+.grove-card.is-hoverable:hover {
+ transform: translateY(-4px);
+ border-color: var(--grove-sage);
+ box-shadow: 0 10px 24px rgba(60, 74, 60, 0.12);
+}
+
+/* ------------------------------------------------------------------ ribbon */
+/* Section title on an organic pill: the foundation's Title with our leaf. */
+.grove-ribbon {
+ display: inline-flex;
+ align-items: center;
+ height: 2em;
+ padding: 0 1.4em;
+ color: #fff;
+ background: var(--grove-leaf);
+ border-radius: 40px 35px 45px 38px / 38px 45px 35px 40px;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ box-shadow: 0 4px 0 0 var(--grove-leaf-active);
+ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.15);
+}
+.grove-ribbon-paper {
+ color: var(--grove-ink-2);
+ background: var(--grove-paper-2);
+ border: 2px solid var(--grove-sage);
+ box-shadow: 0 4px 0 0 var(--grove-sand);
+ text-shadow: none;
+}
+
+/* ------------------------------------------------------------------ dialog */
+/* The foundation's blob clip-path, applied to a native . The dialog
+ * fills the viewport and carries the tint and blur itself: a transparent
+ * dialog box over a blurred ::backdrop leaves a sharp un-blurred rectangle. */
+dialog.grove-dialog {
+ position: fixed;
+ inset: 0;
+ width: 100vw;
+ height: 100vh;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ border: 0;
+ padding: 24px;
+ display: grid;
+ place-items: center;
+ background: rgba(24, 40, 28, 0.38);
+ -webkit-backdrop-filter: blur(3px);
+ backdrop-filter: blur(3px);
+ color: var(--grove-ink);
+}
+dialog.grove-dialog::backdrop {
+ background: transparent;
+}
+.grove-dialog-shell {
+ width: min(520px, 100%);
+ filter: drop-shadow(0 18px 40px rgba(24, 40, 28, 0.28));
+}
+.grove-dialog-blob {
+ background: var(--grove-paper-2);
+ clip-path: url(#grove-blob);
+ padding: 44px 44px 36px;
+}
+.grove-dialog-blob h2 {
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: -0.4px;
+ margin: 0 0 6px;
+}
+.grove-dialog-blob > p {
+ font-size: 12px;
+ color: var(--grove-ink-3);
+ line-height: 1.7;
+ margin: 0 0 18px;
+}
+.grove-dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 22px;
+ padding-bottom: 4px;
+}
+
+/* ------------------------------------------------------------------ speech */
+/* Ours: an agent's turn as a dialogue box, the way villagers talk. */
+.grove-speech {
+ position: relative;
+ background: var(--grove-paper-2);
+ border: 2px solid var(--grove-sage-2);
+ border-radius: 22px 22px 22px 6px;
+ padding: 14px 18px;
+ font-size: 12px;
+ line-height: 1.7;
+ color: var(--grove-ink-2);
+ box-shadow: 0 4px 0 0 var(--grove-sage-2);
+}
+.grove-speech.from-agent {
+ background: var(--grove-leaf-bg);
+ border-color: #cfe3cf;
+ box-shadow: 0 4px 0 0 #cfe3cf;
+ border-radius: 22px 22px 6px 22px;
+}
+.grove-speech .who {
+ position: absolute;
+ top: -12px;
+ left: 14px;
+ height: 22px;
+ padding: 0 10px;
+ display: inline-flex;
+ align-items: center;
+ border-radius: var(--grove-pill);
+ background: var(--grove-leaf);
+ color: #fff;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ box-shadow: 0 2px 0 0 var(--grove-leaf-active);
+}
+.grove-speech.from-agent .who {
+ left: auto;
+ right: 14px;
+ background: var(--grove-ink-2);
+ box-shadow: 0 2px 0 0 var(--grove-ink);
+}
+
+/* -------------------------------------------------------------------- tabs */
+.grove-tabs {
+ display: inline-flex;
+ gap: 4px;
+ padding: 4px;
+ border-radius: var(--grove-pill);
+ background: var(--grove-sage-2);
+}
+.grove-tabs button {
+ height: 30px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: var(--grove-pill);
+ background: transparent;
+ color: var(--grove-ink-3);
+ font: inherit;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--grove-fast) var(--grove-ease);
+}
+.grove-tabs button[aria-selected="true"] {
+ background: #fff;
+ color: var(--grove-ink);
+ box-shadow: 0 2px 0 0 var(--grove-sand-2);
+}
+.grove-tabs button:focus-visible {
+ outline: 2px solid var(--grove-focus);
+ outline-offset: 2px;
+}
+
+/* ------------------------------------------------------------------- toast */
+.grove-toast {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 18px;
+ border: 2px solid var(--grove-sage-2);
+ border-radius: 18px;
+ background: var(--grove-paper-2);
+ box-shadow: 0 5px 0 0 var(--grove-sage-2);
+ font-size: 12px;
+ color: var(--grove-ink-2);
+}
+.grove-toast strong {
+ display: block;
+ color: var(--grove-ink);
+ margin-bottom: 2px;
+}
+.grove-toast.is-success {
+ border-color: #cfe3cf;
+ box-shadow: 0 5px 0 0 #cfe3cf;
+}
+.grove-toast.is-warning {
+ border-color: #efdcb1;
+ box-shadow: 0 5px 0 0 #efdcb1;
+}
+.grove-toast.is-danger {
+ border-color: #efc8c2;
+ box-shadow: 0 5px 0 0 #efc8c2;
+}
+
+/* ----------------------------------------------------------------- divider */
+/* Ours: a vine. Repeating leaf-wave drawn in SVG, sized to the text colour. */
+.grove-vine {
+ height: 14px;
+ margin: 18px 0;
+ background: url("data:image/svg+xml;utf8, ")
+ repeat-x center;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .grove-btn,
+ .grove-card,
+ .grove-switch .handle,
+ .grove-check .box,
+ .grove-check .box svg {
+ transition: none;
+ }
+ .grove-btn.is-loading {
+ animation: none;
+ }
+}
diff --git a/ui/src/style.css b/ui/src/style.css
index 8ddbe3e..2971ca8 100644
--- a/ui/src/style.css
+++ b/ui/src/style.css
@@ -2258,3 +2258,14 @@ dialog::backdrop {
color: #305475;
background: #eef5fc;
}
+
+.grove-form {
+ display: grid;
+ gap: 4px;
+}
+.grove-form .grove-label {
+ margin-top: 14px;
+}
+.grove-form .grove-label:first-child {
+ margin-top: 0;
+}