forked from akai/readest
d932444b78
* fix(settings): clamp option-row description to a single line SettingsRow descriptions wrapped to multiple lines on narrow (mobile) widths, giving boxed-list rows uneven heights (e.g. "Uploads book files to your other devices." in the Cloud Sync panel). Clamp the description to one line with ellipsis in the shared primitive so every option row stays uniform; the description is a hint, not a paragraph (longer copy belongs in a Tips block). Codified in DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(settings): shorten sync strategy labels to "Send only" / "Receive only" Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync forms). Keys renamed in every locale, preserving existing translations. * feat(sync): temporarily ungate third-party cloud sync from premium Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free, while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS / isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line flip. Applies to the Settings provider rows and the reader auto-sync gate. * fix(settings): polish cloud-sync connect buttons Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral, e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google Drive sign-in tips below the Connect button. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1000 lines
41 KiB
Markdown
1000 lines
41 KiB
Markdown
## 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 `<h2>`; 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 `<SubPageHeader>` 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 `<p>` below the header. Top-level panels currently
|
||
inline the title + description; if a third or fourth panel needs the same pattern,
|
||
extract a `<PanelHeader>` primitive following the same shape.
|
||
|
||
**Examples.**
|
||
|
||
```tsx
|
||
// Sub-page (Integrations → OPDS Catalogs)
|
||
<SubPageHeader
|
||
parentLabel={_('Integrations')}
|
||
currentLabel={_('OPDS Catalogs')}
|
||
description={_('Browse and download books from online catalogs')}
|
||
onBack={() => setSubPage(null)}
|
||
/>
|
||
|
||
// Top-level panel (Integrations panel root)
|
||
<div className='w-full'>
|
||
<h2 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('Integrations')}</h2>
|
||
<p className='text-base-content/70 text-sm leading-relaxed'>
|
||
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
|
||
</p>
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
### 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. | `<Dialog>` body |
|
||
| **View** | `bg-base-100/60` or `bg-base-200/40` | Mid-tier surface inside a window. Tip boxes, secondary panels. | The "提示 / Tips" callout in CustomDictionaries |
|
||
| **Card** | `bg-base-100` | Top-tier content surface. Boxed lists, popovers, modal-box. | The dictionaries list card |
|
||
|
||
Border treatment:
|
||
|
||
- **Window** has no border (it IS the boundary).
|
||
- **View** uses no border or `border-base-200/60` for very soft delineation.
|
||
- **Card** uses `border border-base-200`. In e-ink, `eink-bordered` flips it to 1px
|
||
`border-base-content`.
|
||
|
||
Corner radius:
|
||
|
||
- **Card / View**: `rounded-lg` (8px) — Readest's house radius. Adwaita uses 9px; 8px is
|
||
close enough and matches Tailwind's scale.
|
||
- **Modal / Sheet**: `modal-box` default (~1rem / 16px) — bigger surfaces get bigger radii.
|
||
- **Pills / Chips**: `rounded-full`.
|
||
- **Inputs / small buttons**: `rounded-md` (6px) or `rounded-lg` (8px).
|
||
|
||
#### Surface continuity rule
|
||
|
||
When a control extends a card (an "add row" affordance, a footer button bar attached to a
|
||
list), it inherits the card's surface treatment: same `bg-base-100`, same
|
||
`border-base-200`, same `rounded-lg`. It is the card grown by one row.
|
||
|
||
---
|
||
|
||
### 4. Action vocabulary
|
||
|
||
Six archetypes. Pick by **role**, not by **appearance**.
|
||
|
||
#### 4.1 Accent CTA
|
||
|
||
The primary, accent-colored button. **One per surface, max.** Submit on a form, "Open
|
||
Book", "Sign In".
|
||
|
||
```tsx
|
||
className = 'btn btn-primary';
|
||
```
|
||
|
||
Eink: `btn-primary` has dedicated rules (inverts to base-content bg + base-100 text) so it
|
||
stays distinct from secondary actions on monochrome screens.
|
||
|
||
#### 4.2 Suggested
|
||
|
||
A non-accent-but-emphasized action. Used when there are multiple equally-weighted actions
|
||
and one is the recommended path. Adwaita's "suggested-action" CSS class.
|
||
|
||
```tsx
|
||
className = 'btn btn-neutral';
|
||
```
|
||
|
||
Rare. Most surfaces don't need this tier.
|
||
|
||
#### 4.3 Flat
|
||
|
||
The default secondary button. Sits on a view or card surface, no border, hover lifts to
|
||
`base-200`. The bulk of buttons should be flat.
|
||
|
||
```tsx
|
||
className="btn btn-ghost"
|
||
// or for a custom surface treatment:
|
||
className={clsx(
|
||
'rounded-lg px-4 py-2 text-sm font-medium',
|
||
'hover:bg-base-200 transition-colors duration-150',
|
||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15',
|
||
)}
|
||
```
|
||
|
||
#### 4.4 Pill / Circular ghost
|
||
|
||
Compact icon-only buttons in header bars and toolbars. Always `rounded-full`,
|
||
`btn-circle` or hand-rolled circular ghost.
|
||
|
||
```tsx
|
||
className = 'btn btn-ghost btn-circle h-8 min-h-8 w-8 p-0';
|
||
```
|
||
|
||
The window controls in `SettingsDialog.tsx` (search, menu, close) use this archetype.
|
||
|
||
#### 4.5 Destructive
|
||
|
||
Delete, remove, irreversible. Adwaita uses `destructive-action`. Readest uses red
|
||
sparingly — usually only the icon, not the whole button.
|
||
|
||
```tsx
|
||
// Icon-only delete X in delete mode:
|
||
className = 'btn btn-ghost btn-sm shrink-0 px-1';
|
||
// with <IoMdCloseCircleOutline className="text-error h-4 w-4" />
|
||
```
|
||
|
||
For destructive **dialogs** (confirmation modals), the confirm button can be `btn-error`,
|
||
but only in the modal — never on the main surface.
|
||
|
||
#### 4.6 ListExtension
|
||
|
||
A Readest-named archetype for "add another row to the list above" affordances. The two
|
||
buttons at the bottom of `CustomDictionaries.tsx` are the canonical example.
|
||
|
||
Anatomy:
|
||
|
||
- Surface matches the parent card (`border border-base-200 bg-base-100 rounded-lg`)
|
||
- Height ~h-11
|
||
- Centered: small icon chip + label
|
||
- Icon chip: `bg-base-200 text-base-content/60 rounded-full h-5 w-5`
|
||
- Hover: border deepens to `base-300`, bg lightens to `bg-base-200/60`, icon chip inverts
|
||
to `bg-base-content text-base-100`
|
||
- `eink-bordered` on the button itself
|
||
|
||
```tsx
|
||
<button
|
||
type='button'
|
||
onClick={handleAdd}
|
||
className={clsx(
|
||
'eink-bordered group flex h-11 items-center justify-center gap-2.5',
|
||
'border-base-200 bg-base-100 rounded-lg border px-4',
|
||
'text-base-content text-sm font-medium',
|
||
'transition-colors duration-150',
|
||
'hover:border-base-300 hover:bg-base-200/60',
|
||
'active:bg-base-200/80',
|
||
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
|
||
)}
|
||
>
|
||
<span
|
||
className={clsx(
|
||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||
'bg-base-200 text-base-content/60',
|
||
'transition-colors duration-150',
|
||
'group-hover:bg-base-content group-hover:text-base-100',
|
||
)}
|
||
>
|
||
<MdAdd className='h-3.5 w-3.5' />
|
||
</span>
|
||
<span className='line-clamp-1'>{label}</span>
|
||
</button>
|
||
```
|
||
|
||
Use this for: "Import Dictionary", "Add Web Search", "Add Custom Theme", any "+ add new
|
||
to this list" pattern. **Do not** use `btn-outline btn-primary` for these.
|
||
|
||
---
|
||
|
||
### 5. Boxed list anatomy
|
||
|
||
The settings UI is built on boxed lists. One pattern, used everywhere.
|
||
|
||
#### Container
|
||
|
||
Use the `<BoxedList>` primitive at `src/components/settings/primitives/BoxedList.tsx`
|
||
rather than inlining the chassis classes:
|
||
|
||
```tsx
|
||
<BoxedList title={_('Reading Sync')} data-setting-id='settings.section.id'>
|
||
{/* rows */}
|
||
</BoxedList>
|
||
```
|
||
|
||
The primitive renders:
|
||
|
||
```tsx
|
||
<div className='card eink-bordered border-base-200 bg-base-100 border'>
|
||
<div className='divide-base-200 divide-y'>{children}</div>
|
||
</div>
|
||
```
|
||
|
||
- `card` for the radius
|
||
- `border border-base-200` for the boundary (eink upgrades this automatically)
|
||
- `eink-bordered` for the e-ink-mode contrast border
|
||
- `divide-base-200 divide-y` for inter-row separators
|
||
|
||
> **No `overflow-hidden` on the card.** Children may host popovers (color
|
||
> pickers, dropdowns, tooltips) that need to escape the card bounds. The
|
||
> `divide-y` rules sit between rows and don't touch the card's rounded
|
||
> corners, so omitting overflow-clip is visually safe AND keeps embedded
|
||
> popovers from getting clipped.
|
||
|
||
#### Row anatomy
|
||
|
||
Three slots, in order, always:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ [prefix] Title text [suffix slots] │
|
||
│ [ ] Subtitle text (optional) [ ][ ]│
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
| Slot | Contents |
|
||
| ------------ | ------------------------------------------------------------------------------------------------- |
|
||
| **Prefix** | Drag handle, leading icon, avatar, status dot, or empty. |
|
||
| **Title** | Primary label. `font-medium`. Truncates with `truncate`. |
|
||
| **Subtitle** | Optional secondary line. `text-sm text-base-content/70`. Used for warnings, descriptions, status. |
|
||
| **Suffix** | Badge, switch, button, chevron, value, or any combination. End-aligned. |
|
||
|
||
Canonical example: `SortableRow` in `src/components/settings/CustomDictionaries.tsx`. The
|
||
drag handle is the prefix, the dict name is the title, the warning reason is the
|
||
subtitle, and the badge + toggle + edit/delete buttons stack as suffixes.
|
||
|
||
#### Row variants
|
||
|
||
- **ActionRow** — title + suffix is a single button or chevron. Tap anywhere navigates.
|
||
- **SwitchRow** — title + suffix is a toggle. Tap anywhere toggles.
|
||
- **ComboRow** — title + suffix is a dropdown/select.
|
||
- **ExpanderRow** — chevron suffix; tap expands to reveal nested rows.
|
||
|
||
These names come from libadwaita and apply 1:1 to Readest's lists. Use the names in code
|
||
comments and PR descriptions.
|
||
|
||
#### Spacing
|
||
|
||
- Row vertical padding: `py-2` (8px) for compact lists, `py-3` (12px) for breathing room.
|
||
- Row horizontal padding: `px-3` (12px) or `px-4` (16px). Stay consistent within a list.
|
||
- Slot gap: `gap-2` (8px) between prefix/title/suffix elements.
|
||
|
||
#### Disabled rows
|
||
|
||
Disabled rows fade the title to `text-base-content/60` and disable the suffix control. The
|
||
row itself stays at full opacity — only the **content** dims, not the row.
|
||
|
||
#### Toggle size
|
||
|
||
| Daisyui class | Use case |
|
||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||
| `toggle` (default, h-5 / ~20px) | **Settings panel boxed-list rows** — `<SettingsSwitchRow>` uses this. Visible weight matches the 56px `min-h-14` row. |
|
||
| `toggle-sm` (h-4 / ~16px) | Inline secondary switches in tighter contexts — e.g. dictionary list rows in `CustomDictionaries`. |
|
||
| `toggle-xs` (h-3 / ~12px) | Compact metadata toggles inside cards — e.g. OPDS catalog "Auto-download". |
|
||
|
||
The `<SettingsSwitchRow>` primitive bakes in the default `toggle`. **Don't override
|
||
to `toggle-sm` inside boxed-list rows** — it looks orphaned in the row's vertical
|
||
breathing room. Use the smaller sizes only when the row itself is shorter than 56px.
|
||
|
||
#### Typography inherits from `.settings-content`
|
||
|
||
The Settings dialog (and any settings-style sheet/popup) wraps its content
|
||
in `.settings-content`, which is defined in `src/styles/globals.css` as:
|
||
|
||
```css
|
||
.dropdown-content,
|
||
.settings-content {
|
||
font-size: 14px; /* desktop */
|
||
}
|
||
@media (max-width: 768px) {
|
||
.dropdown-content,
|
||
.settings-content {
|
||
font-size: 16px; /* mobile bump — high-DPI phones need bigger body text */
|
||
}
|
||
}
|
||
```
|
||
|
||
**Don't hardcode `text-sm` on row labels, NavigationRow titles, or panel
|
||
descriptions** — that locks the text to 14px on every viewport and kills
|
||
the mobile bump. Instead:
|
||
|
||
- **Primary labels** (SettingsRow label, NavigationRow title, SubPageHeader
|
||
description, ad-hoc row labels in panels and integration forms): no
|
||
font-size class — inherits 14/16 from the wrapper. Use `<SettingLabel>`
|
||
rather than inlining a `<span>`; it adds `font-medium` for cased scripts
|
||
and drops the weight for caseless scripts (CJK / Arabic / Hebrew / Indic
|
||
/ Thai / Tibetan), since those bold poorly at body size and `font-medium`
|
||
on Han / Hangul / Devanagari renders as uneven stroke-thickening across
|
||
system fonts.
|
||
- **Secondary text** (SettingsRow description, NavigationRow status, Tips
|
||
body, BoxedList description): use `text-[0.85em]` so it stays
|
||
proportional (≈12px desktop, ≈13.6px mobile). A **`SettingsRow`
|
||
description is clamped to a single line** (`line-clamp-1`, ellipsis on
|
||
overflow) by the primitive — keep it short enough to read on one line at
|
||
mobile width; it is a hint, not a paragraph. Move anything longer into a
|
||
`<Tips>` block below the list.
|
||
- **Form controls** (`<input>`, `<select>`): browsers don't inherit
|
||
font-size onto form elements, so add the `settings-content` class
|
||
_directly on the element_ to re-apply the 14/16 cascade. The legacy
|
||
NumberInput already does this — match its pattern.
|
||
- **Section headers** (`BoxedList` uppercase title): use `text-[0.85em]
|
||
font-semibold uppercase tracking-wider`. The em-relative size keeps it
|
||
proportional with the `.settings-content` cascade. **Caseless-script
|
||
exception:** when `isCaselessUILang()` is true, bump to `text-[1em]`.
|
||
The `uppercase` rule is a no-op in scripts without case (CJK, Arabic,
|
||
Hebrew, Devanagari/Bengali/Tamil/Sinhala, Thai, Tibetan), so the size
|
||
has to carry the emphasis those scripts can't pick up from casing. The
|
||
helper lives in `src/utils/misc.ts`; the underlying `isCaselessLang`
|
||
predicate lists every covered language code in `src/utils/lang.ts`.
|
||
|
||
Why this matters: Tailwind's `text-xs` / `text-sm` are rem-based — they
|
||
ignore the parent's `font-size` because rem is rooted at the document.
|
||
The `.settings-content` cascade is in `px`, so any child that picks a
|
||
Tailwind size literally tunes itself to the desktop default and never
|
||
grows on mobile. iOS and Android have small physical screens but high
|
||
DPI, so the mobile bump is what makes the text legible at typical reading
|
||
distance.
|
||
|
||
#### Uniform row height
|
||
|
||
Settings rows in a boxed list MUST all be the same visual height. Use
|
||
`min-h-14 items-center` (56px) on each row container — toggle, select, and
|
||
input rows then center their controls vertically inside identical boxes.
|
||
**Don't use `py-3`** — content-driven padding produces uneven heights
|
||
because toggles, selects (`h-9`), and inputs (`h-9`) have different
|
||
intrinsic sizes.
|
||
|
||
```tsx
|
||
// ✓ Right — no text-sm; label inherits .settings-content (14/16)
|
||
<label className='flex min-h-14 items-center justify-between px-4'>
|
||
<span className='font-medium'>{_('Sync Enabled')}</span>
|
||
<input type='checkbox' className='toggle' ... />
|
||
</label>
|
||
|
||
// ✗ Wrong — toggle row will be 48px, select rows 60px
|
||
<label className='flex items-center justify-between px-4 py-3'>...</label>
|
||
|
||
// ✗ Wrong — text-sm hardcodes 14px even on mobile (kills the bump)
|
||
<span className='text-sm font-medium'>{_('Sync Enabled')}</span>
|
||
```
|
||
|
||
#### Controls inside a boxed list have no chrome
|
||
|
||
When a control sits inside a bordered card, it shouldn't carry its own
|
||
border or fill. The card supplies the visual boundary; the control just
|
||
sits on the row.
|
||
|
||
- **Selects:** drop `select-bordered` and `eink-bordered`. Add
|
||
`!bg-transparent !bg-none !appearance-none` to suppress daisyui's
|
||
background chevron and native arrow. Render a real `<MdArrowDropDown>`
|
||
icon at the cell's trailing edge for the affordance — see "End-aligned
|
||
values" below.
|
||
- **Inputs:** drop `input-bordered` and `eink-bordered`. Add `!bg-transparent`
|
||
with `hover:!bg-base-200/60 focus:!bg-base-200/60` so the field still
|
||
signals interactability. Use `text-end` and `!pe-0` so the value sits
|
||
flush against the row's trailing edge.
|
||
- **Toggles:** untouched — they're already chromeless.
|
||
|
||
This is the iOS Settings / Adwaita PreferencesGroup convention: list
|
||
chrome belongs to the container, not its children.
|
||
|
||
#### End-aligned values + chevron alignment
|
||
|
||
The selected value of a select/input MUST end-align (`text-end`). The
|
||
**visible right edge** of every row's value (toggle, chevron icon, input
|
||
text) MUST land at the same X — the row's trailing padding.
|
||
|
||
The trap: daisyui's select renders its chevron via background-image at
|
||
`calc(100% - 1rem) center`, which floats the glyph 16px _inside_ the
|
||
select's right edge. So if the toggle in row 1 ends at the row's `pe-4`
|
||
edge, the chevron in row 2 ends 16px before that — visibly misaligned.
|
||
|
||
**Fix:** suppress daisyui's bg-image chevron and render an explicit icon at
|
||
the cell's trailing edge. The select's own daisyui focus chrome (outline +
|
||
box-shadow + ring) is suppressed; **no focus ring** on controls inside the
|
||
boxed list — focus state is signaled by a subtle wrapper bg-shift instead
|
||
(hover and focus-within both lift to `bg-base-200/60`). Rings would compete
|
||
with the card's own border and double-stack with adjacent rows.
|
||
|
||
```tsx
|
||
<div className='hover:bg-base-200/60 focus-within:bg-base-200/60 flex max-w-[60%] items-center rounded-md'>
|
||
<select className='select h-9 min-w-0 cursor-pointer !appearance-none truncate !border-0 !bg-transparent !bg-none !pe-1 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0'>
|
||
{/* options */}
|
||
</select>
|
||
<MdArrowDropDown
|
||
aria-hidden='true'
|
||
className='text-base-content/55 pointer-events-none h-5 w-5 flex-shrink-0'
|
||
/>
|
||
</div>
|
||
```
|
||
|
||
> **Why so many `!` overrides?** daisyui's `.select` and `.input` apply
|
||
> `border-width: 1px` + `border-color` (transparent at rest, `var(--bc)` on
|
||
> focus), plus `outline`, `box-shadow`, and `ring` chrome on focus. To make
|
||
> the control truly chromeless inside a boxed list, you need to kill all
|
||
> four properties. Missing any of them — especially `border-0` — leaves a
|
||
> visible focus border leaking through.
|
||
|
||
The `<MdArrowDropDown>` icon's trailing edge now lives at the same X as the
|
||
toggle's trailing edge in adjacent rows, because both are flush with the
|
||
row's `pe-4` padding.
|
||
|
||
For inputs, no wrapper is needed — the input is one element, so put the
|
||
hover/focus bg directly on it. Suppress daisyui's own focus chrome the
|
||
same way:
|
||
|
||
```tsx
|
||
<input className='input hover:!bg-base-200/60 focus:!bg-base-200/60 h-9 max-w-[60%] rounded-md !border-0 !bg-transparent !pe-0 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0' />
|
||
```
|
||
|
||
> **Why no ring here when §2.7 says "focus needs a visible ring"?** §2.7 is
|
||
> for standalone custom buttons (Submit, Cancel, ListExtension, etc.). In a
|
||
> boxed list, the row already provides strong visual containment via the
|
||
> card border + dividers, and stacking a per-control ring inside that
|
||
> creates double chrome. The bg-shift IS the focus indicator — keyboard
|
||
> users still get clear feedback; the surface stays calm.
|
||
|
||
---
|
||
|
||
### 6. Header bars, dialogs, popups, sheets
|
||
|
||
#### Header bar
|
||
|
||
The dialog/page header. Adwaita's AdwHeaderBar.
|
||
|
||
- **48–56px tall** (`h-12` to `h-14`).
|
||
- **Center-aligned title** in `font-semibold text-base`.
|
||
- **Leading slot**: back chevron (mobile) or empty (desktop).
|
||
- **Trailing slot**: window controls — search (pill ghost), menu (pill ghost),
|
||
close (pill ghost circle with `bg-base-300/65`).
|
||
- No bottom border; rely on tab/divider that follows.
|
||
|
||
`SettingsDialog.tsx`'s mobile header is the canonical example. The desktop header is
|
||
slightly different — tabs sit in the same row as window controls, no center title — but
|
||
it's the same archetype adapted for screen real estate.
|
||
|
||
#### Dialog (modal)
|
||
|
||
```tsx
|
||
<Dialog
|
||
isOpen={...}
|
||
onClose={...}
|
||
boxClassName="sm:min-w-[520px] overflow-hidden"
|
||
header={<HeaderBar />}
|
||
>
|
||
{/* content */}
|
||
</Dialog>
|
||
```
|
||
|
||
- `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.
|
||
|
||
#### Stacking order (z-index scale)
|
||
|
||
Full-screen and body-portaled overlays share **one global stacking scale**. Keep it
|
||
compact — never reach for four-digit z-indexes. Every layer must clear the desktop
|
||
rounded-window page frame (`.window-border`, `z-99` in `globals.css`), then layer:
|
||
|
||
| z-index | Layer | Where |
|
||
| ------- | ----- | ----- |
|
||
| `99` | Desktop window-border page frame | `globals.css` |
|
||
| `100` | RSVP immersive reading overlay | `RSVPOverlay` |
|
||
| `101` | RSVP immersive controls (start dialog, lookup chip) | `RSVPStartDialog`, `RSVPOverlay` |
|
||
| `110` | Settings app dialog (above RSVP for in-overlay dictionary mgmt) | `SettingsDialog` |
|
||
| `120` | Modal / command palette | `ModalPortal`, `CommandPalette` |
|
||
| `130` | Toast / alert | `Alert` |
|
||
| `200` | Security lock screen | `AppLockScreen` |
|
||
|
||
The non-obvious invariant: **`ModalPortal` (120) must stay above `SettingsDialog`
|
||
(110)** so a modal opened _from inside_ Settings (e.g. Add OPDS Catalog) isn't buried.
|
||
This bites only on mobile — desktop traps `SettingsDialog` inside the `z-99`
|
||
`.window-border` stacking context, so the body-portaled modal already wins there.
|
||
The ordering is locked by `src/__tests__/styles/zIndexScale.test.ts`; update both
|
||
together.
|
||
|
||
---
|
||
|
||
### 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: `<Dialog>` handles this.
|
||
- Esc to dismiss: `<Dialog>` and `<Popup>` 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):
|
||
<button className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'>
|
||
<MdAdd className='h-5 w-5' />
|
||
Import Dictionary
|
||
</button>
|
||
|
||
// 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:
|
||
<button className="text-base-content/70 hover:text-base-content hover:bg-primary/10">
|
||
|
||
// Correct: keep the label color steady, hover via bg shift on the surface
|
||
<button className="text-base-content hover:bg-base-200 transition-colors">
|
||
```
|
||
|
||
Why: principle 2.4 (localize the hover signal). Whole-button color shifts feel decorative.
|
||
|
||
#### 10.3 Transform-based hover
|
||
|
||
```tsx
|
||
// Anti-pattern:
|
||
<button className="hover:scale-105 transition-transform">
|
||
|
||
// Correct: color/border-based hover
|
||
<button className="hover:bg-base-200 hover:border-base-300 transition-colors">
|
||
```
|
||
|
||
Why: breaks under e-ink (§2.5), feels jumpy under Adwaita's calm rhythm.
|
||
|
||
#### 10.4 Soft borders without `eink-bordered`
|
||
|
||
```tsx
|
||
// Anti-pattern:
|
||
<div className="border border-base-200 bg-base-100 rounded-lg p-4">
|
||
...
|
||
</div>
|
||
|
||
// Correct:
|
||
<div className="eink-bordered border border-base-200 bg-base-100 rounded-lg p-4">
|
||
...
|
||
</div>
|
||
```
|
||
|
||
Why: in e-ink mode, `base-200` borders disappear into the background. `eink-bordered`
|
||
flips the border to `base-content` so the boundary stays visible.
|
||
|
||
Exception: containers that **don't** need a visible boundary in eink (e.g., a
|
||
`bg-base-100` surface that's already against `bg-base-200`) can skip `eink-bordered`.
|
||
The class is opt-in for "this surface needs a border to read correctly".
|
||
|
||
#### 10.5 Reduced-opacity text in e-ink
|
||
|
||
```tsx
|
||
// Anti-pattern (in eink):
|
||
<span className="text-base-content/50">Optional metadata</span>
|
||
|
||
// Correct (still readable in eink):
|
||
<span className="text-base-content text-xs">Optional metadata</span>
|
||
// Or use semantic muting that the eink overlay handles:
|
||
<span className="text-neutral-content">Optional metadata</span>
|
||
```
|
||
|
||
Why: e-ink's reduced color depth turns `/50` opacity into illegible mush. Use size or
|
||
weight for hierarchy on muted secondary text.
|
||
|
||
#### 10.6 Daisyui `btn` defaults without intent
|
||
|
||
```tsx
|
||
// Anti-pattern: just reaching for `btn` with no role:
|
||
<button className="btn">Click me</button>
|
||
|
||
// Correct: pick an archetype from §4.
|
||
<button className="btn btn-ghost">Cancel</button> // Flat
|
||
<button className="btn btn-primary">Save</button> // Accent CTA
|
||
```
|
||
|
||
Why: daisyui's `btn` default isn't tuned for any specific role. Pick from the action
|
||
vocabulary so the button signals its weight in the surface hierarchy.
|
||
|
||
#### 10.7 Ad-hoc surface tokens
|
||
|
||
```tsx
|
||
// Anti-pattern:
|
||
<div className="bg-white border-gray-200">
|
||
|
||
// Correct:
|
||
<div className="bg-base-100 border-base-200">
|
||
```
|
||
|
||
Why: hard-coded colors don't theme. Readest has 11 themes plus user-defined custom themes.
|
||
Always use the daisyui semantic tokens.
|
||
|
||
#### 10.8 Mixing `btn` sizes within a surface
|
||
|
||
```tsx
|
||
// Anti-pattern:
|
||
<header>
|
||
<button className="btn btn-sm">Search</button>
|
||
<button className="btn btn-md">Settings</button>
|
||
<button className="btn btn-xs">Close</button>
|
||
</header>
|
||
|
||
// Correct: one size per surface
|
||
<header>
|
||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Search</button>
|
||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Settings</button>
|
||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Close</button>
|
||
</header>
|
||
```
|
||
|
||
Why: visual rhythm. Mixed sizes feel like the surface is unfinished.
|
||
|
||
---
|
||
|
||
### 11. Quick reference
|
||
|
||
When designing a new surface, walk this checklist:
|
||
|
||
1. **What's the surface tier?** Window / View / Card. (§3)
|
||
2. **What's the corner radius?** Match the tier. (§3)
|
||
3. **Is there a primary action?** If yes, ONE accent CTA. If no, all flats. (§4.1, §4.3)
|
||
4. **Are there list extensions?** Use the ListExtension archetype, not `btn-outline btn-primary`. (§4.6)
|
||
5. **Is it a list?** Use the BoxedList chassis with ActionRow / SwitchRow / ComboRow / ExpanderRow rows. (§5)
|
||
6. **Does it need `eink-bordered`?** If it has a soft border that must stay visible in
|
||
eink mode, yes. (§8)
|
||
7. **Is the hover signal localized?** One focal element changes, not the whole control. (§2.4)
|
||
8. **Is motion color-only?** No transforms unless the transform IS the message. (§2.5)
|
||
9. **Is focus visible?** `focus-visible:ring-2 focus-visible:ring-base-content/15` on
|
||
custom buttons. (§7)
|
||
10. **Will it work on the smallest theme + e-ink?** Toggle Sepia + Eink, retest.
|
||
|
||
---
|
||
|
||
### 12. Glossary
|
||
|
||
- **Adwaita / libadwaita**: GNOME's design system and widget toolkit. Source of Readest's
|
||
visual lineage.
|
||
- **AdwActionRow / AdwSwitchRow / AdwComboRow / AdwExpanderRow**: libadwaita's row
|
||
primitives. Readest mirrors these conceptually with custom React components.
|
||
- **AdwBoxedList**: libadwaita's named container for grouped action rows.
|
||
- **AdwBanner**: top-of-window inline alert (persistent).
|
||
- **AdwToast**: bottom slide-in transient alert.
|
||
- **Window / View / Card**: surface tiers (§3).
|
||
- **ListExtension**: Readest-named archetype for "+ add new row" buttons (§4.6).
|
||
- **eink-bordered**: utility class in `globals.css` that gives a surface its e-ink-mode
|
||
contrast border. Opt-in.
|
||
- **Pill ghost**: circular icon button, `btn-ghost btn-circle`.
|
||
|
||
---
|
||
|
||
### 13. Maintenance
|
||
|
||
This doc is the **source of truth** for new design decisions. When the system grows:
|
||
|
||
- New archetypes get a numbered subsection in §4 or §5.
|
||
- New anti-patterns get added to §10 with a real source reference.
|
||
- Updates to existing principles require a brief why-changed note in the relevant section.
|
||
|
||
Cross-references that must stay in sync:
|
||
|
||
- `CLAUDE.md` E-ink mode section → §8 of this doc.
|
||
- `docs/safe-area-insets.md` → §9 (cross-platform).
|
||
- `src/styles/globals.css` `[data-eink]` rules → §8.
|
||
- `src/styles/themes.ts` Palette type → §3 token table.
|
||
|
||
If you change a rule here, search for the cross-reference and update both.
|