diff --git a/docs/design/liquid-leaf.html b/docs/design/liquid-leaf.html new file mode 100644 index 0000000..fde99a1 --- /dev/null +++ b/docs/design/liquid-leaf.html @@ -0,0 +1,377 @@ + + + + + + Liquid Leaf + + + + + + +
+

Liquid Leaf

+

+ Liquid glass, outdoors. A translucent sap surface with a meniscus, a + specular highlight that follows the pointer, a lit rim, frost behind it + and a coloured glow into the page. Two scenes: outdoor by day and indoor + by night. Hover and press the buttons. +

+
+
+

Outdoor · day

+
+ + + +
+
+ Live workspace + Reconnecting + +
+
+
+ +

Connect an agent

+

+ You choose its budget. Maintainers direct the work. The card + frosts whatever sits behind it. +

+
+
+
+ + + + ← goo filter: touching drops merge +
+

+ Palette: sprout #b8ec9c · grass #7dc395 · leaf #58aa72 · moss + #3d7a52 +

+
+
+

Indoor · night

+
+ + + +
+
+ Live workspace + Reconnecting + +
+
+
+ +

Connect an agent

+

+ Same surface, darker sap. The rim light is cooler and the glow + is what you notice first. +

+
+
+
+ + ← goo filter +
+

+ Palette: sprout #9ad6a6 · grass #4c8f66 · leaf #2f6a49 · moss + #1f4a34 +

+
+
+
+
+ MeniscusA bright radial bead along the top where sap meets the + rim. This, more than the blur, is what says “liquid”. +
+
+ Specular that movesThe highlight is a radial gradient + positioned by two custom properties; the React hook sets them from the + pointer. +
+
+ Lit rim, shaded footA 1px gradient border, bright at the light + and dark opposite, made with a masked pseudo-element. liquid-dom does + this in a shader as an SDF rim band. +
+
+ Frostbackdrop-filter: blur(12px) saturate(1.35). + Falls back to a solid tint where unsupported. +
+
+ Squash and springPress scales to 0.97 × 0.94; release plays a + spring via a linear() easing and a 600 ms turbulence + wobble. +
+
+ GooThe metaball filter (blur, then a hard alpha ramp) lets + presence drops merge when they touch. +
+
+
+ + + diff --git a/docs/design/liquid-leaf.md b/docs/design/liquid-leaf.md new file mode 100644 index 0000000..cc36ce7 --- /dev/null +++ b/docs/design/liquid-leaf.md @@ -0,0 +1,68 @@ +# Liquid Leaf + +## What it is + +Liquid glass, outdoors. A translucent green surface, sap rather than glass, +for the parts of the workspace that should feel alive: presence, the primary +action, live state. It has a bright meniscus along the top, a specular +highlight that follows the pointer, a lit rim and a shaded foot, frost behind +it and a coloured glow into the page. Press it and it squashes, then springs +back with a short wobble. + +Two scenes share one set of tokens: **outdoor** (default, daytime grass) and +**indoor** (`[data-scene="indoor"]`, night, moss under lamplight). The dark +theme has not been designed yet; indoor is the first sketch of what it wants +to be, and the tokens are the seam where it plugs in. + +Open `docs/design/liquid-leaf.html` for both scenes side by side. + +## Where it is used + +- The "Live workspace" pill in the top bar, with a breathing sap drop for the + connection state (sandy and still when reconnecting). +- The primary action ("New contribution"). +- The "Connect an agent" card in the sidebar. +- Presence drops beside connected agents. + +Everything else keeps the current flat chrome so the two can be compared. + +Files: `ui/src/liquid/liquid-leaf.css` (tokens, surface, variants, filters), +`ui/src/liquid/LiquidLeaf.tsx` (``, ``, +`useLeafLight`, `useLeafPress`). + +## What was taken from liquid-dom, and what was not + +[liquid-dom](https://github.com/AndrewPrifer/liquid-dom) renders real +refraction: an SDF of the shape gives a surface normal, the backdrop is +sampled through per-channel refraction, a specular band is lit along the rim, +and the backdrop is blurred adaptively. It needs WebGPU, and drawing live DOM +content behind the glass needs Chrome's experimental HTML-in-Canvas flag. That +is not a base to build a product on today, and its core package has no +license file, so nothing from it is vendored. + +What carried over is the model of what makes glass read as liquid, rebuilt in +CSS and SVG so it works in every current browser: + +| liquid-dom | Liquid Leaf | +| --------------------------------- | --------------------------------------------------------------------- | +| SDF rim + specular band | 1px masked gradient border, bright at the light, dark opposite | +| Lit highlight from a light vector | Radial gradient positioned by `--leaf-light-x/y`; pointer moves it | +| Adaptive backdrop blur | `backdrop-filter: blur(12px) saturate(1.35)`, solid fallback | +| Corner smoothing | `corner-shape: squircle` where supported, radius elsewhere | +| Refraction | Not attempted; a turbulence displacement on press stands in for "wet" | +| Blurred shadow mask | Ambient lift plus a green-tinted glow | + +If WebGPU-backed glass ever becomes a reasonable dependency, it slots in as a +progressive enhancement behind the same class names. + +## Notes for building on it + +- The surface is one class (`.leaf`) plus a variant; new components should + start from that rather than copying the gradient stack. +- `useLeafLight` is cheap (two custom properties per pointer move) and can be + attached to anything, not only leaves. +- The goo filter (`.leaf-goo`) merges touching drops into one blob, the + metaball trick. It is meant for avatar or agent clusters. +- Motion respects `prefers-reduced-motion`: no spring, no wobble, no breathing. +- Text on sap is the ink token, not pure black, and gets a 1px light text + shadow outdoors / dark shadow indoors to stay crisp on the gradient. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d3254ac..77af59f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,7 @@ import { people, } from "./components.js"; import { ContributionPage } from "./Contribution.js"; +import { Leaf, LeafDefs } from "./liquid/LiquidLeaf.js"; type Page = "overview" | "contributions" | "people" | "activity"; function currentPage(): Page { @@ -113,6 +114,7 @@ export default function App() { }; return (
+ Skip to workspace @@ -169,14 +171,14 @@ export default function App() { />
-
+

Connect an agent

You choose its budget. Maintainers direct the work.

-
+ @@ -203,14 +205,17 @@ export default function App() { {repository}
- - + + {error ? "Reconnecting" : workspace ? "Live workspace" : "Connecting"} - +
{workspace.principal.role === "maintainer" && ( - + )}
{page === "overview" && ( @@ -498,7 +503,7 @@ export default function App() { {agent.name} with {agent.owner} - + )) ) : ( diff --git a/ui/src/liquid/LiquidLeaf.tsx b/ui/src/liquid/LiquidLeaf.tsx new file mode 100644 index 0000000..3ba9c46 --- /dev/null +++ b/ui/src/liquid/LiquidLeaf.tsx @@ -0,0 +1,177 @@ +import { createElement, useCallback, useEffect, useRef, useState } from "react"; +import type { CSSProperties, PointerEvent, ReactNode } from "react"; +import "./liquid-leaf.css"; + +/* Liquid Leaf: React bindings for the surface described in liquid-leaf.css. + * + * mounts the SVG filters (goo, wobble) once per document + * a surface; variants map to the CSS classes + * useLeafLight moves the specular highlight toward the pointer + * useLeafPress squash on press, spring on release, wobble on landing */ + +export function LeafDefs() { + return ( + + ); +} + +/** Specular highlight follows the pointer; snaps back when it leaves. */ +export function useLeafLight() { + const ref = useRef(null); + const onPointerMove = useCallback((event: PointerEvent) => { + const node = ref.current; + if (!node) return; + const box = node.getBoundingClientRect(); + const x = ((event.clientX - box.left) / box.width) * 100; + const y = ((event.clientY - box.top) / box.height) * 100; + node.style.setProperty("--leaf-light-x", `${x.toFixed(1)}%`); + node.style.setProperty("--leaf-light-y", `${y.toFixed(1)}%`); + }, []); + const onPointerLeave = useCallback(() => { + const node = ref.current; + if (!node) return; + node.style.removeProperty("--leaf-light-x"); + node.style.removeProperty("--leaf-light-y"); + }, []); + return { ref, onPointerMove, onPointerLeave }; +} + +/** Squash while pressed; on release play the spring and a short wobble. */ +export function useLeafPress() { + const [state, setState] = useState<"rest" | "pressed" | "releasing">("rest"); + const timer = useRef | null>(null); + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); + const onPointerDown = useCallback(() => setState("pressed"), []); + const release = useCallback(() => { + setState((current) => (current === "pressed" ? "releasing" : current)); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setState("rest"), 650); + }, []); + const className = + state === "pressed" + ? "is-pressed" + : state === "releasing" + ? "is-releasing is-wobbling" + : ""; + return { + className, + onPointerDown, + onPointerUp: release, + onPointerCancel: release, + }; +} + +type Variant = "button" | "pill" | "card" | "dot"; +const TAGS: Record = { + button: "button", + pill: "span", + card: "div", + dot: "span", +}; + +export function Leaf({ + variant, + className = "", + children, + live, + off, + style, + ...rest +}: { + variant: Variant; + className?: string; + children?: ReactNode; + /** dot only: breathe while live */ + live?: boolean; + /** dot only: dry, sandy colour when offline */ + off?: boolean; + style?: CSSProperties; + disabled?: boolean; + onClick?: () => void; + title?: string; + "aria-label"?: string; +}) { + const light = useLeafLight(); + const press = useLeafPress(); + const interactive = variant === "button"; + const classes = [ + "leaf", + `leaf-${variant}`, + live ? "is-live" : "", + off ? "is-off" : "", + interactive ? press.className : "", + className, + ] + .filter(Boolean) + .join(" "); + return createElement( + TAGS[variant], + { + ...rest, + ref: light.ref, + className: classes, + style, + ...(variant === "button" ? { type: "button" } : {}), + ...(variant === "button" || variant === "card" + ? { + onPointerMove: light.onPointerMove, + onPointerLeave: (event: PointerEvent) => { + light.onPointerLeave(); + if (interactive) press.onPointerCancel(); + void event; + }, + } + : {}), + ...(interactive + ? { + onPointerDown: press.onPointerDown, + onPointerUp: press.onPointerUp, + } + : {}), + }, + children, + ); +} diff --git a/ui/src/liquid/liquid-leaf.css b/ui/src/liquid/liquid-leaf.css new file mode 100644 index 0000000..464dc36 --- /dev/null +++ b/ui/src/liquid/liquid-leaf.css @@ -0,0 +1,304 @@ +/* Liquid Leaf + * + * A translucent, glossy, slightly gooey green surface for the pieces of the + * workspace that should feel alive: presence, primary actions, live state. + * It is what "liquid glass" becomes outdoors: sap and leaf instead of glass and + * chrome. Borrowed from liquid-dom's glass model (an SDF rim with a specular + * band, blurred backdrop, corner smoothing) and rebuilt with CSS and SVG so it + * works in every current browser without WebGPU. + * + * Layers, back to front: + * backdrop-filter frosts whatever is behind the surface + * background the sap: a layered green gradient with a bright meniscus + * ::before specular highlight, a soft ellipse that follows the light + * ::after rim light on the top edge and a shadow line on the bottom + * + * Two palettes: outdoor (default, daytime, grass) and indoor + * ([data-scene="indoor"], night, moss under lamplight). */ + +:root, +[data-scene="outdoor"] { + --leaf-sprout: #b8ec9c; + --leaf-grass: #7dc395; + --leaf-leaf: #58aa72; + --leaf-moss: #3d7a52; + --leaf-ink: #1f3d2b; + --leaf-text: #16301f; + --leaf-glow: rgba(125, 195, 149, 0.45); + --leaf-shadow: rgba(31, 61, 43, 0.22); + --leaf-shine: rgba(255, 255, 255, 0.85); + --leaf-frost: 12px; + --leaf-light-x: 28%; + --leaf-light-y: 18%; +} +[data-scene="indoor"] { + --leaf-sprout: #9ad6a6; + --leaf-grass: #4c8f66; + --leaf-leaf: #2f6a49; + --leaf-moss: #1f4a34; + --leaf-ink: #0f2418; + --leaf-text: #e8f4ea; + --leaf-glow: rgba(154, 214, 166, 0.3); + --leaf-shadow: rgba(0, 0, 0, 0.5); + --leaf-shine: rgba(214, 245, 220, 0.75); +} + +/* ---------------------------------------------------------------- surface */ +.leaf { + --leaf-radius: 16px; + position: relative; + isolation: isolate; + color: var(--leaf-text); + border: 0; + border-radius: var(--leaf-radius); + corner-shape: squircle; + background: + /* meniscus: the bright bead where the sap meets the rim */ + radial-gradient( + 120% 140% at 50% -40%, + var(--leaf-shine) 0%, + rgba(255, 255, 255, 0.35) 22%, + transparent 46% + ), + /* depth: darker toward the bottom edge, like a full glass */ + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.28) 0%, + rgba(255, 255, 255, 0) 38%, + rgba(0, 0, 0, 0) 70%, + rgba(0, 40, 20, 0.16) 100% + ), + /* the sap itself */ + linear-gradient( + 160deg, + color-mix(in oklab, var(--leaf-sprout) 78%, transparent) 0%, + color-mix(in oklab, var(--leaf-grass) 82%, transparent) 45%, + color-mix(in oklab, var(--leaf-leaf) 86%, transparent) 100% + ); + -webkit-backdrop-filter: blur(var(--leaf-frost)) saturate(1.35); + backdrop-filter: blur(var(--leaf-frost)) saturate(1.35); + box-shadow: + /* rim light along the top */ + inset 0 1px 0 rgba(255, 255, 255, 0.7), + /* thickness on the bottom edge */ inset 0 -1px 0 rgba(0, 40, 20, 0.2), + /* ambient lift */ 0 1px 2px var(--leaf-shadow), + /* coloured glow into the page, the "wet" part */ 0 8px 24px -8px + var(--leaf-glow); + transition: + transform 0.45s cubic-bezier(0.2, 0, 0, 1), + box-shadow 0.35s cubic-bezier(0.2, 0, 0, 1), + filter 0.35s ease; +} +/* specular highlight: an ellipse that sits where the light is */ +.leaf::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + corner-shape: inherit; + pointer-events: none; + background: radial-gradient( + 38% 28% at var(--leaf-light-x) var(--leaf-light-y), + rgba(255, 255, 255, 0.55) 0%, + rgba(255, 255, 255, 0.18) 40%, + transparent 70% + ); + mix-blend-mode: soft-light; + transition: + background-position 0.4s ease, + opacity 0.4s ease; +} +/* rim: a 1px light band that fades from the lit corner to the shaded one */ +.leaf::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + corner-shape: inherit; + pointer-events: none; + padding: 1px; + background: linear-gradient( + 150deg, + rgba(255, 255, 255, 0.85) 0%, + rgba(255, 255, 255, 0.25) 40%, + rgba(0, 40, 20, 0.08) 75%, + rgba(0, 40, 20, 0.28) 100% + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} +.leaf:hover { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.8), + inset 0 -1px 0 rgba(0, 40, 20, 0.2), + 0 2px 4px var(--leaf-shadow), + 0 12px 30px -8px var(--leaf-glow); +} +/* press: sap squashes, then springs back (see spring() in LiquidLeaf.tsx) */ +.leaf:active, +.leaf.is-pressed { + transform: scale(0.97, 0.94); + transition-duration: 0.08s; +} +.leaf.is-releasing { + animation: leaf-spring 0.6s + linear( + 0, + 0.2178 2.1%, + 1.1052 8.7%, + 1.2103 11.4%, + 1.2181 13.2%, + 1.1729 15.6%, + 1.0282 21.4%, + 0.9575 25.3%, + 0.9327 29.4%, + 0.9463 33.6%, + 1.0143 43.5%, + 1.0091 49.4%, + 0.9963 59.3%, + 0.9977 69.3%, + 1.0011 82.4%, + 1 + ) + both; +} +@keyframes leaf-spring { + from { + transform: scale(0.97, 0.94); + } + to { + transform: scale(1, 1); + } +} + +/* ------------------------------------------------------------- variants */ +.leaf-button { + --leaf-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 36px; + padding: 9px 16px; + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1px; + cursor: pointer; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35); +} +[data-scene="indoor"] .leaf-button { + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.3); +} +.leaf-button:disabled { + opacity: 0.55; + cursor: not-allowed; + filter: saturate(0.6); +} +.leaf-button.small { + min-height: 30px; + padding: 5px 12px; + font-size: 10px; +} + +.leaf-pill { + --leaf-radius: 999px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 11px 4px 8px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; +} + +.leaf-card { + --leaf-radius: 18px; + padding: 18px 16px; +} + +/* a presence dot: a drop of sap with a highlight, breathing while live */ +.leaf-dot { + --leaf-radius: 50%; + corner-shape: round; + --leaf-light-x: 34%; + --leaf-light-y: 30%; + width: 10px; + height: 10px; + display: inline-block; + flex-shrink: 0; + -webkit-backdrop-filter: none; + backdrop-filter: none; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.8), + 0 0 0 3px color-mix(in oklab, var(--leaf-grass) 22%, transparent), + 0 2px 6px -1px var(--leaf-glow); +} +.leaf-dot.is-live { + animation: leaf-breathe 2.6s ease-in-out infinite; +} +@keyframes leaf-breathe { + 0%, + 100% { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.8), + 0 0 0 3px color-mix(in oklab, var(--leaf-grass) 22%, transparent), + 0 2px 6px -1px var(--leaf-glow); + } + 50% { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.8), + 0 0 0 5px color-mix(in oklab, var(--leaf-grass) 14%, transparent), + 0 3px 10px -1px var(--leaf-glow); + } +} +.leaf-dot.is-off { + background: linear-gradient(160deg, #d9c9a8, #b9a37b); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.7), + 0 0 0 3px rgba(185, 163, 123, 0.2); +} + +/* -------------------------------------------------------------- liquids */ +/* Goo: children of a .leaf-goo merge into one blob wherever they touch. + * Classic metaball trick: blur, then sharpen alpha with a colour matrix. */ +.leaf-goo { + filter: url("#leaf-goo"); +} +/* Wobble: a low-frequency displacement, switched on briefly by JS so the + * surface shivers like a drop that has just landed. */ +.leaf.is-wobbling { + filter: url("#leaf-wobble"); +} +/* The filter definitions live in ; this keeps them out of layout. */ +.leaf-defs { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .leaf, + .leaf::before { + transition: none; + } + .leaf.is-releasing, + .leaf-dot.is-live { + animation: none; + } + .leaf.is-wobbling { + filter: none; + } +} +@supports not (backdrop-filter: blur(1px)) { + .leaf { + background-color: color-mix(in oklab, var(--leaf-grass) 92%, #fff); + } +} diff --git a/ui/src/style.css b/ui/src/style.css index 8ddbe3e..11b0776 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -200,12 +200,8 @@ code { padding-top: 40px; } .bring-agent { - background: #e5e9dd; - border: 1px solid #dce2d2; - border-radius: 12px; padding: 18px 16px; margin-bottom: 18px; - position: relative; overflow: hidden; } .bring-agent > .agent-symbol { @@ -221,13 +217,14 @@ code { letter-spacing: -0.35px; } .bring-agent p { - color: #56624f; + color: var(--leaf-ink); font-size: 11px; line-height: 1.7; margin: 8px 0 14px; } .bring-agent button { - border-top: 1px solid #d0d8c6; + border-top: 1px solid rgba(31, 61, 43, 0.18); + color: var(--leaf-ink); width: 100%; padding: 12px 0 0; font-size: 11px; @@ -310,23 +307,14 @@ code { gap: 18px; } .live-pill { - display: flex; - align-items: center; - gap: 7px; font-size: 10px; - color: #55634f; - white-space: nowrap; } -.live-pill > span { - width: 5px; - height: 5px; - background: #6f975d; - border-radius: 50%; - box-shadow: 0 0 0 3px #e9efe3; +.live-pill .leaf-dot { + width: 8px; + height: 8px; } -.live-pill.is-offline > span { - background: #ac6b32; - box-shadow: 0 0 0 3px #f3e9da; +.live-pill.is-offline { + filter: saturate(0.45) sepia(0.35); } .icon-button { display: inline-flex;