## Readest Design Language
Readest's UI is **Adwaita-aligned**, **e-ink-first**, **cross-platform-aware**. This doc is the
reference for that language: principles, vocabulary, anti-patterns. New work should read it
before reaching for daisyui defaults; existing work is gradually migrating toward it.
### Status
This doc is the **first articulation** of the system, not a retrospective. Many existing
components don't fully match it yet (especially older buttons and ad-hoc panels). The goal
is that **new code uses these conventions** and **migrations land opportunistically** as
features get touched.
---
### 1. Identity & lineage
Readest's visual language descends from **Adwaita / libadwaita** — GNOME's design system —
adapted for a cross-platform Tauri + Next.js app that also runs on iOS, Android, web, and
e-ink readers.
What we take from Adwaita:
- **Content first, chrome recedes.** The reading surface is the product. Settings, toolbars,
popups never compete with the page.
- **Boldly minimal.** Restraint over density. Whitespace is structural.
- **Surface hierarchy** — window → view → card — three explicit elevation tiers, no shadow
gymnastics.
- **Color discipline.** Brand color is rare and earned. Neutral palette carries the weight.
- **Boxed lists are the chassis.** AdwActionRow's prefix · title · suffix anatomy is the
canonical settings/list row everywhere.
- **Pills, ghosts, flats.** Three-tier button palette: pill/circular ghost in headers, flat
secondary over view-bg, accent CTA only when truly primary.
- **Banner vs Toast.** AdwBanner = inline, top-of-window, persistent. AdwToast = transient,
bottom slide-in.
- **Switches over checkboxes** for boolean settings.
- **Subtle motion.** Short, ease-out, never bouncy.
What's Readest-specific:
- **E-ink as a first-class mode.** Every surface flips to flat 1px contrast borders under
`[data-eink='true']`. Adwaita is desktop-GNOME-only; we ship to e-ink readers and the
visual language has to survive there.
- **Cross-platform reality.** Readest runs on macOS, Windows, Linux, iOS, Android, web. The
identity stays Adwaita; platform grace notes (radii, target sizes) follow host
conventions where they matter.
---
### 2. Principles
The seven rules. When in doubt, work backward from these.
#### 2.1 Surfaces continue surfaces
A control that extends a list/card should match its parent's border + fill. The
"+ Import Dictionary" button at `src/components/settings/CustomDictionaries.tsx` reads as
detached card siblings of the dictionary list above it because they share
`border-base-200 bg-base-100 rounded-lg`.
> **Bad**: a list of dictionaries in a `bg-base-100` card, followed by a `btn-outline btn-primary`
> add button. The button shouts; the list whispers; the eye bounces.
>
> **Good**: list and add-button share the same surface vocabulary. The eye flows.
#### 2.2 Color is earned
Brand `primary` is reserved for **the** primary action of a surface. Most actions don't have
a primary action — they have a list of equally-weighted choices, or a single accent.
- Settings dialog has no primary. Every panel is a list of toggles. **Zero brand color.**
- "Import a Book" in onboarding is a primary CTA. **One brand color.**
- "Add Web Search" extends a list — it's not the surface's primary action. **Neutral.**
#### 2.3 Two-step depth
State changes cycle through **`base-100 → base-200 → base-300`** instead of recoloring.
Hover lifts, active deepens, disabled fades opacity. This is theme-safe (works across all
11 color themes), e-ink-friendly (depth is preserved as borders, not shades), and
calmer than recoloring.
#### 2.4 Localize the hover signal
When a button hovers, **one focal element changes**, not the whole button. The icon chip
inverts; the label stays steady. The badge intensifies; the row stays neutral. This reads
as deliberate, not decorative.
#### 2.5 Motion is color, not transform
Default to `transition-colors duration-150`. No `scale`, no `translate`, no `rotate` unless
the motion **is** the message (a chevron rotating to indicate expansion is fine; a button
that scales on hover is not). Transforms break under `[data-eink='true']` and feel
gimmicky under Adwaita's calm rhythm.
#### 2.6 Eink-first by default
Every custom-styled bordered surface gets the `eink-bordered` class. Every primary action
gets `btn-primary` (which has dedicated eink rules). Don't rely on color or shadow alone
for hierarchy — eink screens have neither.
If you can't toggle Settings → Misc → Eink and still tell which button is the CTA, the
hierarchy is broken.
#### 2.7 Focus is visible but quiet
Keyboard focus needs a visible ring. `focus-visible:ring-2 focus-visible:ring-base-content/15`
is the canonical treatment for custom buttons. Loud `ring-primary` reserved for inputs
where the focus state IS the affordance.
#### 2.8 RTL: always use logical properties (REQUIRED)
Readest ships with RTL languages enabled. **Never use direction-bound Tailwind
utilities** when a logical equivalent exists — the visual edges flip in RTL,
the logical ones don't.
| Don't use | Use instead |
| ---------------------------------- | ---------------------------------- |
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
| `ml-*` / `mr-*` | `ms-*` / `me-*` |
| `text-left` / `text-right` | `text-start` / `text-end` |
| `border-l` / `border-r` | `border-s` / `border-e` |
| `rounded-l-*` / `rounded-r-*` | `rounded-s-*` / `rounded-e-*` |
| `left-*` / `right-*` (positioning) | `start-*` / `end-*` |
| `justify-start` / `justify-end` | (these ARE direction-aware) — keep |
The `flex-row` direction is automatically reversed in RTL by the browser, so
you usually don't need to do anything for `flex` / `gap`. Only **explicit
edges** (padding, margin, borders, radius, absolute positioning) need
logical properties.
**Quick scan when reviewing a diff:** grep for `\b(pl|pr|ml|mr|left-|right-|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-` in changed files. Any hit that isn't a deliberate LTR-only
case (rare — usually only icon glyphs that have a fixed orientation) should
be flipped to the logical equivalent.
#### 2.9 Every panel and sub-page starts with title + description (REQUIRED)
Every settings panel and every sub-page must open with:
1. **A title** — the panel name. Style: `text-lg font-semibold tracking-tight`. In a
top-level panel this is an `
`; in a sub-page this is the `parentLabel /
currentLabel` breadcrumb in `SubPageHeader` (which uses the same typography so the
word stays anchored visually as the user navigates in/out).
2. **A one-line description** — a short sentence under the title explaining what this
surface does or how it fits in the user's workflow. Style: `text-sm
text-base-content/70 leading-relaxed`. Skip it only when the surface is so trivial
the breadcrumb already says everything (rare — when in doubt, write one).
Why: orientation, visual rhythm, and Adwaita parity (`AdwPreferencesPage` always has
both). The same vertical opening across every surface makes the system feel cohesive
and gives users a predictable place to learn what a screen does.
**Canonical components.** The `` primitive in
`src/components/settings/SubPageHeader.tsx` accepts a `description?: React.ReactNode`
prop that renders the description in the canonical style — sub-pages should pass it
there rather than rolling their own `
` below the header. Top-level panels currently
inline the title + description; if a third or fourth panel needs the same pattern,
extract a `` primitive following the same shape.
**Examples.**
```tsx
// Sub-page (Integrations → OPDS Catalogs)
setSubPage(null)}
/>
// Top-level panel (Integrations panel root)
{_('Integrations')}
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
```
---
### 3. Surface hierarchy
Three named tiers, mapped onto daisyui tokens. Use these terms in conversation and code
comments even though the classes are still daisyui-native.
| Tier | Token | Role | Example |
| ---------- | ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------- |
| **Window** | `bg-base-200` | The outermost backdrop. Modal scrims, dialog content area, scroll containers. | `
```
- `modal-box` provides the radius, max-width, and shadow (auto-removed in eink).
- Width ~520px on desktop, full-width on mobile.
- Bottom sheets on mobile via `snapHeight` prop.
- Backdrop: `sm:!bg-black/50` (or `/20` when nested over a darker surface).
#### Popup (popover)
For dictionary lookups, annotation editors, and other anchored overlays. Uses the
`Popup` component with a triangle pointer.
- **Width**: clamp to fit content; ~320–420px typical.
- **Surface**: `bg-base-100`, `rounded-lg`, soft shadow (eink removes shadow).
- **Triangle**: pointer toward the anchor; eink has special triangle classes.
- **Padding**: `p-3` to `p-4` for content.
#### Sheet (mobile bottom)
Reserved for mobile contextual menus and full-screen secondary panels. Uses the dialog's
`snapHeight` prop. Adwaita doesn't have a native sheet but Readest's mobile pattern is
the closest analog.
- Always full-width.
- Top corners rounded; bottom corners flat (it's anchored to the bottom).
- Drag handle at top (the small horizontal pill) is mandatory if the sheet supports
swipe-to-dismiss.
---
### 7. Motion + a11y
#### Motion
- Default duration: **150ms** for color transitions.
- Default easing: browser default (`ease`) or `ease-out`. Never `ease-in`.
- Longer transitions (300ms+) only for layout changes (sheet snap, panel slide).
- **Never** use `transform` for hover unless the transform IS the message
(chevron rotation, drag-handle drag visualization). E-ink doesn't render mid-transitions
cleanly and Adwaita's identity is calm.
```tsx
// Good — hover:bg-base-200 with transition-colors
className = 'transition-colors duration-150 hover:bg-base-200';
// Bad — scale on hover
className = 'transition-transform hover:scale-105';
```
Existing exceptions: `.window-button` in globals.css uses `hover:scale-105`. That's
legacy; new code shouldn't follow it.
#### Reduced motion
Reduced-motion preference is honored via the `no-transitions` class
(`globals.css:624`). Layout-changing transitions should respect
`prefers-reduced-motion: reduce` either via this class or `motion-safe:` Tailwind
prefixes.
#### Focus
- Every focusable element must have a visible focus indicator.
- Custom buttons:
`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15`.
- Inputs: rely on daisyui's input focus ring; inputs with custom styling use
`focus:ring-2 focus:ring-primary/40`.
- Don't use `outline-none` without `focus-visible:` replacement.
#### Hit targets
- **Minimum**: 32px (the size of `btn-sm`).
- **Recommended**: 40px (`btn`) on touch surfaces.
- **Mobile**: 44px+ for taps that aren't fail-safe (delete, navigate-away).
- The `touch-target` class in globals.css extends a small visual control's hit area to
44px without changing its rendered size — use it on icon-sized buttons in mobile UIs.
#### Color contrast
- Body text on background: WCAG AA (4.5:1) minimum.
- Large text: WCAG AA Large (3:1) minimum.
- Interactive text on hover state: still passes contrast on the new background.
- Theme palette is generated from `(bg, fg, primary)`; the tinycolor pipeline keeps
contrast within range, but custom themes can break this — Settings → Color flags
low-contrast custom themes.
#### Keyboard
- Tab order matches visual order. If you use `flex-row-reverse` for visual layout,
consider `tabIndex` to fix order.
- Modal focus trap: `` handles this.
- Esc to dismiss: `` and `` handle this.
- Arrow keys for grouped controls (radio-like tab strips, sortable lists). dnd-kit's
`KeyboardSensor` is wired for sortable lists.
---
### 8. E-ink overlay (cross-cutting)
E-ink mode is toggled by `[data-eink='true']` on the document. It applies a global
override layer in `src/styles/globals.css:484-622` that:
- Removes all `box-shadow`.
- Forces `text-base-content`, `text-blue-*`, `text-red-*`, `text-neutral-content` to a
single foreground color.
- Inverts `btn-primary` and `btn-outline` to base-content bg + base-100 text.
- Adds 1px contrast borders to `.eink-bordered`, `.modal-box`, `.menu-container`,
`.popup-container`, `.alert`, `.opds-navigation .card`, `.booknote-item`,
`.bookitem-main`.
What this means for new components:
| Surface type | Required class | Why |
| ------------------------------------ | ----------------------- | ------------------------------------------------------------- |
| Custom bordered button or input | `eink-bordered` | Gets the 1px contrast border in eink |
| Primary CTA | `btn-primary` | Picks up the inverted treatment |
| Cancel / secondary action | `btn-ghost` (no border) | Reads as "outlined" only after pairing with the CTA |
| Card / panel using `border-base-200` | `eink-bordered` | Otherwise the soft border vanishes in eink |
| Modal / Popup | (auto) | `modal-box` and `.popup-container` are handled in globals.css |
Verification checklist before shipping a new UI:
- [ ] Toggle Settings → Misc → Eink mode and re-test every screen.
- [ ] Every container that has a soft border (`border-base-200`) still has visible
delineation.
- [ ] Every CTA is distinguishable from its neighbors (cancel, secondary).
- [ ] No hover transforms make the UI feel jumpy.
- [ ] Text is fully opaque (no `text-base-content/60` content; eink can't render the
reduced opacity well).
#### What's NOT compatible with e-ink
- Drop shadows for hierarchy (use borders).
- Color-only state changes (use border weight or fill swap).
- Hover scale / translate (they look broken on slow refresh).
- Animations longer than ~200ms (visible refresh artifacts).
---
### 9. Cross-platform grace notes
Readest ships on **macOS, Windows, Linux, iOS, Android, web**. Adwaita is desktop-GNOME-
native; we adapt where the host OS has strong conventions, but never at the cost of
identity.
#### iOS
- Slightly larger corner radii feel native (`rounded-xl` on dialogs, `rounded-lg` on
cards).
- Safe area insets are mandatory for top + bottom anchored elements (see
`docs/safe-area-insets.md`).
- Avoid Material Design ripple effects.
- Sheet-style modals (bottom-anchored) match iOS conventions and are preferred over
centered dialogs on phone-sized screens.
#### Android
- Material 3 conventions that conflict with Adwaita (FABs, elevation shadows, ripple
inks): **don't** copy them. Readest's identity is Adwaita; the user is reading on
Android, not in Android.
- Touch targets bumped to 48px for primary actions (Material's recommended target).
- Back-gesture-aware UIs: ensure swipe-from-edge doesn't conflict with horizontal swipe
controls.
#### Linux
- Native Adwaita territory. Readest can match host theme for window chrome (Tauri
decorations) but should keep its own internal palette for the reading surface — book
themes (sepia, gruvbox, etc.) are user choices, not OS choices.
#### macOS / Windows
- Window controls (close/minimize/maximize) are platform-native via Tauri.
- Title bar height matches platform convention; internal layout follows Readest's
Adwaita palette.
#### Web
- No safe-area insets needed.
- Keyboard shortcuts are doubled with command-palette discoverability (Cmd/Ctrl+K).
- Browser-native focus rings: respected, augmented with `focus-visible:ring-*`.
#### E-ink readers (Android-based, custom firmware)
- Detected via the eink mode toggle (Settings → Misc).
- All rules in §8 apply.
- This is a **first-class** target, not a fallback.
---
### 10. Anti-patterns
Things that LOOK fine in isolation but break the system. Each one has a real source diff
or commit reference.
#### 10.1 Loud outlined CTAs for non-primary actions
```tsx
// Anti-pattern (was in CustomDictionaries.tsx, fixed Nov 2026):
// Correct: ListExtension archetype (see §4.6)
```
Why it broke: the buttons read as primary CTAs but are list extensions. They competed
with the active settings tab indicator and pulled the eye from the list itself.
#### 10.2 Recoloring the whole button on hover
```tsx
// Anti-pattern: