UI Design System¶
Purpose¶
Define the visual language, color tokens, typography, component rules, and animation system for the Aelghar client. Everything here should be implementable directly in SvelteKit with no exotic tooling.
The guiding constraint from browser-first-constraints.md: the UI must communicate duration, risk, supplies, and expected reward at a glance for a player who is alt-tabbed and returning briefly.
Design Principles¶
The visual language is carved stone and worn leather, not AI product. Every decision should read as a medieval ledger or iron-bound chest, not a SaaS dashboard.
Explicit Anti-Patterns¶
| Pattern | Why banned | Replacement |
|---|---|---|
Glow halos (box-shadow: 0 0 Npx color) on interactive elements |
AI product affordance signal | Border color shift on hover/focus |
| Colored ambient shadow behind modals (blue/purple tint) | AI "depth" illusion | Pure black drop shadow only |
| Gradient-sweep skeleton loader (moving highlight across grey bar) | AI loading state cliché | Opacity dim-pulse |
hue-rotate filter for magic/arcane effects |
AI generative aesthetic | Brightness + opacity shift |
Slide-in-from-below on page load / scroll (y: [N, 0] stagger) |
Every SaaS landing page since 2020 | Fade-in only (opacity), or nothing |
Glassmorphism (backdrop-filter: blur) |
AI dashboard staple | Opaque panel surfaces |
| Persistent pulsing glow on status indicators | AI "live" signal trope | Border color flicker, no emanation |
Pill badges with border-radius: 999px on primary action buttons |
Modern SaaS shape language | border-radius: 3px on buttons; pill only on inline metadata tags |
Explicit pill exceptions: Skill Band Badges, Hall Rank Badges, Risk Badges, and duration/status tags intentionally use border-radius: 999px. These are inline metadata — not primary actions. This is the defined carve-out from the above rule.
Color Palette¶
The base palette is established in map-preview.html. This section formalizes it as CSS custom properties and extends it with accent colors.
Backgrounds¶
| Token | Value | Use |
|---|---|---|
--bg-base |
#020c14 |
Page shell, outermost container |
--bg-surface |
#051018 |
Main panel backgrounds |
--bg-elevated |
#091620 |
Cards, drawers, board items |
--bg-raised |
#0d1f2e |
Dropdowns, modals, tooltips |
--bg-hover |
#122030 |
Hover state background |
Text¶
| Token | Value | Use |
|---|---|---|
--text-primary |
#c7d7e7 |
Main content text |
--text-secondary |
#aac3dc |
Labels, secondary content, column headers |
--text-muted |
#4c6279 |
Captions, hints, timestamps |
--text-faint |
#2a4055 |
Placeholder text, disabled state |
Borders¶
| Token | Value | Use |
|---|---|---|
--border-faint |
#0d1e2e |
Structural separator, barely visible |
--border-default |
#17314a |
Standard card and panel border |
--border-active |
#285070 |
Focused or active element border |
--border-bright |
#3a7298 |
Selected, highlighted, or hovered border |
Accent — Gold (rewards, coin, quality, XP)¶
| Token | Value | Use |
|---|---|---|
--gold-dim |
#7a5520 |
Muted gold backgrounds, inactive quality tier |
--gold-mid |
#c9913a |
Standard coin labels, quality badges, primary button border |
--gold-bright |
#e0b060 |
Highlight, hover state gold, master-tier badge |
--gold-glow |
rgba(201,145,58,0.18) |
Deprecated — do not use as box-shadow. Token kept for reference only. Ambient glow is banned per Design Principles. |
Accent — Green (safe, ready, healthy, success)¶
| Token | Value | Use |
|---|---|---|
--green-dim |
#1e4a30 |
Safe-status background tint |
--green-mid |
#3a7a54 |
Health bar fill, success badge, ready indicator |
--green-bright |
#5ab87a |
Full health highlight, completion flash |
Accent — Red (danger, injury, failure, critical)¶
| Token | Value | Use |
|---|---|---|
--red-dim |
#3a1818 |
Low-danger background tint |
--red-mid |
#7a2828 |
Danger badge, injured status, failure state |
--red-bright |
#b04040 |
Critical warning, high-risk expedition label |
Accent — Amber (warning, fatigue, caution)¶
| Token | Value | Use |
|---|---|---|
--amber-dim |
#3a2808 |
Fatigue background tint |
--amber-mid |
#b07028 |
Fatigue bar fill, caution badge |
--amber-bright |
#d09040 |
Warning threshold highlight |
Accent — Arcane (magic, ritual, arcane effects)¶
| Token | Value | Use |
|---|---|---|
--arcane-dim |
#2a1a4a |
Arcane background tint |
--arcane-mid |
#5a3a8a |
Arcane tag, ward indicator |
--arcane-bright |
#8a5abf |
Active ward, ritual in-progress pulse |
Typography¶
Fonts¶
Three fonts, all available on Google Fonts.
| Role | Font | Character |
|---|---|---|
| Display / Headings | Cinzel | Roman inscription feel; uppercase-native; excellent for town names, section headers, NPC titles |
| Body / UI | Crimson Pro | Humanist serif; warm and readable at small sizes; primary text for cards, panels, board items |
| Flavor / Codex | Spectral | Scholarly serif; italic variant for quest flavor text, journal entries, codex passages |
<!-- Google Fonts import -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Pro:ital,wght@0,400;0,600;1,400;1,600&family=Spectral:ital,wght@0,400;1,400;1,600&display=swap" rel="stylesheet">
Type Scale¶
Base: 16px. All values in rem.
| Token | Size | Use |
|---|---|---|
--text-xs |
0.75rem (12px) |
Badges, tooltips, captions, timestamps |
--text-sm |
0.875rem (14px) |
Table data, secondary labels, item quantities |
--text-base |
1rem (16px) |
Primary body text, card descriptions |
--text-md |
1.125rem (18px) |
Card headings, emphasized labels |
--text-lg |
1.25rem (20px) |
Panel section headings |
--text-xl |
1.5rem (24px) |
Route section titles |
--text-2xl |
2rem (32px) |
Page-level titles (Guild Hall, Market, Board) |
--text-3xl |
2.5rem (40px) |
Major display titles (town name, character name card) |
Weight Rules¶
400— body text, descriptions600— headings, labels, emphasized UI text700— primary Cinzel display only (town names, route titles)
Letter Spacing¶
- Cinzel headings:
letter-spacing: 0.08em(default),0.15emfor all-caps labels - Crimson Pro body:
letter-spacing: 0(normal) - Small uppercase labels (e.g. "RANK", "DURATION", "RISK"):
letter-spacing: 0.12em,font-size: --text-xs,font-weight: 600
Line Height¶
| Context | Value |
|---|---|
| Body text (Crimson Pro — panels, cards, descriptions) | 1.6 |
| UI labels, badges, table cells | 1.3 |
| Codex / lore prose (Spectral) | 1.75 |
| Cinzel headings | 1.2 |
Spacing¶
| Token | Value | Use |
|---|---|---|
--space-1 |
4px |
Tight padding, inline badge gaps |
--space-2 |
8px |
Inner card padding, small gap |
--space-3 |
12px |
Standard label gap |
--space-4 |
16px |
Card padding, section gap |
--space-6 |
24px |
Panel padding, between-card gap |
--space-8 |
32px |
Section separation |
--space-12 |
48px |
Major route section gap |
Icons¶
Icon source: Lucide (MIT license, SVG-first, consistent stroke-width API, stroke-only default set — matches no-fill-icon rule).
<script>
import { Sword, Scroll, Map, User, Settings } from 'lucide-svelte'
</script>
<Sword size={20} strokeWidth={1.5} color="currentColor" />
| Property | Value |
|---|---|
| Size (default UI) | 20×20 |
| Size (nav / large affordance) | 22×22 |
| Size (inline label / badge) | 14×14 |
stroke-width |
1.5 always |
color |
currentColor — inherits from parent CSS color |
| Fill | Never — stroke only |
Do not mix Lucide with other icon sets in the same view.
Component Style Rules¶
Card (board order, market listing, expedition summary)¶
background: var(--bg-elevated)
border: 1px solid var(--border-default)
border-radius: 4px
padding: var(--space-4)
font-family: Crimson Pro, serif
hover:
border-color: var(--border-bright)
background: var(--bg-hover)
transition: border-color 120ms ease, background 120ms ease
Keep border-radius: 4px. No large rounded corners — medieval parchment-and-iron aesthetic.
Panel / Modal¶
background: var(--bg-raised)
border: 1px solid var(--border-default)
border-radius: 4px
box-shadow: 0 8px 40px rgba(0,0,0,0.72), inset 0 1px 0 rgba(255,255,255,0.04)
Modal header bar:
padding: var(--space-3) var(--space-6)
border-bottom: 1px solid var(--border-faint)
display: flex; align-items: center; justify-content: space-between
Title:
font-family: Cinzel, serif
font-size: var(--text-lg)
font-weight: 600
letter-spacing: 0.08em
color: var(--text-primary)
Close button (×):
color: var(--text-muted)
width: 28px; height: 28px; border-radius: 3px
hover: background --bg-hover, color --text-primary
aria-label: "Close"
Modal body: padding: var(--space-6). Overflowing content: overflow-y: auto on body div only — header and footer stay fixed.
Modal footer (action row):
padding: var(--space-4) var(--space-6)
border-top: 1px solid var(--border-faint)
display: flex; justify-content: flex-end; gap: var(--space-3)
Primary action on right. Destructive action on left with Ghost button + --red-mid border override.
Button — Primary¶
background: linear-gradient(180deg, #9e7030 0%, var(--gold-dim) 100%)
border: 1px solid var(--gold-mid)
color: var(--text-primary)
font-family: Cinzel, serif
font-weight: 600
letter-spacing: 0.10em
padding: var(--space-2) var(--space-6)
border-radius: 3px
hover:
border-color: var(--gold-bright)
background: linear-gradient(180deg, #b08038 0%, #5a3a10 100%)
active:
transform: scale(0.98)
transition: transform 60ms ease
Button — Ghost / Secondary¶
background: transparent
border: 1px solid var(--border-active)
color: var(--text-secondary)
font-family: Crimson Pro, serif
font-weight: 600
border-radius: 3px
hover:
background: var(--bg-hover)
border-color: var(--border-bright)
color: var(--text-primary)
Progress Bar¶
Track: var(--bg-raised), border: 1px solid var(--border-faint), border-radius: 2px, height 6px.
Fill colors by context:
| Context | Fill Color |
|---|---|
| Health | --green-mid |
| Fatigue | --amber-mid |
| XP / Progress | --gold-mid |
| Supply depletion | --red-mid |
| Timer drain | --text-muted |
Animate fill width using Svelte Tween (see Animation section).
Skill Band Badge¶
Pill shape (border-radius: 999px), font-size: --text-xs, font-weight: 600, letter-spacing: 0.10em, uppercase.
| Band | Background | Text |
|---|---|---|
| Untrained | --bg-raised |
--text-faint |
| Familiar | #0d2035 |
--text-muted |
| Practiced | --green-dim |
#4aaa74 |
| Skilled | #1a3a28 |
--green-bright |
| Veteran | #1a3028 |
#7adba8 |
| Expert | --gold-dim |
--gold-mid |
| Master | #3a2400 |
--gold-bright |
Hall Rank Badge¶
Inline pill, same shape spec as Skill Band Badge (border-radius: 999px, --text-xs, font-weight: 600, uppercase, letter-spacing: 0.10em).
| Rank | Background | Text |
|---|---|---|
| F | --bg-raised |
--text-faint |
| E | #0d1e2e |
--text-muted |
| D | #0d2035 |
--text-secondary |
| C | #1a2a3a |
--text-primary |
| B | --green-dim |
--green-bright |
| A | --gold-dim |
--gold-mid |
| S | #3a2400 |
--gold-bright |
Used in Character Sheet identity block and party member tooltip.
Risk Badge¶
Inline pill on expedition cards and board orders.
| Risk | Background | Text | Border |
|---|---|---|---|
| Safe | --green-dim |
--green-bright |
--green-mid |
| Low | #1a2a1a |
#6ab880 |
#2a5a3a |
| Moderate | --red-dim |
--amber-bright |
--amber-mid |
| High | #3a1810 |
--red-bright |
--red-mid |
| Critical | #480000 |
#e06060 |
--red-bright |
Duration / Timer Pill¶
background: var(--bg-raised)
border: 1px solid var(--border-faint)
color: var(--text-secondary)
font-family: Crimson Pro, serif
font-size: --text-sm
padding: 2px var(--space-2)
border-radius: 2px
font-variant-numeric: tabular-nums
When under 10% remaining time: border → --amber-mid, text → --amber-bright.
When under 2% remaining time: border → --red-mid, text → --red-bright.
Divider¶
For section breaks within panels: use border-top: 1px solid var(--border-default) with slightly more margin.
Input Field¶
background: var(--bg-raised)
border: 1px solid var(--border-default)
border-radius: 3px
color: var(--text-primary)
font-family: Crimson Pro, serif
font-size: var(--text-base)
padding: var(--space-2) var(--space-3)
outline: none
focus:
border-color: var(--border-bright)
box-shadow: 0 0 0 2px rgba(58,114,152,0.18) /* --border-bright at low alpha — structural focus ring only */
placeholder:
color: var(--text-faint)
disabled:
background: var(--bg-elevated)
border-color: var(--border-faint)
color: var(--text-faint)
cursor: not-allowed
Select element: Same tokens. Add appearance: none and a custom ▾ glyph via SVG background-image in --text-muted color. Dropdown panel uses Panel / Modal tokens.
Checkbox / Toggle¶
Checkbox:
Unchecked: border 1px --border-default, background --bg-raised, border-radius 2px, 16×16px
Checked: background --gold-dim, border-color --gold-mid; checkmark in --gold-bright (SVG or ::after)
Focus: same focus ring as Input Field
Disabled: border --border-faint, fill --bg-elevated, checkmark --text-faint
Label: Crimson Pro, --text-sm, --text-secondary, inline with 8px gap.
Toggle (on/off switch, e.g. Settings):
Track width: 36px, height: 20px, border-radius: 10px
Off: track --bg-raised, border --border-default; thumb --text-faint
On: track --green-dim, border --green-mid; thumb --green-bright
Transition: 160ms ease on track background, thumb translateX
No toggle used for gameplay-critical state (use explicit Accept / Cancel buttons). Toggle only in Settings.
Tabs¶
Tab bar:
border-bottom: 1px solid var(--border-default)
gap: 0
Tab item:
font-family: Crimson Pro, serif
font-size: var(--text-sm)
font-weight: 600
letter-spacing: 0.08em
color: var(--text-muted)
padding: var(--space-2) var(--space-4)
border-bottom: 2px solid transparent
cursor: pointer
hover:
color: var(--text-secondary)
border-bottom: 2px solid var(--border-active)
active/selected:
color: var(--text-primary)
border-bottom: 2px solid var(--gold-mid)
No background fill on tabs. Active state is border-bottom accent only — consistent with medieval ledger aesthetic.
Primary use: Board (Available / My Orders / Archived), Market (Buy / Sell).
Tooltip¶
Appears on hover/focus after 400ms delay. Dismisses immediately on pointer-leave.
background: var(--bg-raised)
border: 1px solid var(--border-default)
border-radius: 3px
box-shadow: 0 4px 16px rgba(0,0,0,0.64)
color: var(--text-secondary)
font-family: Crimson Pro, serif
font-size: var(--text-xs)
padding: var(--space-1) var(--space-2)
max-width: 240px
pointer-events: none
z-index: 900
Mount with transition:fly={{ y: -4, duration: 120 }} + fade. No arrow/caret — clean rectangular panel only. No tooltip on mobile-width viewports; use tap-expand instead.
Dropdown / Popover¶
Attached to trigger element. Opens below (flips above if viewport clip).
background: var(--bg-raised)
border: 1px solid var(--border-default)
border-radius: 4px
box-shadow: 0 8px 32px rgba(0,0,0,0.64)
min-width: 160px
padding: var(--space-1) 0
z-index: 800
Mount with transition:fly={{ y: 6, duration: 140, easing: cubicOut }} + fade.
Dropdown item:
padding: var(--space-2) var(--space-4)
color: var(--text-secondary)
font-family: Crimson Pro, serif
font-size: var(--text-sm)
cursor: pointer
hover:
background: var(--bg-hover)
color: var(--text-primary)
selected/active:
color: var(--gold-bright)
font-weight: 600
disabled:
color: var(--text-faint)
cursor: not-allowed
Divider between groups: border-top: 1px solid var(--border-faint), margin: var(--space-1) 0.
Toast / Notification¶
Non-blocking. Stacks at top-right (desktop) or top-center (narrow). AutoAnimate on container.
background: var(--bg-raised)
border: 1px solid var(--border-default)
border-radius: 4px
box-shadow: 0 4px 24px rgba(0,0,0,0.64)
padding: var(--space-3) var(--space-4)
min-width: 260px
max-width: 360px
font-family: Crimson Pro, serif
font-size: var(--text-sm)
color: var(--text-secondary)
Mount: fly={{ x: 24, duration: 200, easing: cubicOut }}. Dismiss after 4s (or 6s for urgent). Dismiss on click anywhere on toast. No dismiss button needed for auto-expiring toasts.
Left border accent by type:
| Type | Left border | Icon color |
|---|---|---|
| Info (default) | --border-active |
--text-muted |
| Success | --green-mid |
--green-bright |
| Warning | --amber-mid |
--amber-bright |
| Error | --red-mid |
--red-bright |
| Reward | --gold-mid |
--gold-bright |
Headline: font-weight: 600, --text-primary. Body: --text-secondary. Timestamp (optional): --text-xs, --text-muted.
Sidebar / Navigation Rail¶
Left side, fixed. Width: 56px icon-only (collapsed) or 200px (expanded). Collapsed is default on viewport < 1280px.
Mount/unmount: transition:fly={{ x: -200, duration: 220, easing: cubicOut }}.
Nav item:
display: flex; align-items: center; gap: var(--space-3)
padding: var(--space-2) var(--space-4)
color: var(--text-muted)
font-family: Crimson Pro, serif
font-size: var(--text-sm)
font-weight: 600
border-left: 3px solid transparent
cursor: pointer
hover:
background: var(--bg-hover)
color: var(--text-secondary)
active (current route):
border-left: 3px solid var(--gold-mid)
color: var(--text-primary)
background: var(--bg-elevated)
Icons: SVG, 20×20, currentColor, stroke-width: 1.5. No filled icon style.
Pagination¶
Use when list exceeds one page. Prefer server-cursor pagination over page numbers for board and market (show "Load more" instead when feasible).
display: flex; gap: var(--space-2); align-items: center
font-family: Crimson Pro, serif
font-size: var(--text-sm)
color: var(--text-secondary)
Page button:
padding: var(--space-1) var(--space-3)
border: 1px solid var(--border-default)
border-radius: 3px
background: transparent
color: var(--text-secondary)
cursor: pointer
hover:
background: var(--bg-hover)
border-color: var(--border-active)
current page:
background: var(--bg-elevated)
border-color: var(--gold-mid)
color: var(--text-primary)
font-weight: 600
cursor: default
Ellipsis (…) in --text-faint, no border, no pointer.
Inventory Slot¶
Square, fixed 64×64px. Grid layout, gap: var(--space-2).
background: var(--bg-elevated)
border: 1px solid var(--border-faint)
border-radius: 3px
position: relative
States:
empty:
background: var(--bg-elevated)
border: 1px solid var(--border-faint)
occupied (item present):
border-color: var(--border-default)
hover (occupied):
border-color: var(--border-bright)
background: var(--bg-hover)
selected:
border-color: var(--gold-mid)
background: var(--bg-raised)
Item quantity badge: bottom-right corner, --text-xs, --text-secondary, --bg-base background, border-radius: 2px, padding: 0 3px.
Quality indicator: 2px top border only, color from quality tier:
| Tier | Color |
|---|---|
| Common | --border-faint (effectively invisible) |
| Uncommon | --green-mid |
| Rare | --gold-mid |
| Exceptional | --arcane-bright |
Party Member Row¶
Used in expedition roster panel and town guild roster view.
display: flex; align-items: center; gap: var(--space-3)
padding: var(--space-2) var(--space-3)
border-bottom: 1px solid var(--border-faint)
background: transparent
hover:
background: var(--bg-hover)
Layout (left to right): Avatar placeholder 32×32px → Name --text-base font-weight:600 → Role/archetype tag --text-xs pill → Health bar 60px wide → Status badge.
Avatar: square border-radius: 3px, --bg-raised background, --border-faint border.
Status badge tokens:
| Status | Background | Text |
|---|---|---|
| Ready | --green-dim |
--green-bright |
| On expedition | --bg-raised |
--text-muted |
| Injured | --red-dim |
--red-bright |
| Resting | --arcane-dim |
--arcane-bright |
Expedition Status Card¶
In-progress expedition card shown on Town dashboard. Extends base Card spec.
border-color: var(--border-active) /* overrides --border-default while active */
padding: var(--space-4)
Active state: expedition-breathe CSS animation (border-color shift, see Animation section).
Layout (top to bottom):
- Header row: Route name
--text-md font-weight:600| Risk badge (right-aligned) - Party row: character name list, comma-separated,
--text-sm --text-muted - Progress row: supply bar (
Tween,--red-midfill draining left), timer pill (right-aligned) - Status line: current stage label
--text-xs --text-faint letter-spacing:0.12em uppercase
On settlement: border-color transitions to --green-mid (success) or --red-mid (failure/injury) over 300ms. expedition-breathe animation stops.
Character Sheet Panel¶
Full-height side panel or modal. Contains character identity, stats, skills, and progression.
background: var(--bg-raised)
border: 1px solid var(--border-default)
border-radius: 4px
padding: var(--space-6)
width: 320px
Sections (top to bottom):
- Identity block: Name
--text-2xl Cinzel font-weight:700| Lineage tag--text-sm --text-muted| Hall rank badge (inline pill, same spec as Skill Band Badge but gold-tier colors for rank F–S) - Archetype indicator: Icon + label row,
--arcane-brighttext if ritual archetype,--gold-midif combat,--green-brightif support. - Skill rows: One row per skill. Label
--text-sm font-weight:600(left) | Band badge (right) | Progress bar beneath (6px,--gold-midfill,Tween-animated). When within 20% of next band: inline estimate label--text-xs --text-mutedappears below bar (per Skill Proximity Display spec incharacter-progression-and-roles.md). - Stats block: Key–value pairs in 2-column grid. Key:
--text-xs uppercase letter-spacing:0.12em --text-faint. Value:--text-sm --text-primary font-weight:600. - Renown / reputation row: Faction name | inline bar
--arcane-midfill | tier label.
Dividers between sections use --border-faint rule.
Codex Entry¶
Long-form lore, creature bestiary, cartography notes. Maximum prose width 68ch.
font-family: Spectral, serif
font-size: var(--text-base)
line-height: 1.75
color: var(--text-primary)
Heading levels inside codex:
| Level | Font | Size | Color | Spacing |
|---|---|---|---|---|
| H2 (section) | Cinzel 600 | --text-xl |
--text-primary |
margin-top: var(--space-8) |
| H3 (subsection) | Cinzel 600 | --text-lg |
--text-secondary |
margin-top: var(--space-6) |
| H4 (entry label) | Crimson Pro 600 italic | --text-md |
--text-secondary |
margin-top: var(--space-4) |
Flavor text / pull quote:
font-style: italic
color: var(--text-muted)
border-left: 3px solid var(--border-default)
padding-left: var(--space-4)
margin: var(--space-4) 0
Creature / item stat block (inside codex entry):
background: var(--bg-elevated)
border: 1px solid var(--border-faint)
border-radius: 4px
padding: var(--space-4)
font-family: Crimson Pro, serif
font-size: var(--text-sm)
Key–value layout same as Character Sheet stats block.
Scroll-triggered opacity reveal (inView) permitted on codex sections only. No Y-translate (per Design Principles anti-pattern rule).
Empty State¶
Shown when a list or panel has no content.
display: flex; flex-direction: column; align-items: center
padding: var(--space-12) var(--space-6)
color: var(--text-faint)
font-family: Crimson Pro, serif
text-align: center
Content structure (top to bottom):
- Icon —
40×40Lucide SVG,stroke-width: 1.5,--text-faint - Headline —
--text-base,--text-muted,font-weight: 600,margin-top: var(--space-3) - Body —
--text-sm,--text-faint,max-width: 28ch,margin-top: var(--space-2) - Action button (optional) — Ghost button,
margin-top: var(--space-4)
| Context | Icon | Headline | Body |
|---|---|---|---|
| Board — no orders | Scroll |
"No orders available" | "Check back after the next town cycle." |
| Market — no listings | Scale |
"Nothing listed" | "Post an item or try different filters." |
| Inventory — empty | Box |
"Inventory empty" | (omit) |
| Codex — no entries | BookOpen |
"Nothing recorded yet" | "Encounter creatures and explore sites to fill this section." |
Error State¶
Form field validation error:
Input: border-color: var(--red-mid) /* replaces --border-default */
Error text (below field):
color: var(--red-bright)
font-family: Crimson Pro, serif
font-size: var(--text-xs)
margin-top: 4px
role="alert" (or aria-live="assertive")
Appears on blur or first submit attempt. Clears on re-type after first error (live validation after initial trigger).
Panel-level server error:
background: var(--red-dim)
border: 1px solid var(--red-mid)
border-radius: 4px
padding: var(--space-3) var(--space-4)
color: var(--red-bright)
font-family: Crimson Pro, serif
font-size: var(--text-sm)
role: "alert"
Close button (×) top-right. Auto-dismiss after 8s if non-critical. Auth failures and data-loss errors do not auto-dismiss.
Skeleton / Loading Placeholder Layout¶
skeleton-pulse CSS keyframe (defined in Animation section) drives all skeletons. This section defines layout shapes.
Board card skeleton (≈88px tall — matches live card height):
Row 1: width 55%, height 14px — title
Row 2: width 30%, height 10px — subtitle / type
Row 3: flex row — 15% + 20% + 12% wide,
height 10px, gap var(--space-2) — badges
Row 4: width 40%, height 10px,
margin-left: auto — reward (right-aligned)
Each row: background --bg-elevated, border-radius 2px
All rows: margin-top var(--space-2), skeleton-pulse animation
Character row skeleton (≈44px tall):
Left: 32×32px circle (avatar) — --bg-elevated, border-radius 50%
Right: two rows — 55% + 30% width, height 10px each, gap var(--space-1)
Prose / panel skeleton:
3–5 full-width rows at varying widths: 90%, 80%, 70%, 85%, 60%
Height 12px each, gap var(--space-2), skeleton-pulse
Skeleton timeout: 10s. Replace with panel-level server error if data has not arrived.
Animation System¶
Four layers. Use the cheapest layer that covers the need. Never combine layers for the same element.
All animation must respect prefers-reduced-motion. The global guard is the Svelte 5 prefersReducedMotion reactive value from svelte/motion. When prefersReducedMotion.current is true, skip JS-driven transitions and collapse CSS keyframe durations to 0.01ms.
Layer 1 — Svelte 5 Built-ins (zero bundle cost)¶
Install: none. Already in Svelte.
import { Tween, Spring, prefersReducedMotion } from 'svelte/motion'
import { fade, fly, scale, draw } from 'svelte/transition'
import { flip } from 'svelte/animate'
import { cubicOut, linear, expoOut } from 'svelte/easing'
Panel mount / unmount:
Combine withtransition:fade={{ duration: 140 }} on the overlay backdrop.
Modal:
- Backdrop: transition:fade={{ duration: 200 }}
- Content box: transition:scale={{ start: 0.96, duration: 180, easing: cubicOut }}
Sidebar / drawer:
Progress bars and health bars — Tween:
const health = new Tween(currentHealth, { duration: 400, easing: cubicOut })
// Update: health.target = newValue
// Bind: style:width="{health.current / maxHealth * 100}%"
Countdown timer — Tween:
Linear easing keeps timer drain visually accurate.Numeric value changes (coin, quantities) — Tween:
const coinDisplay = new Tween(coin, { duration: 600, easing: cubicOut })
// Bind: {Math.round(coinDisplay.current)}
Spring for physical feel (drag, fling, interactive elements):
List reorder — flip:
SVG path draw (expedition route reveal):
Layer 2 — @formkit/auto-animate (~2kb)¶
Use for lists and containers where items are added, removed, or reordered by server updates. Drop-in Svelte action — no configuration required.
<script>
import autoAnimate from '@formkit/auto-animate'
</script>
<!-- Board order list -->
<ul use:autoAnimate>
{#each boardOrders as order (order.id)}
<li>...</li>
{/each}
</ul>
<!-- Market listings -->
<div use:autoAnimate>
{#each listings as listing (listing.id)}
<MarketCard {listing} />
{/each}
</div>
AutoAnimate automatically respects prefers-reduced-motion.
Use on: board order lists, market listings, inventory slot grid, notification/toast queue, party member list.
Do not use on: SVG elements, canvas, any container that will have more than ~50 simultaneous items in view (prefer pagination and flip from Svelte for performance control).
Layer 3 — Motion v12 (2.3kb mini build)¶
Use for: SVG path animations, staggered entrance reveals, scroll-triggered content, one-off imperative animations that Svelte transitions can't drive.
Staggered board card entrance on route load:
import { animate, stagger } from 'motion'
onMount(() => {
animate(
'.board-card',
{ opacity: [0, 1] },
{ delay: stagger(0.05), duration: 0.25, easing: 'ease-out' }
)
})
SVG expedition route reveal (stroke-dashoffset):
import { animate } from 'motion'
animate(
routePathEl,
{ strokeDashoffset: [pathLength, 0] },
{ duration: 1.2, easing: 'ease-in-out' }
)
stroke-dasharray: pathLength on the path element beforehand.
Map region highlight pulse:
animate(
regionEl,
{ opacity: [0.3, 0.7, 0.3] },
{ duration: 2.4, repeat: Infinity, easing: 'ease-in-out' }
)
Scroll-triggered content reveal (Codex and lore pages only — never on core game UI screens):
import { inView, animate } from 'motion'
inView('.codex-section', ({ target }) => {
animate(target, { opacity: [0, 1] }, { duration: 0.4, easing: 'ease-out' })
})
Gate all Motion calls behind prefersReducedMotion.current check from Svelte:
Layer 4 — CSS Keyframes (pure CSS, no JS)¶
Ambient, idle, and loading animations. Cheapest possible. Must be wrapped in the reduced-motion media query.
@media (prefers-reduced-motion: no-preference) {
/* Torch / ember flicker — use on lantern SVG elements in town view */
@keyframes flicker {
0% { opacity: 1; filter: brightness(1); }
20% { opacity: 0.90; filter: brightness(0.92); }
40% { opacity: 1; filter: brightness(1.04); }
60% { opacity: 0.93; filter: brightness(0.96); }
80% { opacity: 1; filter: brightness(1.02); }
100% { opacity: 0.95; filter: brightness(0.94); }
}
.torch-glow { animation: flicker 2.4s ease-in-out infinite; }
/* Map fog drift — use on SVG fog overlay layer */
@keyframes fog-drift {
0% { transform: translateX(0) translateY(0); opacity: 0.55; }
50% { transform: translateX(12px) translateY(-6px); opacity: 0.65; }
100% { transform: translateX(0) translateY(0); opacity: 0.55; }
}
.fog-layer { animation: fog-drift 18s ease-in-out infinite; }
/* Active expedition breathing — on the expedition status card while in progress */
@keyframes expedition-breathe {
0%, 100% { border-color: var(--border-active); }
50% { border-color: var(--border-bright); }
}
.expedition-active { animation: expedition-breathe 3s ease-in-out infinite; }
/* Gold glow pulse — on selected items, highlighted rewards */
@keyframes gold-pulse {
0%, 100% { border-color: var(--gold-mid); }
50% { border-color: var(--gold-bright); }
}
.reward-highlight { animation: gold-pulse 2.2s ease-in-out infinite; }
/* Skeleton / loading pulse — on placeholder cards while data loads */
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.skeleton {
background: var(--bg-elevated);
border: 1px solid var(--border-faint);
animation: skeleton-pulse 1.8s ease-in-out infinite;
}
/* Danger flash — on cards when an expedition enters critical state */
@keyframes danger-flash {
0%, 100% { border-color: var(--border-default); }
50% { border-color: var(--red-bright); }
}
.danger-flash { animation: danger-flash 1.2s ease-in-out 3; }
/* Arcane shimmer — on warding and ritual overlays */
@keyframes arcane-shimmer {
0%, 100% { opacity: 0.6; filter: brightness(0.9); }
50% { opacity: 0.88; filter: brightness(1.12); }
}
.arcane-active { animation: arcane-shimmer 4s ease-in-out infinite; }
}
Route-Level Animation Patterns¶
Boot → Login transition¶
--bg-basefill fades in from black over600ms- Title
flyin from y=20,expoOut,400msafter fade completes
Town route initial load¶
- Panel backgrounds appear instantly (no animation)
- Board cards stagger in with Motion
animate + stagger(0.06)after a150msinitial delay - Active expedition card begins
expedition-breatheCSS animation immediately
Expedition departure (planning → in-progress)¶
- Expedition card morphs state in place (border color transitions,
Tween-animated supply bar drain begins) - Route path on world map reveals with
drawtransition (SVG stroke) over900ms - Planning panel
flyout, status panelflyin from same direction
Board order arrival (server push)¶
- AutoAnimate handles the DOM insertion
- New card briefly has
--border-activeborder (CSS transition) then settles to--border-default - If the order matches the character's archetype bias:
gold-pulseCSS animation plays once for2.2sthen stops
Expedition return (result settlement)¶
- Result modal appears: backdrop
fade, contentscalefrom 0.96 - Coin number animates with
Tween(600ms,cubicOut) - XP bar fills with
Tween(800ms,cubicOut), potentially triggering skill-band upgrade flash (green-brightborder flash, 1 cycle ofgold-pulseif Expert or Master reached) - If injury:
danger-flashon the character health bar (3 cycles)
Character creation preview¶
- Each choice panel transitions in with
fly y=8 + fade, 180ms - SVG character preview layers fade in individually as choices are made (staggered
fadevia Svelte{#if}blocks) - Village backdrop swaps with a
200mscross-fade between two absolutely-positioned SVG elements
Market route initial load¶
- Panel backgrounds appear instantly
- Listing cards stagger in with Motion
animate + stagger(0.04)after100msinitial delay - Filter/search controls appear instantly — player may be returning to a saved filter state; no animation implying a fresh start
Codex route initial load¶
- Page background fades in over
300ms - Codex entry content fades in
opacity: [0, 1]over400ms— single fade, no stagger inViewscroll-triggered reveals active on this route only (see Layer 3)
Map route initial load¶
- SVG world map fades in over
400ms - Known-terrain tiles appear at full opacity; fog overlay fades in at
200msdelay after map layer - If character has active expedition: route path draws in with
drawtransition over900msafter map is visible - No stagger on individual hex tiles — entire layer fades as a group (too many elements for per-tile animation)
Settings route¶
No animation. All panels appear instantly. Settings are infrequently visited — zero animation overhead is appropriate.
Sound Events¶
Five sounds cover the critical engagement touchpoints. No looping ambient audio at launch. All sounds respect browser autoplay policy — none play before the user has interacted with the page in the current session.
| Sound | Trigger | Character | Notes |
|---|---|---|---|
| Expedition return chime | Expedition settles; result available | Warm, brief — medieval bell or struck bowl. Not a generic notification ping. | The only sound that fires in a background tab. Requires prior user interaction per session (browser autoplay policy). |
| Skill band advance | Settlement write advances a band | Resonant, single struck note with short sustain. Rare — should feel earned. | Fires in result modal only, not mid-background. |
| Coin counter tick | Result modal coin Tween animating |
Fast, light, metallic. Stops when tween completes. | Tick rate capped at 100 ticks/sec to prevent audio spam on large payouts. |
| Town alarm | Town supply enters Critical band or lower | Low, sustained, fades after 3 seconds | Plays once per login if current tracked town is Critical or below. Not repeated until next login. |
| Urgent offer alert | New Emergency-tier board offer appears on refresh | Single brief tone, distinct from return chime | Plays on board refresh only if at least one new Emergency offer was added. |
Sound Technical Notes¶
- File formats: provide
.mp3(primary) and.ogg(fallback). List in<audio>source priority order for browser compat. - Load via Web Audio API (
AudioContext+fetch→decodeAudioData). Store decodedAudioBufferin module scope after first load — do not re-fetch per play. AudioContextcreated lazily on first user gesture. Do not construct on page load (blocked by autoplay policy).- Background-tab chime reuses the same
AudioContextfrom that session's first user gesture. - Total sound asset budget:
<50KBcombined (all 5 sounds,.mp3).
Sound Rules¶
- No sound plays on page load.
- Volume is a single slider in Settings. Default: 60%.
- When
prefers-reduced-motionis active, sound defaults to muted. Player can opt back in via Settings. - Store preference in
localStoragekeyaelghar_sound_enabled(boolean) andaelghar_sound_volume(0–1). - All sounds are under 2 seconds. No looping.
- Coin tick and skill advance sounds require the result modal to be open and in the foreground.
Accessibility¶
Minimum compliance target: WCAG 2.1 AA.
Focus Management¶
- All interactive elements (
button,a,input,select, checkbox, toggle, tab, nav item) must be keyboard-reachable viaTab. - Focus ring:
box-shadow: 0 0 0 2px rgba(58,114,152,0.18). Apply via:focus-visible— not:focus— to avoid showing ring on mouse click. - Modal open → move focus to first interactive element inside modal. On close → return focus to the trigger element.
- Focus trap inside open modals:
Tabcycles within modal only.Escapedismisses.
ARIA¶
| Element | Required attribute(s) |
|---|---|
| Modal / dialog | role="dialog", aria-modal="true", aria-labelledby pointing to header title id |
| Toast queue container | aria-live="polite", aria-atomic="false" |
| Form validation error text | role="alert" or aria-live="assertive" |
| Icon-only buttons (close ×, nav icons) | aria-label required |
| Progress bars | role="progressbar", aria-valuenow, aria-valuemin="0", aria-valuemax |
| Skill Band Badge | aria-label with full band name (e.g. aria-label="Skilled") |
| Tabs | role="tablist" on bar; role="tab" on items; role="tabpanel" on panels; aria-selected; aria-controls |
| Skeleton containers | aria-busy="true" while loading; remove on data arrival |
Keyboard Navigation¶
| Context | Key behavior |
|---|---|
| Tabs | ← / → moves between tabs; Enter / Space activates |
| Dropdown / Popover | ↓ opens; ↑/↓ navigates items; Enter selects; Escape closes and returns focus to trigger |
| Modal | Escape closes; Tab / Shift+Tab cycles within focus trap |
| Board card list | ↑/↓ moves between cards; Enter opens detail |
| Inventory grid | ←/→/↑/↓ navigates slots; Enter selects |
Color Contrast¶
Verified pairs (approximate ratios):
| Token pair | Ratio | Level |
|---|---|---|
--text-primary on --bg-elevated |
≥ 7:1 | AAA |
--text-secondary on --bg-surface |
≥ 4.5:1 | AA |
--text-muted on --bg-elevated |
~3:1 | AA Large only |
--gold-mid on --bg-elevated |
~4.5:1 | AA |
--red-bright on --red-dim |
≥ 4.5:1 | AA |
--text-faint is intentionally sub-AA. Use only for decorative / placeholder content (disabled fields, structural dividers). Never for informational content a user must read.
Scroll and Layout Notes¶
- Routes should not require heavy scrolling for core task completion. Primary action (accept order, confirm expedition, check result) must be reachable without scrolling on a standard 1080p window.
- Overflow-scrolling panels (board list, market list, codex) use
overflow-y: auto, no custom scroll styling beyond::-webkit-scrollbarthinning to4pxtrack,--bg-hoverthumb,--bg-raisedtrack. - Maximum readable prose width:
68ch(codex, journal). All other panels are fixed-width columns, not centering a prose block. - Scroll reset on route change: SvelteKit scrolls to top on navigation by default. Override to preserve scroll position only for: Board tab switching (Available ↔ My Orders), Market filter param changes (same route, updated query). All other navigations reset to top.
Mobile and Responsive¶
The game is async and low-input — a strong fit for mobile. Desktop is the primary target; mobile is fully supported. Breakpoints, touch targets, and navigation pattern are defined here.
Breakpoints¶
| Name | Min-width | Primary layout |
|---|---|---|
sm |
0 | Single-column, bottom nav |
md |
640px | Two-column panels, bottom nav |
lg |
1024px | Sidebar visible (collapsed 56px) |
xl |
1280px | Sidebar expanded 200px |
Prefer CSS @media (min-width: ...) over JS window listeners. Use Svelte $state + window.matchMedia only when layout must change JS behavior (e.g. map touch vs. mouse controls).
Touch Targets¶
All interactive elements must meet a 44×44px minimum touch target (Apple HIG / WCAG 2.5.5).
| Element | Visual size | Touch target enforcement |
|---|---|---|
| Nav item (sidebar/bottom bar) | varies | min-height: 44px; min-width: 44px |
| Button — Primary / Ghost | height driven by padding | padding: 10px var(--space-6) at sm (forces ≥44px height) |
| Tab item | --space-2 pad top/bottom |
Add padding-block: 10px at sm |
| Checkbox | 16×16 visual | Wrap in <label> with min-height: 44px; display: flex; align-items: center |
| Inventory slot | 64×64 | Meets target natively |
| Pagination button | small | min-width: 44px; min-height: 44px at sm |
| Dropdown item | text-driven | min-height: 44px at sm |
Bottom Navigation (sm / md)¶
At <lg the sidebar is hidden. Primary navigation moves to a fixed bottom bar.
position: fixed; bottom: 0; left: 0; right: 0
height: 56px + env(safe-area-inset-bottom)
background: var(--bg-surface)
border-top: 1px solid var(--border-faint)
display: flex; justify-content: space-around; align-items: center
padding-bottom: env(safe-area-inset-bottom)
z-index: 700
Bottom nav item:
display: flex; flex-direction: column; align-items: center; gap: 2px
min-width: 44px; min-height: 44px
color: var(--text-muted)
font-family: Crimson Pro, serif
font-size: 10px
letter-spacing: 0.08em
active (current route):
color: var(--gold-mid)
Icon: SVG 22×22, currentColor, stroke-width: 1.5
Items (up to 5): Town, Board, Expedition, Map, Character. Overflow into a "More" item with a sheet drawer if routes exceed 5.
Safe Area Insets (Notched Phones)¶
Apply env(safe-area-inset-*) to all fixed-position elements:
/* app shell — global */
.app-shell {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* bottom nav */
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom);
height: calc(56px + env(safe-area-inset-bottom));
}
/* main content area when bottom nav is visible */
.main-content {
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
Required in app.html:
Font Size Floor¶
--text-xs (12px) is the minimum rendered size. At sm breakpoint, bump up one step where --text-xs is used for primary reading content:
| Use | Desktop | Mobile (sm) |
|---|---|---|
| Badge / pill text | --text-xs |
--text-xs (ok — decorative) |
| Tooltip body | --text-xs |
Hidden — use tap-expand |
| Timer pill | --text-xs |
--text-sm |
| Inventory quantity badge | --text-xs |
--text-xs (ok — 64px slot) |
| Codex caption | --text-xs |
--text-sm |
| Stat block key labels | --text-xs |
--text-sm |
Tap-Expand Pattern¶
Replaces tooltip on touch devices. Tap target shows an inline expanded row or a bottom sheet with the same content a tooltip would show.
Inline expand (for board cards, skill rows, inventory slots):
State: collapsed (default)
Tap → toggle boolean → Svelte {#if expanded} block appended below the tapped element
Expand: transition:fly={{ y: -4, duration: 140 }} + fade
Second tap or tap-outside: collapse
Bottom sheet (for complex tooltip content — item detail, risk breakdown):
Overlay: position fixed, inset 0, rgba(0,0,0,0.5) backdrop, fade 200ms
Sheet: position fixed, bottom 0, left 0, right 0
background var(--bg-raised), border-top 1px solid --border-default
border-radius 12px 12px 0 0, padding var(--space-6)
padding-bottom: calc(var(--space-6) + env(safe-area-inset-bottom))
Mount: fly y:60 duration:240 cubicOut
Dismiss: swipe down (Svelte pointer events on handle bar) or tap backdrop
Handle bar: 32×4px, --bg-hover, border-radius 2px, centered, margin-bottom var(--space-4)
Use bottom sheet for: item full detail, risk breakdown, party member detail, skill description. Use inline expand for: quick stat reveal, badge explanation, minor label clarification.
Inventory Grid Reflow¶
At sm: slot grid goes from fixed 64px to fluid minmax(56px, 1fr) grid. Minimum slot 56×56px (still above 44px touch target).
@media (max-width: 639px) {
.inventory-grid {
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
}
.inventory-slot {
width: 100%;
aspect-ratio: 1;
}
}
World Map on Touch¶
The hex map SVG (160×120 grid) requires pan and pinch-zoom on touch. Mouse-wheel zoom and click-to-select do not translate to mobile.
Approach: SVG viewport transform controlled by pointer events on a transparent overlay div. Do not add touch listeners directly to individual hex elements — attach one handler to the overlay and translate screen coords to hex coords using offset-q math.
Pan: 1-finger drag → translate SVG viewBox origin
Zoom: 2-finger pinch → scale SVG viewBox (clamp: 0.4× – 4× native)
Tap: single tap with <5px movement → select hex (same as desktop click)
Long-press (500ms, <5px movement): open hex bottom sheet (context actions)
Momentum/inertia after pan: optional, implement with Spring from svelte/motion on viewBox coordinates if desired.
Map toolbar (zoom in/out buttons, center-on-character) must remain reachable without two-handed grip — place at bottom-right of map view, above bottom nav.
Implementation Checklist¶
Reference this list when bringing up a new route or component.
- [ ] CSS variables declared in
:rootand imported globally - [ ] Google Fonts
linkpreloaded inapp.html - [ ]
viewport-fit=coverinapp.html<meta name="viewport"> - [ ]
env(safe-area-inset-*)applied to app shell and bottom nav - [ ]
prefersReducedMotionimported fromsvelte/motionand checked before any JS animation call - [ ] AutoAnimate applied to all dynamic list containers
- [ ] Svelte
Tweenused for all progress bar and numeric value changes - [ ] CSS keyframes wrapped in
@media (prefers-reduced-motion: no-preference) - [ ] Motion
animatecalls gated behindprefersReducedMotion.currentcheck - [ ] No animation runs continuously on a hidden or off-screen element
- [ ] Skeleton loaders shown while awaiting server data (no blank panels)
- [ ] Timer pills switch to amber/red color at correct thresholds (10% / 2%)
- [ ] All interactive elements meet 44×44px touch target at
smbreakpoint - [ ] Tooltips replaced with tap-expand or bottom sheet on touch-width viewports
- [ ] Bottom nav rendered (sidebar hidden) at
<lgbreakpoint - [ ] Inventory grid uses fluid
minmax(56px, 1fr)columns atsm - [ ] World map has pointer-event overlay for touch pan/pinch-zoom
- [ ] All interactive elements have
:focus-visiblering - [ ] Modals implement focus trap; focus returns to trigger on close
- [ ] Toast container has
aria-live="polite" - [ ] Icon-only buttons have
aria-label - [ ] Skeleton containers have
aria-busy="true"while loading - [ ] Empty states defined for all list/panel components
- [ ] Skeleton height matches live component height
Related Documents¶
client-and-svg-pipeline.md— SVG layer stack, route set, art direction rulesbrowser-first-constraints.md— performance and session-length constraints that drive the design../world/character-origins.md— creation route panels (step sequence, preview layer stack)