ryoku/shell/quickshell/, driven
by the ryoku-shell Go daemon under ryoku/shell/ipc/. This page is the
contract for changing or adding UI: where things live, which tokens to reach
for, and the motion language popouts have to honor.
Hands-on background: read docs/structure.md, docs/ui-ux.md, docs/frame.md,
and docs/conventions.md in the repository before a non-trivial change.
View and logic stay apart
The shell is split deliberately:- QML renders and animates. Each surface lives in its own directory under
ryoku/shell/quickshell/and each component is a single.qmlfile. ryoku-shelldecides. The Go daemon supervises the Quickshell components, owns wallpaper, clipboard, and lock state, registers as the GNOME keyring system prompter (ipc/prompter.go,ipc/secretexchange.go), and serves the control socket.
ryoku-shell or calls a named ryoku-* helper through
a narrow service. No ad hoc hyprctl calls from a component; for compositor
state, go through a service that wraps the IPC.
One component per file
The QML tree mirrors the surfaces fromdocs/ui-ux.md:
Compose with components; do not merge unrelated UI into one file. A new
concern is a new
.qml next to the surface it belongs to, not a branch inside
an existing component.
Shared tokens
Each surface ships its own singleton pack under<surface>/Singletons/ and
exposes them through a qmldir. They are the single source of truth for
colors, motion, and shell-wide configuration; literal one-off values are
opting out of theming.
In the pill (ryoku/shell/quickshell/pill/Singletons/qmldir):
Practical rules:
- Never hardcode a color, font, duration, or radius that should follow the
theme. Read from the surface’s singletons. Brand orange and the 力 mark are
fixed (
Theme.brand/#F25623); everything else is themed. - Motion lives in
Motion. Pickmorphfor the project’s signature morph,standardfor everyday transitions,fastfor snappy feedback. Match the curve already inpillrather than inventing a new one; consistency is the aesthetic. - Geometry that drives a motion (a morph progress, a clip width) is fine to compute; only the timing and curve must come from the tokens.
- When a value needs to become user-configurable, add it to
Config, expose it through Ryoku Settings, and make the view read the shared value.
Motion is the frame swelling open
The pill, the edge popouts, the screen border, and the centre surfaces share one signed-distance field (Ryoku.Blobs, the compiled QML plugin under
ryoku/shell/plugin/). Every popup that emerges from the frame must animate
as the frame expanding, never as a separate panel flying in. The hard rules,
from docs/frame.md:
- Pin the frame-side edge. Grow only the perpendicular dimension; clip the content and reveal it edge-first as the body widens.
- Fuse with a neck into the border. A
BlobRectbody in the sameBlobGroupextends a neck clamped to its own width, so it retracts in lockstep instead of snapping off as a flickering sliver. - Open with the morph curve, close with a spring. Open uses the project’s
cubic-bezier(0.16, 1, 0.3, 1)with no end-overshoot. Close uses a lightly-dampedSpringAnimation: it melts fully flush into the border, then springs back a touch, so the resting state stays flush.
- Sliding a full panel in from off-screen.
- Fading with
opacity. - Center-zoom of the whole panel.
- A standalone
BlobRectwhose width is held constant on close (it leaves a band wider than the island in the last frames; morph width down to the neck width).
Adding a popout
Popout.qml under pill/popouts/ is the reusable machinery: the blob body,
a content slot, the pixel-perfect edge hover trigger, and the reveal clip.
To add a new one (the same shape Mixer.qml and Power.qml use):
- Add
ryoku/shell/quickshell/pill/popouts/Foo.qml: a transparentItem(anchors.fill: parent, ansscale property) holding the content, readingThemeandMotionfor tokens and styling. The blob behind it is the surface, so do not paint a background. - In
pill/shell.qml, inside the overlay’s blob field, addPopout { group: blobGroup; frameThickness: 16; radius: 16; smoothing: 30; edge: "left"|"right"; Foo {} }. - Union the popout’s
triggerX/Y/W/HandbodyX/Y/W/Hinto the overlay input mask so its edge and open body catch input while the rest stays click-through. - Trigger by hover (built in). For a keybind, route the
ryoku-shellcommand totogglePopoutrather than a centre surface.
Adding a centre surface
Centre surfaces (the toolkit deck, the launcher, the keyring prompt, the voice panel) live inpill/ itself. They keep the same motion contract as edge
popouts (anchored to the pill, growing the blob field, no off-screen slide),
and they read state from the daemon over the shell socket. The view never
asks Hyprland or PipeWire directly; the daemon does, and the QML binds.
Layout discipline
Operational surfaces stay dense and predictable:- Use stable
width,height, orimplicit*sizing for repeated controls. - Use shared spacing tokens between rows and sections.
- Cards for repeated items, menus, modals, and framed tools; avoid nested cards.
- Keep text inside controls short enough to fit at narrow widths.
- Do not use viewport-scaled font sizes.
The dev loop
ryoku/shell/dev-run.sh launches the shell from the checkout via qs -p
with hot reload, so QML edits show as you save. Tune timing against the
running surface; do not eyeball it. dev-stop.sh releases the dev shell,
dev-binds.sh reloads Hyprland binds for a faster bind iteration.
For non-trivial changes touching the daemon or a shared service, trace the
flow from Config or IPC into every consumer rather than patching one visible
page. The shared tokens and the daemon are the contracts; everything else is
view.
