Skip to content

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, descriptions
  • 600 — headings, labels, emphasized UI text
  • 700 — primary Cinzel display only (town names, route titles)

Letter Spacing

  • Cinzel headings: letter-spacing: 0.08em (default), 0.15em for 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).

npm install lucide-svelte
<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

border: none;
border-top: 1px solid var(--border-faint);
margin: var(--space-4) 0;

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.

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.

Left side, fixed. Width: 56px icon-only (collapsed) or 200px (expanded). Collapsed is default on viewport < 1280px.

background:      var(--bg-surface)
border-right:    1px solid var(--border-faint)
padding:         var(--space-4) 0

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):

  1. Header row: Route name --text-md font-weight:600 | Risk badge (right-aligned)
  2. Party row: character name list, comma-separated, --text-sm --text-muted
  3. Progress row: supply bar (Tween, --red-mid fill draining left), timer pill (right-aligned)
  4. 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):

  1. 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)
  2. Archetype indicator: Icon + label row, --arcane-bright text if ritual archetype, --gold-mid if combat, --green-bright if support.
  3. Skill rows: One row per skill. Label --text-sm font-weight:600 (left) | Band badge (right) | Progress bar beneath (6px, --gold-mid fill, Tween-animated). When within 20% of next band: inline estimate label --text-xs --text-muted appears below bar (per Skill Proximity Display spec in character-progression-and-roles.md).
  4. 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.
  5. Renown / reputation row: Faction name | inline bar --arcane-mid fill | 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):

  1. Icon — 40×40 Lucide SVG, stroke-width: 1.5, --text-faint
  2. Headline — --text-base, --text-muted, font-weight: 600, margin-top: var(--space-3)
  3. Body — --text-sm, --text-faint, max-width: 28ch, margin-top: var(--space-2)
  4. 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:

<div transition:fly={{ y: 8, duration: 180, easing: cubicOut }}>
Combine with transition: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:

<nav transition:fly={{ x: -240, duration: 220, easing: cubicOut }}>

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:

const timer = new Tween(remainingMs, { duration: tickInterval, easing: linear })
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):

const pos = new Spring({ x: 0, y: 0 }, { stiffness: 0.15, damping: 0.8 })

List reorder — flip:

{#each orders as order (order.id)}
  <div animate:flip={{ duration: 250 }}>...</div>
{/each}

SVG path draw (expedition route reveal):

<path transition:draw={{ duration: 900, easing: cubicOut }} d="..." />

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.

npm install @formkit/auto-animate
<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.

npm install motion

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' }
)
Set 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' })
})
No Y-translate. Opacity only. Game UI screens (board, town, expedition) must not use scroll-triggered entrance effects.

Gate all Motion calls behind prefersReducedMotion.current check from Svelte:

if (!prefersReducedMotion.current) {
  animate(...)
}

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-base fill fades in from black over 600ms
  • Title fly in from y=20, expoOut, 400ms after fade completes

Town route initial load

  • Panel backgrounds appear instantly (no animation)
  • Board cards stagger in with Motion animate + stagger(0.06) after a 150ms initial delay
  • Active expedition card begins expedition-breathe CSS 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 draw transition (SVG stroke) over 900ms
  • Planning panel fly out, status panel fly in from same direction

Board order arrival (server push)

  • AutoAnimate handles the DOM insertion
  • New card briefly has --border-active border (CSS transition) then settles to --border-default
  • If the order matches the character's archetype bias: gold-pulse CSS animation plays once for 2.2s then stops

Expedition return (result settlement)

  • Result modal appears: backdrop fade, content scale from 0.96
  • Coin number animates with Tween (600ms, cubicOut)
  • XP bar fills with Tween (800ms, cubicOut), potentially triggering skill-band upgrade flash (green-bright border flash, 1 cycle of gold-pulse if Expert or Master reached)
  • If injury: danger-flash on 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 fade via Svelte {#if} blocks)
  • Village backdrop swaps with a 200ms cross-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) after 100ms initial 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] over 400ms — single fade, no stagger
  • inView scroll-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 200ms delay after map layer
  • If character has active expedition: route path draws in with draw transition over 900ms after 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 + fetchdecodeAudioData). Store decoded AudioBuffer in module scope after first load — do not re-fetch per play.
  • AudioContext created lazily on first user gesture. Do not construct on page load (blocked by autoplay policy).
  • Background-tab chime reuses the same AudioContext from that session's first user gesture.
  • Total sound asset budget: <50KB combined (all 5 sounds, .mp3).

Sound Rules

  • No sound plays on page load.
  • Volume is a single slider in Settings. Default: 60%.
  • When prefers-reduced-motion is active, sound defaults to muted. Player can opt back in via Settings.
  • Store preference in localStorage key aelghar_sound_enabled (boolean) and aelghar_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 via Tab.
  • 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: Tab cycles within modal only. Escape dismisses.

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-scrollbar thinning to 4px track, --bg-hover thumb, --bg-raised track.
  • 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
/* In global CSS or a breakpoints.css module */
--bp-sm: 640px;
--bp-md: 1024px;
--bp-lg: 1280px;

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:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

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 :root and imported globally
  • [ ] Google Fonts link preloaded in app.html
  • [ ] viewport-fit=cover in app.html <meta name="viewport">
  • [ ] env(safe-area-inset-*) applied to app shell and bottom nav
  • [ ] prefersReducedMotion imported from svelte/motion and checked before any JS animation call
  • [ ] AutoAnimate applied to all dynamic list containers
  • [ ] Svelte Tween used for all progress bar and numeric value changes
  • [ ] CSS keyframes wrapped in @media (prefers-reduced-motion: no-preference)
  • [ ] Motion animate calls gated behind prefersReducedMotion.current check
  • [ ] 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 sm breakpoint
  • [ ] Tooltips replaced with tap-expand or bottom sheet on touch-width viewports
  • [ ] Bottom nav rendered (sidebar hidden) at <lg breakpoint
  • [ ] Inventory grid uses fluid minmax(56px, 1fr) columns at sm
  • [ ] World map has pointer-event overlay for touch pan/pinch-zoom
  • [ ] All interactive elements have :focus-visible ring
  • [ ] 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

  • client-and-svg-pipeline.md — SVG layer stack, route set, art direction rules
  • browser-first-constraints.md — performance and session-length constraints that drive the design
  • ../world/character-origins.md — creation route panels (step sequence, preview layer stack)