Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a514839
docs: ratify the empty-state tiers and loading protocol
jackwener Aug 9, 2026
cfa8256
refactor(settings): put the memory page's empty states on the shared …
jackwener Aug 9, 2026
d932df2
feat(mcp): load the installed list as row-shaped skeletons
jackwener Aug 9, 2026
8c51ecd
fix(desktop): give busy buttons native isLoading instead of dead stat…
jackwener Aug 9, 2026
3a01923
fix(onboarding): pulse the first-paint placeholder with real skeletons
jackwener Aug 9, 2026
fda58a3
fix(ui): conform empty states to the §10 tiers
jackwener Aug 9, 2026
597699d
fix(settings): give the search test native isLoading and one dialog s…
jackwener Aug 9, 2026
972aece
fix(styles): retire the phantom motion tokens and close the keyboard-…
jackwener Aug 9, 2026
641b49a
fix(styles): one reduced-motion rule for real users and two hover-was…
jackwener Aug 9, 2026
b16cf8b
refactor(ui): put every glyph on the five-rung icon scale
jackwener Aug 9, 2026
e18bbb0
refactor(styles): name the plate ladder and its fill convention
jackwener Aug 9, 2026
0fd26ce
refactor(settings): one right-edge cap for row end slots
jackwener Aug 9, 2026
d0afbf3
refactor(ui): one section-header system and a straightened type ladder
jackwener Aug 9, 2026
fae5a6a
refactor(styles): move the import dialog onto the product token contract
jackwener Aug 9, 2026
355f02b
refactor(settings): one QR frame spec for the paired dialogs
jackwener Aug 9, 2026
88ab3b2
feat(styles): one scrollbar recipe and quiet tab counters
jackwener Aug 9, 2026
0b29346
fix(styles): paint focus rings whole where ancestors clip them
jackwener Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,52 @@ The palette is cool-neutral and quiet; color is generated to spec, not picked by

Use Astryx primitives as the default seam. New work composes product meaning through published props, tokens, and stable `themeProps` extension points; internal-DOM overrides are acknowledged transitional states, not precedent.

- **Controls:** Maka uses a 20/24/28/32/36/40px height ruler with 32px as the default; Astryx owns the 28/32/36px variants. Hover is restrained; press may use `scale(0.98)`; keyboard focus is always visible. At most one inverted (filled) element per control.
- **Controls:** Maka uses a 20/24/28/32/36/40px height ruler with 32px as the default; Astryx owns the 28/32/36px variants. Hover is restrained; press may use `scale(0.98)`; keyboard focus is always visible. At most one inverted (filled) element per control. Hover washes come in exactly two lanes: product rows and controls take `--state-hover-bg`; chrome that must stay in lockstep with Astryx internals takes `--color-overlay-hover`. Hand-mixed hover alphas are drift.
- **Fields:** labels, descriptions, and validation belong to the field primitive; input focus belongs to its control. Keep disabled reasons discoverable through the owning control's tooltip; do not rebuild field chrome around a bare input.
- **Badges and status:** Badge is 20px high and pill-shaped. Choose semantic variants by meaning, not hue; use status dots for success, active, attention, error, or neutral.
- **Counters:** a count is one step smaller and quieter than its label (supporting role, muted ink, `tabular-nums`) and follows its parent's active state back to full ink. Never bolder than the label it counts.
- **Scrollbars:** one app-wide recipe — a 6px pill (10px hit area, 2px transparent inset), thumb at `--border-strong`, one step darker on hover, no painted track. Surfaces may hide their own bars; none may restyle them.
- **Cards:** Astryx Card uses container radius, 12px default padding, and no resting elevation. Astryx components own their geometry.
- **Workspace:** conversation, tool activity, artifacts, browser state, and generated files stay connected to the task that produced them. Assistant messages remain quiet and avatar-free.
- **Custom companion:** a desktop pet is the sole mascot exception: user-supplied, disabled by default, decorative, pointer-transparent, hidden from assistive technology, and reduced-motion aware. It never conveys required status or speaks for the agent.

## 10. Do's and Don'ts
## 10. Empty & Loading States

An absence and a wait are both states of real content, and both are composed from Astryx primitives — never hand-rolled.

### Empty states: three tiers, one component

Every empty state is Astryx `EmptyState`; the tiers are parameter combinations, not new components, wrappers, or product CSS re-creating its layout. The tier count is three and not arbitrary, because the component's props offer exactly two real steps — adding a description and adding an action. That is the spec's anti-inflation lock: a fourth tier is a component-change proposal, not a casual decision.

| Tier | Use | `icon` | `description` | `actions` | `isCompact` |
|---|---|---|---|---|---|
| 1 inline | a section's local absence | no | no | no | yes |
| 2 panel | a whole list/panel/inspector is empty | yes | yes | no | no |
| 3 first-run | the page's reason to exist has not happened yet | yes | yes | exactly one | no |

- **Tier 1 carries no icon.** Both reference systems converged on this independently: a local absence must not be amplified into an event — and it is what keeps three tiers three (an icon on tier 1 leaves only a description between it and tier 2).
- **There is no tier 4.** No second button, no extra link, no help caption below the action. Anything else worth saying goes in `description`.
- **No illustrations.** The `icon` slot takes an icon glyph only — never an illustration, large graphic, or brand mark.
- **Search/filter empties always carry a clear action** (`ghost` + `sm`), on any tier. This is usability, not decoration: the user is in a state they caused themselves and must be able to exit. The canonical example is the MCP market's no-match state. Ghost/small because clearing is an exit, not the page's main action.
- **Titles are noun phrases without a period**; `description` holds the full sentence and may echo the user's query. `headingLevel` follows the document-outline ladder (§9-adjacent; ratified in the typography chapter's hierarchy), never a pinned number.
- The chat first-run hero is the sanctioned exception: a prompt-suggestion hero exceeds tier 3's single action by design and owns its layout; nothing else does.

### Loading: reserve the ready geometry

The loading state occupies the same box the ready content will occupy — loading is the same box in an unready state, not filler. The hard criterion: switching from loading to ready causes zero layout shift. This is screenshot-verifiable and reviewed as such.

Three mutually exclusive forms, chosen by structural predictability — never by expected speed:

| Form | When | Geometry |
|---|---|---|
| skeleton | structure predictable (known rows/cards) | bar heights encode type (10/12/16); row count is that surface's measured ready-state constant, never a global default — a one-row skeleton grows the page on arrival, which violates the criterion above |
| spinner | structure unpredictable (single result, unknown-size body) | three placements only: inline (metadata icon size), page-centered large, or one quiet muted line inside a card (block-level unpredictable content only — lists always take skeletons) |
| `isLoading` | busy buttons and pressable controls | the Astryx prop, always — no hand-swapped labels, icons, or disable-plus-spinner recreations |

- One region never shows a skeleton and a spinner at the same time.
- A state attribute nobody reads is worse than none — it convinces the next reader that feedback already exists. Wire state markers (`data-pending` and kin) to visible feedback or delete them.

## 11. Do's and Don'ts

### Do:

Expand All @@ -198,6 +236,7 @@ Use Astryx primitives as the default seam. New work composes product meaning thr

- **Don't** write a bare `oklch()` status color or wash at a call site — consume the generated families (§8).
- **Don't** use `border-radius: 0` off a full-bleed row (§6).
- **Don't** put an illustration in an empty-state icon slot, add anything past tier 3's single action, or ship a state attribute with no visible feedback (§10).
- **Don't** hardcode `background: white` or any literal surface color — resolve a ladder tier (§2), or `--surface-paper` when the content's own contrast is not ours to control (foreign documents, QR codes) and inverting it would break the content rather than restyle it.
- **Don't** put more than one inverted element in a single control.
- **Don't** mix `srgb` and `oklch` derivations inside one token family (§3).
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/agent-graph-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
AgentGraphClientSnapshot,
} from '@maka/runtime';
import { IconButton } from '@maka/ui';
import { ChevronDown } from '@maka/ui/icons';
import { ICON_SIZE, ChevronDown } from '@maka/ui/icons';
import { Button } from '@astryxdesign/core/Button';

type GraphPanelCopy = {
Expand Down Expand Up @@ -247,7 +247,7 @@ export function AgentGraphPanel(props: {
className="maka-agent-graph-collapse-toggle"
label={collapsed ? copy.expand : copy.collapse}
tooltip={collapsed ? copy.expand : copy.collapse}
icon={<ChevronDown size={16} aria-hidden="true" />}
icon={<ChevronDown size={ICON_SIZE.chrome} aria-hidden="true" />}
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setCollapsed((current) => !current)}
Expand Down
34 changes: 16 additions & 18 deletions apps/desktop/src/renderer/artifact-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import {
ICON_SIZE,
AlertTriangle,
ArrowLeft,
FileCode,
Expand Down Expand Up @@ -405,19 +406,17 @@ export function ArtifactPane(props: {
<Banner
status="error"
className="maka-artifact-list-error"
icon={<AlertTriangle size={14} aria-hidden="true" />}
icon={<AlertTriangle size={ICON_SIZE.control} aria-hidden="true" />}
title={copy.pane.listLoadFailed}
description={activeListError}
endContent={(
<Button
variant="secondary"
size="sm"
onClick={() => void retryArtifactListRefresh()}
isDisabled={pendingArtifactListRetry}
aria-busy={pendingArtifactListRetry ? 'true' : undefined}
data-pending={pendingArtifactListRetry ? 'true' : undefined}
icon={<RefreshCcw size={13} aria-hidden="true" />}
label={pendingArtifactListRetry ? copy.pane.retrying : copy.pane.retry}
isLoading={pendingArtifactListRetry}
icon={<RefreshCcw size={ICON_SIZE.meta} aria-hidden="true" />}
label={copy.pane.retry}
/>
)}
/>
Expand Down Expand Up @@ -473,8 +472,7 @@ export function ArtifactPane(props: {
) : (
<AstryxEmptyState
className="maka-artifact-list-empty"
isCompact
icon={<FileText aria-hidden="true" />}
icon={<FileText size={ICON_SIZE.empty} aria-hidden="true" />}
{...emptyStateCopy}
/>
)
Expand All @@ -485,7 +483,7 @@ export function ArtifactPane(props: {
variant="ghost"
size="sm"
isIconOnly
icon={<ArrowLeft size={16} aria-hidden="true" />}
icon={<ArrowLeft size={ICON_SIZE.chrome} aria-hidden="true" />}
label={copy.pane.back}
onClick={returnToList}
/>
Expand All @@ -505,18 +503,18 @@ export function ArtifactPane(props: {
items={[
{
label: copy.pane.openInFinder,
icon: <FolderOpen size={14} aria-hidden="true" />,
icon: <FolderOpen size={ICON_SIZE.control} aria-hidden="true" />,
onClick: () => void runArtifactAction(`${previewRecord.id}:open`, () => openInFinder(previewRecord.id)),
},
{
label: copy.pane.saveAs,
icon: <Save size={14} aria-hidden="true" />,
icon: <Save size={ICON_SIZE.control} aria-hidden="true" />,
onClick: () => void runArtifactAction(`${previewRecord.id}:save`, () => saveAs(previewRecord.id)),
},
...(isTextKind(previewRecord.kind)
? [{
label: copy.pane.copy,
icon: <Copy size={14} aria-hidden="true" />,
icon: <Copy size={ICON_SIZE.control} aria-hidden="true" />,
onClick: () => void runArtifactAction(`${previewRecord.id}:copy`, () => copyText(previewRecord.id)),
}]
: []),
Expand All @@ -527,7 +525,7 @@ export function ArtifactPane(props: {
previewRecord.source === 'tool_result_archive'
? copy.pane.deleteReadOnly
: copy.pane.delete,
icon: <Trash2 size={14} aria-hidden="true" />,
icon: <Trash2 size={ICON_SIZE.control} aria-hidden="true" />,
isDisabled:
previewRecord.source === 'deep_research' ||
previewRecord.source === 'tool_result_archive',
Expand Down Expand Up @@ -595,15 +593,15 @@ function artifactActionErrorMessage(error: unknown, locale: UiLocale, copy: Arti
function KindIcon(props: { kind: ArtifactKind }) {
switch (props.kind) {
case 'file':
return <FileText size={14} />;
return <FileText size={ICON_SIZE.control} />;
case 'diff':
return <GitMerge size={14} />;
return <GitMerge size={ICON_SIZE.control} />;
case 'html':
return <FileCode size={14} />;
return <FileCode size={ICON_SIZE.control} />;
case 'image':
return <FileImage size={14} />;
return <FileImage size={ICON_SIZE.control} />;
case 'pdf':
return <FileType size={14} />;
return <FileType size={ICON_SIZE.control} />;
}
}

Expand Down
13 changes: 6 additions & 7 deletions apps/desktop/src/renderer/browser-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* ordinary chat reserves no space.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Globe, RotateCw, X } from '@maka/ui/icons';
import { ICON_SIZE, ChevronLeft, ChevronRight, Globe, RotateCw, X } from '@maka/ui/icons';
import { normalizeBrowserAddressInput, type BrowserState } from '@maka/core';
import {
IconButton,
Expand Down Expand Up @@ -149,7 +149,7 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
<Tooltip content={copy.back}>
<IconButton
label={copy.backAria}
icon={<ChevronLeft size={16} aria-hidden />}
icon={<ChevronLeft size={ICON_SIZE.chrome} aria-hidden />}
variant="ghost"
size="sm"
isDisabled={!state.canGoBack}
Expand All @@ -159,7 +159,7 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
<Tooltip content={copy.forward}>
<IconButton
label={copy.forwardAria}
icon={<ChevronRight size={16} aria-hidden />}
icon={<ChevronRight size={ICON_SIZE.chrome} aria-hidden />}
variant="ghost"
size="sm"
isDisabled={!state.canGoForward}
Expand All @@ -169,7 +169,7 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
<Tooltip content={state.loading ? copy.stop : copy.refresh}>
<IconButton
label={state.loading ? copy.stopAria : copy.refreshAria}
icon={state.loading ? <X size={16} aria-hidden /> : <RotateCw size={16} aria-hidden />}
icon={state.loading ? <X size={ICON_SIZE.chrome} aria-hidden /> : <RotateCw size={ICON_SIZE.chrome} aria-hidden />}
variant="ghost"
size="sm"
isDisabled={!state.hasPage && !state.loading}
Expand Down Expand Up @@ -205,7 +205,7 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
<Tooltip content={copy.close}>
<IconButton
label={copy.closeAria}
icon={<X size={16} aria-hidden />}
icon={<X size={ICON_SIZE.chrome} aria-hidden />}
variant="ghost"
size="sm"
onClick={() => void window.maka.browser.close(sessionId)}
Expand All @@ -216,10 +216,9 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
{!state.hasPage && (
<EmptyState
className="maka-browser-empty"
icon={<Globe aria-hidden="true" />}
icon={<Globe size={ICON_SIZE.empty} aria-hidden="true" />}
title={copy.title}
description={copy.description}
isCompact
/>
)}
</div>
Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/src/renderer/chat-message-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ProviderType,
type SettingsSection,
} from '@maka/core';
import { Skeleton } from '@astryxdesign/core';
import { Banner, Button, ChatView, useUiLocale } from '@maka/ui';
import { OnboardingHero } from './onboarding-hero';
import type { AppShellSessionUiState, AppShellSessionUiStateController } from './app-shell-session-ui-state';
Expand Down Expand Up @@ -126,16 +127,19 @@ export function ChatMessageSurface({
/>
</div>
) : isOnboardingLoading ? (
// @kenji review: render a no-op skeleton while the
// first snapshot resolves so EmptyChatHero doesn't
// flash. Use an aria-busy live region so screen
// readers know something is loading.
// Blocks EmptyChatHero from flashing while the first snapshot resolves.
// Astryx Skeleton bars (DESIGN.md §10) in the ready card's own frame —
// the hand-drawn static ::before/::after bars this replaces never pulsed,
// so the first screen a new user saw read as frozen.
(<div
className="maka-onboarding-loading"
role="status"
aria-busy="true"
aria-label={copy.loading}
/>)
>
<Skeleton width="52%" height={16} radius="rounded" index={0} />
<Skeleton width="78%" height={12} radius="rounded" index={1} />
</div>)
) : undefined;

return (
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/renderer/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ICON_SIZE,
ChevronRight,
CornerDownLeft,
} from '@maka/ui/icons';
Expand Down Expand Up @@ -151,11 +152,12 @@ export function CommandPalette(props: {
/>
)}
emptySearchText={(
/* Filter empty (DESIGN.md §10 tier 1): no clear action here — the
palette input itself is the exit from a no-match search. */
<EmptyState
role="presentation"
className="maka-palette-empty"
title={copy.emptyTitle}
description={copy.emptyDescription}
isCompact
/>
)}
Expand All @@ -167,17 +169,17 @@ export function CommandPalette(props: {
return (
<>
<span className="maka-palette-icon" aria-hidden="true">
<command.Icon size={15} />
<command.Icon size={ICON_SIZE.chrome} />
</span>
<span className="maka-palette-label">{command.label}</span>
{command.hint ? (
<span className="maka-palette-hint">
{command.hint}
<ChevronRight size={12} aria-hidden="true" />
<ChevronRight size={ICON_SIZE.meta} aria-hidden="true" />
</span>
) : (
<span className="maka-palette-hint maka-palette-cursor" aria-hidden="true">
<CornerDownLeft size={12} />
<CornerDownLeft size={ICON_SIZE.meta} />
</span>
)}
</>
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { Component, type ErrorInfo, type ReactNode } from 'react';
import type { UiLocale } from '@maka/core';
import { AlertTriangle, Check, Clipboard, RotateCw } from '@maka/ui/icons';
import { ICON_SIZE, AlertTriangle, Check, Clipboard, RotateCw } from '@maka/ui/icons';
import { Button as UiButton, Card, redactSecrets } from '@maka/ui';
import { getShellCopy } from './locales/shell-copy.js';

Expand Down Expand Up @@ -115,7 +115,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLo
keeps only the icon/copy grid geometry. */}
<Card variant="red" elevation="high" padding={0} className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} />
<AlertTriangle size={ICON_SIZE.empty} /> {/* 20 in the 32px plate — the ladder's fill convention */}
</span>
<div className="maka-error-copy">
<h2>{copy.title}</h2>
Expand All @@ -139,13 +139,13 @@ export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLo
isDisabled={copyPending}
aria-busy={copyPending ? 'true' : undefined}
onClick={this.handleCopyReport}
icon={<CopyIcon size={14} aria-hidden="true" />}
icon={<CopyIcon size={ICON_SIZE.control} aria-hidden="true" />}
label={copyLabel}
/>
<UiButton
variant="secondary"
onClick={this.handleReset}
icon={<RotateCw size={14} aria-hidden="true" />}
icon={<RotateCw size={ICON_SIZE.control} aria-hidden="true" />}
label={copy.retry}
/>
<UiButton variant="primary" onClick={this.handleReload} label={copy.reload} />
Expand Down
Loading
Loading