.agents/skills/ux/SKILL.md
How LobeHub products should feel, and concrete rules to get there. Use this when building or reviewing any user-facing flow.
This file is the index: the design values and interaction principles below are the conceptual layer; the execution checklists live in per-module reference files (see Checklist modules). Each checklist item is tagged with the design value(s) it serves.
Two documents, two jobs — don't duplicate; cross-reference.
DESIGN.md — the design system: what the product looks
and sounds like. Themeable tokens (color, typography, elevation, radius), the component
inventory, and Voice & Content (wording, tone). Reach for it when you need a token
value, a component, or copy tone.ux skill — interaction behavior: how a flow should behave over time.
Empty / loading / error states, lists at scale, selection visibility, pickers, number
formatting, draft safety, action flow & momentum, button hierarchy, entity lifecycle,
capability guardrails, progressive disclosure.Rule of thumb: static look & wording → DESIGN.md; dynamic behavior → this skill. For component/styling choices see react; for imperative modal wiring see modal.
LobeHub follows four product design values — Natural・Meaningful・Certainty・Growth. Read them before designing: references/design-values.md (definitions + conflict priority).
Use these before the execution checklists when a flow has multiple plausible interaction patterns.
Every surface carries a task promise: chat keeps the user in a working conversation, a document page supports focused reading / editing, a settings page supports configuration, and so on. Default interactions should continue that promise instead of unexpectedly moving the user into another mode. Prefer in-context surfaces (portal / panel / drawer) for reference and auxiliary work; reserve full-page navigation for committed focus or explicit mode switches.
Consistency means the same user intent behaves the same way in the same surface. It does not mean the same component must do the same thing everywhere. When a component is reused across surfaces, let the parent surface provide the interaction strategy so behavior follows intent rather than implementation convenience.
Element placement is part of the interface language. Identity and location (breadcrumbs, titles, object labels) should read separately from state and actions (save status, sharing, panel toggles, overflow menus). When these roles are mixed, users have to infer whether an element describes the current object or acts on it.
When a surface class already has a canonical component in this codebase — a sidebar row →
NavItem, a collapsible group → Accordion / GroupedAccordion, an active surface →
Block variant='filled' — compose it, don't rebuild the chrome from raw
<div>/<button>/<input> + a bespoke createStaticStyles block. A hand-rolled parallel
re-derives padding, hover/active states, alignment, and reveal-on-hover by hand, and drifts
from its siblings on each one — the aggregate reads as "unpolished" even when every single gap
is tiny. Before building a list / nav / master-detail panel, find the primitive the sibling
surface uses (grep NavItem, Accordion) and compose it; fall to raw elements only for a
genuinely novel row. See Read §1.10 for the full pattern; the
react component-priority rule covers the mechanics.
Grouped by interaction type — the kind of thing the user is doing. Jump to the module matching the surface you're building; a surface often spans several (an editable list is Read + Edit + Act) — walk each that applies.
The one-screen scan. Each line links back to a module above for the full rule + examples.
Read — viewing data & lists (read.md)
Empty component ships a search/no-match variant, wire it — don't render <Empty/> bare so a zero-result search shows the first-run onboarding.error, don't coerce data ?? [] → Empty); a detail page reads error before falling to NotFound (failed-to-load ≠ deleted/404). On a metrics/dashboard surface the failure default is a zero-valued object (?? {…:0}) that renders as a confident $0 — read error before any aggregate, don't fall through to zeros. A list merged from a fetched set + a static/frontend set ([...fetched, ...placeholders]) branches error before merging — the static half keeps length > 0, so a failed fetch renders a plausible partial catalog neither the empty guard nor an error-unread call site catches (channel).block: 'nearest', re-run after async rows mount).formatUsageValue / formatShortenNumber.shouldPoll → refreshInterval), not a function-form refreshInterval that never schedules a first timer when its initial value is 0.NavItem (+ Accordion / GroupedAccordion for groups, Block variant='filled' for active), not a hand-rolled <div>/<button>/<input> + bespoke CSS — else the hover/active highlight misaligns from the content box, content bleeds to the panel edge, the search/rename/action-reveal drift from every sibling panel, and the list stays a flat ungrouped dump. Grep NavItem before building.Edit — entering & changing content (edit.md)
resetFields() on selection change silently wipes unsaved input, worst when it's pasted secrets — channel).Act — operations, flows & buttons (act.md)
Result) carries an action: error → escape hatch (retry / back), success → close / go-to-result; no bare Result without extra, and "auto-closing in Ns" copy only when the close can actually fire.onOk) and shows progress on the originating surface (optimistic removal / row spinner), not a confirm dialog held spinning on the round-trip — the modal is not the progress surface.catch that only console.errors + optimistic-status rollback reads as a dead button; toast at the store-action boundary so every trigger inherits it.variant).Feedback — loading & system response (feedback.md)
Spin; use NeuralNetworkLoading / project loaders.isXError selector / retryX action with zero rg call sites) is still a missing error state — a built-but-orphaned path is a permanent skeleton at the pixel. A load-more / infinite-scroll page fetch that fails shows an inline Retry at the list tail (distinct from end-of-list), never a silently vanished "loading more" row with hasMore still true that an IntersectionObserver re-fires into a silent retry loop (topics).null / error) — never on the dependency being present in a map, or an absent-by-design dependency hangs it forever. An error branch ordered after a data-presence gate ({error && …} below if (isLoading = !map[id]) return <Skeleton/>) is unreachable on first-load failure — it paints only on revalidation, and a resolved-null not-found hangs the same permanent skeleton; check error/not-found before the gate.finally + offers retry — a failed write never permanently disables the forward/Back control.failed variant (a catch that resets to idle is a silent write); one save-feedback convention across a multi-field surface, ideally in the shared form wrapper.Grow — discoverability & progressive disclosure (grow.md)
1/2/3 chips, ⌘K badge, arrow-nav, keycap hint) actually wires those keys — or is restyled so it doesn't imply an absent shortcut; a keycap-looking chip with no handler is a false affordance, worst in a surface ported from a CLI. Confirm the keys fire at L3.createModal state-machine wiring for confirm/progress/done.Button usage, styling.