Styling and accessibility
Apply Cloud's visual and interaction rules without forking shared primitives.
Use this page when composing or reviewing a Cloud screen. Start with the shared component guidance before adding local markup or CSS.
The ownership boundary is deliberate:
@k2b/uiowns portable components, interaction behavior, component states, and their scoped styles.- Cloud owns product composition, application shells, app identity, and the integration of shared UI into light and dark themes.
- An application owns its domain content, copy, and layout where no shared component contract applies.
Build hierarchy before decoration
Cloud uses a quiet canvas and a small number of neutral work surfaces. App identity is strongest in the rail and workspace identity; ordinary content stays neutral and readable.
Apply these rules in order:
- Remove decoration that does not explain structure or behavior.
- Group with spacing, alignment, typography, and shared surfaces.
- Use color only for identity, action, status, selection, or data.
- Match density to the task: compact navigation, scannable data, and more space for forms and reading.
- Design loading, empty, error, hover, focus, selected, disabled, mobile, and dark states as part of the same component contract.
Keep screens clean and flat. Put the most important value first, use one type scale per page, and leave out data the person does not need at that moment. Do not place a box inside a box. A flat screen lets the content carry the hierarchy, so a person finds the value they came for without reading the frame around it. Flatness never justifies weaker usability, contrast, or keyboard behavior: check those again after you remove decoration.
Tables, states, and moved paths
DataTable follows the minimal Grids reference: no grid, zebra striping, or row lines; 44px rows (40px compact), a hover fill, a small muted header without a line, and numbers aligned right with tabular figures. Only tables with more than 8 columns get row hairlines, automatically. Do not add custom row borders.
Use row-height loading placeholders and distinguish empty, no results, and
no access without adding a box. Every load error offers Try again through
empty-state recovery with state="error" and onRetry;
keep the person's selection. A moved familiar path gets a one-time
MovedHint at the old place, only for people who may
use the new path. Mail is the agreed exception and shows no hint there.
Group content without decorative lines
Do not use lines to group ordinary application content. This includes
<hr>, divide-y, full-width border-t or border-b, pseudo-element rules,
and inset-shadow hairlines. Making a line thinner, lighter, shorter, or more
transparent does not change its role.
Do not add these separators between list rows, cards, settings, detail sections, menu items, form sections, metadata groups, empty states, or pagination. Use whitespace, alignment, a quiet shared surface, or a short semantic section label. Prefer one clear group over nested papers; do not replace a removed line with a box or hover fill around every row.
Do not draw a line between the header, content, and footer of an app-owned surface, such as a dialog, panel, card, or page section. Spacing and type already show where the header ends.
App code draws no vertical or horizontal borders between workspace regions
either: not between sidebar, list, and detail, not under header rows, and not
at the edge between the app rail and the application. The regions separate
through the surfaces, spacing, and edges that AppWorkspace, Panes, and
SettingsModal own. A line that app code adds between regions splits one
workbench into separate boxes and competes with the content for attention.
To verify, look at the changed screen in light and dark mode and search the
diff for border-t, border-b, border-l, border-r, divide-, and <hr>
in app-owned markup.
Boundaries are valid when they explain how a shared component operates:
DataTableadds row hairlines automatically only above 8 columns.- A compound control may separate functional parts.
- A resizable layout may expose an interactive separator.
- A shared workspace layout may mark the edge between adjacent panes or a drawer.
- The app bar separates its shortcuts from the ordinary apps with one group divider.
The shared table, control, or layout primitive owns those boundaries. App code
must not invent an exception. If content needs explicit row boundaries to be
understood, model it as tabular data and use DataTable.
Keep color roles independent
Do not use one color for unrelated meanings.
- App identity marks the active app, workspace, and selected resource.
- Actions use the shared action hierarchy and focus treatment.
- Status uses information, success, warning, and danger semantics.
- Data colors distinguish domain values and remain understandable without color alone.
An app accent must not recolor every primary button. A red app must not make normal navigation look destructive. Status color must not identify an app.
A selected navigation row or a row for an opened or picked object or place uses the app's selection fill and ink. A selected tab, view tab, or segment and an active filter chip share the navigation fill: like a selected row, they mark where the person is or what they picked. Controls keep platform blue: buttons, inputs, checkboxes, radios, switches, and focus rings, plus select and keyboard-active options, pressed toolbar buttons, and checked cards. Link text stays body ink; only its underline follows the app ink. Only the one main action is a filled button; a chosen view or filter is never drawn as one. Status keeps its status colors. Colors and selection lists every state and its primitive.
In the Cloud repository, the selection-tokens check rejects new platform
fills, raw --app-accent color mixes, the raw --app-accent as text ink,
border color, or focus outline, and a button whose variant switches to
primary when it is chosen. App ink uses --ui-app-accent-text, and focus
rings use the platform focus. Control and status uses belong in its allowlist
with a reason.
Use shared components first
Import @k2b/ui/global.css once through the application build and render shared
components below a .k2b-ui scope. It includes the component styles and the
supported font and icon presets. Applications with their own assets may use the
granular styles.css, fonts/plex.css, and icons/tabler.css exports instead.
Do this in the third-party application's own browser bundle; do not depend on
styles or source files from a built-in app.
Choose the public component that owns the required appearance and behavior:
- Use
Button,ButtonLink,IconButton, orIconButtonLinkfor actions and action links. Express hierarchy with the component'svariantprop. - Use
TextInput,Select,MultiSelectInput, and the other shared input components for controlled fields and their validation states. - Use
DropdownorContextMenufor menu behavior instead of assembling a local trigger and popup. - Use
AppWorkspace.SidebarItemand its compound members for workspace navigation rows. - Use
PanelDialog,AppWorkspace,Panes,DataTable, and the other layout primitives for the geometry and interaction they document. - Use
InlineGuidance,Placeholder,NotFoundState,NoticeCard, andStatusBadgefor their specific feedback and status roles. - Use
Paperfor one neutral application-owned group when no more specific shared surface owns the content. It deliberately leaves padding and layout to the application.
For example, render a primary action as a component rather than recreating it with a class:
import { createSignal } from "solid-js";
import { Button, TextInput } from "@k2b/ui";
export function ProjectForm(props: { onSave: (name: string) => void }) {
const [name, setName] = createSignal("");
return (
<div>
<TextInput label="Project name" value={name()} onValueChange={setName} />
<Button variant="primary" onClick={() => props.onSave(name())}>
Save project
</Button>
</div>
);
}Do not recreate these contracts with classes such as btn-primary, input,
sidebar-item, or focus-ui. Those Cloud classes support existing product
integration; they are not an alternative component API for new controls.
Third-party applications use Paper rather than depending on Cloud's internal
paper utility. A built-in Cloud application may keep the utility for
app-owned grouping that has no more specific shared surface. Do not copy the
internal markup, selectors, or CSS of a shared component.
Style app-owned content semantically
Inside a Cloud application, use app-accent-text and app-accent-border
sparingly for app identity. They are Cloud integration utilities, not
standalone @k2b/ui APIs and not general action or status colors.
app-accent-text uses --ui-app-accent-text, the app ink that meets WCAG AA
contrast on Cloud's theme and selection surfaces. --ui-app-selected and
--ui-app-selected-nav provide the app's selection fills on the main surface
and the sidebar. Cloud maps these fills to --k2b-row-selected and
--k2b-app-workspace-selected, with --k2b-app-workspace-active for
navigation ink. Tabs, view tabs, segmented controls, and active filter chips
share the navigation fill; other controls and options retain their platform
colors.
When app-owned CSS is necessary, use the semantic theme variables already
provided by the host. The host's --ui-focus is a complete box-shadow: use
var(--ui-focus) only as a box-shadow value, because the browser drops a
border, outline, background, or color-mix() declaration that uses it as a
color. A standalone @k2b/ui consumer can configure the
documented --k2b-* tokens on its scoped root.
Avoid fixed light backgrounds, black text, hardcoded app colors, and arbitrary borders. They break dark mode, focus treatment, or application theming. Do not override a shared component to make one screen look different; fix a recurring gap in the owning primitive and update its UI context and showcase.
Mix variable colors in separate rules
In a standalone application, give each color-mix() that references a CSS
variable or currentColor its own rule in src/styles/app.css. Until a Bun
release includes oven-sh/bun#38589,
the @k2b/cloud build keeps only one such value per rule. The others show
Tailwind's fallback, usually the first color of the mix. Mixes of literal
colors are computed at build time and are not affected.
/* Avoid: only the background keeps its mixed color. */
.project-highlight {
background: color-mix(in oklab, var(--k2b-action) 12%, var(--k2b-surface));
border-color: color-mix(in oklab, var(--k2b-action) 40%, var(--k2b-surface));
}
/* Use: both keep their mixed colors. */
.project-highlight {
background: color-mix(in oklab, var(--k2b-action) 12%, var(--k2b-surface));
}
.project-highlight {
border-color: color-mix(in oklab, var(--k2b-action) 40%, var(--k2b-surface));
}The same applies to @apply: an opacity modifier on a variable or
currentColor color, such as bg-(--k2b-action)/12, compiles to such a
color-mix(). Split @apply bg-(--k2b-action)/12 border-(--k2b-action)/40
into one rule per utility. Used as classes in markup, the same utilities are not
affected, because each class compiles to its own rule.
A custom property that holds a mixed color counts as such a declaration. To
reuse a mixed color, declare it in its own rule and read it with var():
.project-highlight {
--project-highlight-border: color-mix(in oklab, var(--k2b-action) 40%, var(--k2b-surface));
}
.project-highlight {
background: color-mix(in oklab, var(--k2b-action) 12%, var(--k2b-surface));
border: 1px solid var(--project-highlight-border);
}Render Mermaid diagrams with Cloud colors
Initialize Mermaid with mermaidConfig from @k2b/cloud/browser/mermaid
instead of Mermaid's built-in themes. Pass the current theme, and render again
after a theme change:
import { mermaidConfig } from "@k2b/cloud/browser/mermaid";
import mermaid from "mermaid";
mermaid.initialize(mermaidConfig({ dark: document.documentElement.classList.contains("dark") }));
const { svg } = await mermaid.render(id, source);The configuration uses Cloud's surface, border, and text colors. It keeps
securityLevel: "strict" and ignores diagram directives that change the
theme, colors, CSS, or security settings. Flowchart, class, state, ER, and
requirement diagrams use the dagre layout. An author can set layout: elk in
a diagram's front matter; the ELK layout code then loads only for that diagram.
On Safari, diagrams need Safari 17.4 or later.
Choose surfaces deliberately
- Use one surface to group related content; do not stack papers to manufacture hierarchy.
- Take depth from the shared surface. An in-flow surface gets its depth from
the surface step and inset shading of the shared component, such as the
quiet well of an input, never from an outer shadow, a nested border, or a
box inside a box. Floating layers, such as dialogs, popovers, menus, and
toasts, keep their soft outer shadow, because they sit above the page and
must read as separate from it. Shared components that own their frame,
shadow, or inner sections, such as the workspace card, an
elevatedPaper, or a floatingPanelDialog, keep them; app code does not add its own. - Keep one visible dialog frame. With
PanelDialog, the header and footer stay fixed whilePanelDialog.Bodyowns scrolling. - Inputs are quiet wells with a clear focus state, not permanently emphasized cards.
- Use shared radius and spacing families. Nested frame, surface, and control geometry should remain visually distinct.
AppWorkspace is one clipped workbench. Sidebar, main, detail, and bottom
drawer are sibling regions inside that frame, not adjacent cards. Use
AppWorkspace.Detail, AppWorkspace.MainPane, AppWorkspace.BottomDrawer,
and Panes for their documented roles instead of recreating their geometry.
See Application shells.
Keep the layout still
A Cloud screen has no layout shift: a state change never moves or resizes the content around it. This applies to focus, hover, pressed, and selected states; loading and async results; validation messages; unsaved-changes and save bars; badges and counts; fonts and icons that load late; and scrollbars that appear or disappear. Content that jumps makes a person lose their place and can turn a click into a click on the wrong target.
- Reserve the space for a state before it appears, for example the row for a validation message, the size of a loading result, or the slot of a badge.
- Draw focus with an outline or an inset ring, not with a border or added padding. Shared components already do this; see Focus rings.
- Change the color of an existing border, never its width.
- Never fix clipping with margins or padding. Use
k2b-focus-insetor the shared component that owns the clipping region. - For scrollbars, follow Keep dynamic scroll regions stable.
To verify, decide which neighbors must stay put, then compare their bounding
boxes before and after each state change, at desktop width and at phone width.
A browser test can read them with getBoundingClientRect() and expect them to
be equal. When a person opens something on purpose, such as the formatting
row of a composer, the opened region may grow: test that it grows in the
intended direction and that content anchored to it follows in the same frame
instead of jumping. The test "Aa adds one row above the
field; … never move the field, a control, or the newest message" in
packages/ui/src/chat/chat-composer.browser.test.ts shows the pattern: it
toggles each state at 720 px and 360 px and checks that the field and every
control keep their position. While the formatting row opens, it also checks in
every frame that the newest message stays directly above the composer.
Respond to hover without movement
Hover and focus never move, resize, or lift an element: no translate, no scale, and no growing shadow. Paint changes are fine, such as a color, a focus ring, or an action or affordance that the shared component reveals in space it already reserves. Movement under the pointer makes a target harder to hit and draws attention away from the content. This is the general form of the rule that cards and file drop targets already follow.
To verify, hover and focus the element and compare its bounding box with the
resting state. Search the changed CSS for transform, translate, scale,
and rotate inside :hover and :focus rules, and for an outer box-shadow
inside :hover rules. A focus ring drawn with box-shadow, such as the inset
ring that Focus rings describes, is paint, not movement. In
the Cloud repository, the hover-motion repository check rejects these
patterns in package sources, including Tailwind variants such as
hover:shadow-sm and group-hover:translate-x-0.5.
Move with the shared motion tokens
Every duration and curve comes from the --k2b-motion-* and --k2b-ease-*
tokens of @k2b/ui. Set the state first and animate after it: a result never
waits for an animation. Animate transform, opacity, and color, never width,
height, padding, margin, or position. Nothing animates on first paint or on
mount, and popovers and menus close at once. Reduced motion makes all of it
instant. Motion lists the tokens, their uses, and the
motion repository check.
Keep dynamic scroll regions stable
Let normal page content grow with the document. When a region is already
height-constrained and may cross its overflow boundary, use
ScrollArea or the scrolling part of the shared
component that owns the region. Its stable scrollbar gutter keeps text,
controls, and aligned columns from moving horizontally when a scrollbar
appears or disappears.
Treat the content length as dynamic when it can change through:
- filters, search results, tabs, or view switches;
- disclosures, expandable sections, validation messages, or optional fields;
- pagination, incremental loading, or live updates;
- user-created, user-edited, or otherwise unbounded domain content.
The surrounding layout still owns the region's height, flex behavior, padding,
and spacing. Keep one scroll owner for each full-height region. Do not wrap
DetailPanel.Body, PanelDialog.Body, DataTable, ChatTimeline,
VirtualFeed, Timeline, or another component that already owns scrolling
in an additional ScrollArea. Do not use it for a horizontal-only strip or
add a bounded scroll region where the page should grow naturally.
For a long feed that loads more items at either end and must keep the reading
position, such as messages or activity, use
VirtualFeed instead of a hand-made list. It
renders only the items near the visible area and keeps the visible item in
place while pages load and rows change size.
For messages in a conversation, render each item with
MessageRow, which groups messages by author,
renders safe Markdown, and keeps every row's height from the moment it mounts.
For dated items across days, such as meetings and deadlines, where duration
and free time matter, use Timeline. It loads days
at both ends and keeps what the reader sees in place.
When a standalone scroll region needs to be announced, give it an appropriate landmark and accessible name. Add a tab stop only when keyboard users would otherwise have no focusable content through which to reach the scrollport.
Keep controls and feedback consistent
A shared control owns its resting, hover, focus, active, selected, disabled, loading, error, and dark treatments.
- Use
primaryfor the main forward or write action anddangeronly for a destructive action. - Give icon-only controls an accessible name. Add the shared tooltip when the visible context does not explain the action.
- Keep one continuous, visible focus indicator. Do not stack unrelated border and ring colors on the same edge.
- Keep focus rings whole. Shared containers that clip their content, such as
AppWorkspace,ScrollArea,DetailPanel, dialogs, andDataTable, draw the rings of the controls inside them within the controls. Addk2b-focus-insetto your own element that clips focusable content withoverflow. Never add padding or margins so that a ring fits. See Focus rings. - Progressive actions follow input mode: touch keeps them visible; mouse or
trackpad reveals them on hover, focus, or an open menu. Sidebar actions also
show on the current row, and close controls on the selected tab. Where any
pointer can hover, sidebar actions overlay their row's text, so touch keeps
that reveal for them. Before input,
any-hoversupplies the default. Addk2b-progressive-rowandk2b-progressive-actionto your own rows and actions. Custom rest rules need the media and attribute pair from Input mode; mode changes only opacity, visibility, or pointer-events of reserved boxes. Primary-pointer queries and Tailwind hover variants miss a tablet's secondary mouse. - Use
InlineGuidancefor a short local hint, action error, or loading message while the affected content remains visible. Use itsloadingprop for inline progress rather than assembling a spinner and text. - Use
Placeholderfor a region that is empty, loading, or failed. UseNotFoundStatefor a whole-page dead end or missing resource. - Use
NoticeCardfor a persistent finding and a toast for short confirmation. UseStatusBadgefor a compact health or lifecycle label.
The application owns feedback copy and recovery actions. Distinguish an empty
result from a failed request, and do not replace field validation or
domain-specific states with a generic Placeholder.
Choose feedback by scope
| Situation | Component |
|---|---|
| Invalid field value | The input's validation props |
| Local prerequisite, action error, or inline progress | InlineGuidance |
| Empty, loading, or failed content region | Placeholder |
| Persistent finding that needs emphasis | NoticeCard |
| Compact health or lifecycle label | StatusBadge |
| Brief confirmation after an action | Toast |
Keep static descriptions and metadata as ordinary text. Button loading belongs on the button; progress with a known completion percentage belongs in a progress indicator. Keep retry actions beside the feedback and preserve existing content when a refresh fails.
Treat phones as first-class
Design and verify every screen at phone width with touch, in WebKit as well as Chromium. A phone is a full Cloud client, not a fallback, and Safari and most iOS browsers run WebKit, so a screen checked only in a desktop Chromium is not checked.
Use Layout, AppWorkspace, dialogs, and shared sidebars for responsive
geometry. Content must not depend on pointer hover. Dialogs must fit the
viewport and keep their primary actions reachable.
A phone layout is a composed state, not a squeezed desktop layout. Move navigation and details into their shared mobile behavior, keep touch targets usable, and contain table overflow inside the table region. A standalone page shows its one card flat on phones; see Layout and navigation.
To verify, run the browser tests in both engines as described in Test in Chromium and WebKit, and check the primary flow at phone width with touch input.
Preserve keyboard and screen reader access
- Use native buttons for actions and anchors for navigation.
- Give icon-only controls an accessible name.
- Keep a visible focus state.
- Do not use color as the only status signal.
- Associate labels, descriptions, and errors with inputs.
- Keep heading order and landmarks meaningful.
- Return focus when a dialog closes. Shared overlays return it with a ring only when the keyboard opened or worked them; see Focus rings.
- Announce async changes when they are not otherwise visible.
Use the interaction behavior provided by the component that owns it. For
example, Panes owns its resize interaction and AppWorkspace.NavTree owns
tree keyboard navigation. NavTree.Item only forwards optional native drag
events; an application using them still owns the drag payload, permission
checks, drop behavior, mutation, and an equivalent keyboard path.
InlineGuidance loading supplies status, live-region, and busy semantics. Avoid
adding another live region around the same message. A danger tone alone does
not announce an error: add role="alert" when a newly appearing action error
needs immediate attention. Do not announce every historical error as an alert
when a list renders. Keep field errors associated with their inputs.
On a Cloud page, the OS reduced-motion setting or data-k2b-motion="reduced"
on <html> makes CSS transitions, animations, and scrolling inside .k2b-ui
instant and stops view transitions. Color may still change, movement does
not. A delay that only keeps a fast request from flashing a loading indicator
must keep its wait under reduced motion. Motion that your code drives, such as a smooth scrollIntoView() or
element.animate(), checks prefersReducedMotion() from @k2b/ui; see
Motion.
Do not add aria-grabbed as a substitute for keyboard-operable movement and
clear announcements.
Follow the person's theme
People choose Light, Dark, or Automatic. Automatic is the default
and follows the device's appearance. Cloud sets the <html> class to the
resolved light or dark before the first paint. Browser code reads that
class and observes class changes to follow live updates.
On the server, readThemeFromCookieHeader() from @k2b/cloud/shared returns
the resolved theme. For Automatic, it is only a best-effort hint: the head
script reports the device's scheme in the color-scheme cookie on each page
load and on each change, so the value can be stale when the device switched
while no Cloud page was open, and it is light before the first report.
readThemePreferenceFromCookieHeader() returns the choice itself: light,
dark, or system. When server-rendered markup depends on the theme, read
the preference: for system, render both variants and let the <html> class
show one, or decide in the browser. When server work such as rendering a chart
must match exactly, send the <html> class with the request. Set page.theme
to fix a public display to one theme. Never read prefers-color-scheme in
application CSS.
Verify both themes and interaction modes
Review every changed surface in light and dark mode. Check resting, hover, focus, active, selected, disabled, loading, empty, and error states.
Use automated accessibility checks as a baseline, then complete the keyboard flow manually. Test touch behavior when actions use progressive disclosure. The Frontend testing guide lists the full verification pass.
Review a changed screen
Before accepting a component or screen, verify:
- The layout stays still: focus, hover, loading, async results, validation, save bars, and badges move or resize nothing.
- Hover and focus never move, resize, or lift an element; they change only paint.
- Motion uses the shared tokens, animates no layout property, and is instant under reduced motion.
- Hierarchy works without decorative color or separator lines.
- Lists, settings, forms, details, and pagination use spacing or shared
surfaces instead of
<hr>,divide-y, or app-owned hairlines. - App code draws no lines between header, content, and footer, or between workspace regions.
- App-owned in-flow surfaces take depth from the shared surface; app code adds no outer shadow, nested border, or box inside a box.
- The screen is clean and flat: the most important value comes first, and data the person does not need is left out.
- The closest shared primitive owns geometry and interaction behavior.
- App identity, action, status, selection, and data colors remain independent.
- App-owned CSS uses semantic host tokens and does not copy a shared component.
- Hover, focus, active, selected, disabled, loading, empty, and error states are covered.
- Every action answers at once with an optimistic update or a pending state on the acted element, and offers Undo only where the server can reverse it.
- Progressive disclosure works with pointer, keyboard, and touch.
- Height-constrained dynamic content keeps a stable width as overflow changes.
- Desktop and mobile layouts avoid page-level overflow.
- Phone width works with touch in WebKit and Chromium.
- Light and dark modes preserve the same hierarchy.
- Icon-only actions have accessible names and useful focus treatment.
Show Cloud account avatars
Import CloudAvatar from @k2b/cloud/account/ui for Cloud account identity.
Pass the visible username, userId, and avatarHash; uploaded user pictures
use the account avatar endpoint with the hash as a revision. Without a picture,
the avatar uses initials.
kind defaults to "user" and renders a circle. "service_account" renders a
square robot icon; "system" renders a square automation icon. These two kinds
use their semantic icon even when an avatar hash is supplied. Cloud owns this
mapping; callers do not pass shape, icon, name, or src.
import { CloudAvatar } from "@k2b/cloud/account/ui";
<CloudAvatar username="Jana Berger" userId={user.id} avatarHash={user.avatarHash} size="sm" />
<CloudAvatar username="Stock bot" kind="service_account" size="sm" />type CloudAvatarKind = "user" | "service_account" | "system";
type CloudAvatarProps = Omit<AvatarProps, "name" | "src" | "shape" | "icon"> & {
username: string; userId?: string | null; avatarHash?: string | null; kind?: CloudAvatarKind;
};Other presentation props follow Avatar. For an
AvatarStack person, cloudAvatarSource(userId, avatarHash) from the same account UI entry point supplies the uploaded picture
URL, or undefined when either value is absent.