Melian

Nan Elmoth

Overview

Melian's web interface is themed "Under Boughs of Nan Elmoth": a hushed, organic dusk aesthetic evoking a hidden forest sanctuary. The design draws from Tolkien's Nan Elmoth, the dark forest where Melian the Maia dwelt with Eöl. Every visual decision is in service of that feeling: old wood, deep moss, candlelight at distance, and the occasional flash of the nightingale's throat.

Color Palette

All tokens are defined as CSS custom properties on :root in nan-elmoth.css.

Token Hex Usage
--moss-0 #0a120c Page background (the deepest forest dark)
--moss-1 #131e15 Surface level 1: cards, sidebar
--moss-2 #1d2c1e Surface level 2: hover states, inset areas
--moss-3 #2a3a2a Muted backgrounds: code blocks, table rows
--bark-1 #2a1f15 Deep bark: decorative borders
--bark-2 #3d2d1e Mid bark: dividers, inactive elements
--bark-3 #5a4228 Bark: primary borders, separators
--cream-0 #f1e8d0 Headings, Melian's voice text
--cream-1 #ddd0a8 Body text, primary labels
--cream-2 #b6a878 Muted text, secondary labels, timestamps
--rose #c98a6a Accent (the nightingale's throat)
--silver #a9b8a3 Subtle metallic accents
--moonlight #d4dccd Soft light highlights

Incognito / Angband Mode

When the user enables Incognito mode (conversations not stored to memory), the UI shifts to the Angband palette (named for Morgoth's fortress, a starker, more forbidding place). The colors are crimson-purple:

Token override Hex Notes
--moss-0 #0e080c Near-black, warm-dark
--rose #a03250 Crimson replaces rose
--cream-0 #dcc0cc Warm pinkish-white text

The switch is applied by adding the .incognito CSS class to the root element. All components use the same CSS tokens so no component-level changes are needed.

Typography

Role Font Family Weight / Style Fallback
Melian's voice Sorts Mill Goudy 400 italic Georgia, serif
Headings Sorts Mill Goudy 400 normal Georgia, serif
Body / UI Atkinson Hyperlegible 400, 700 system-ui, sans-serif
Labels / Nav Cormorant Upright 500 small-caps Georgia, serif
Code ui-monospace, SFMono-Regular, "SF Mono", Menlo 400, 700 monospace

Fonts are loaded via Google Fonts <link> tags in the HTML head.

Font size scale (in rem):

Token Value Usage
--text-xs 0.75rem Timestamps, metadata
--text-sm 0.875rem Labels, secondary UI
--text-base 1rem Body text
--text-lg 1.125rem Message text
--text-xl 1.25rem Section headings
--text-2xl 1.5rem Page headings
--text-display 2rem Empty state fleuron caption

Atmosphere Layers

Five layers are stacked in z-order beneath the application content using position: fixed; pointer-events: none so they never intercept interaction.

Layer 1: Canopy

A multi-stop radial-gradient approximates filtered light falling through a forest canopy. Three overlapping elliptical gradients in --moss-0 through --moss-3 create an uneven, organic background. The gradients are skewed slightly off-center to avoid geometric symmetry.

.atm-canopy {
  background:
    radial-gradient(ellipse 90% 60% at 30% -10%, rgba(80,90,40,0.30), transparent 60%),
    radial-gradient(ellipse 80% 120% at 70% 60%, var(--moss-1) 0%, transparent 55%),
    var(--moss-0);
}

Layer 2: Bark

A repeating-linear-gradient at a shallow angle (95deg) produces a subtle wood-grain texture using alternating --bark-1 and transparent strips. Opacity is kept at 0.18 so it reads as texture rather than pattern.

.atm-bark {
  background: repeating-linear-gradient(
    95deg,
    transparent,
    transparent 40px,
    var(--bark-1) 40px,
    var(--bark-1) 41px
  );
  opacity: 0.18;
}

Layer 3: Mist

A large radial gradient centered off-screen slowly drifts left-to-right over a 90-second cycle using atm-mist-drift with ease-in-out easing. The gradient is --cream-1 at 3% opacity fading to transparent, barely visible, but it softens the bark layer and creates a sense of movement in peripheral vision.

@keyframes atm-mist-drift {
  0%   { transform: translateX(-8%) translateY(2%); }
  50%  { transform: translateX(8%) translateY(-2%); }
  100% { transform: translateX(-8%) translateY(2%); }
}

Layer 4: Leaves

Seven div elements with individual classes fall on staggered paths using atm-fall. Each leaf has a unique border-radius shaping it into an irregular leaf silhouette. Colors rotate through --bark-3, --cream-2, and --rose. Fall durations range from 22s to 35s with animation-delay offsets to prevent clustering. Each leaf also has a subtle rotation applied mid-fall via a rotate keyframe inside atm-fall.

Layer 5: Film Grain

An SVG <feTurbulence> filter generates fractal noise, applied as a background-image: url(data:image/svg+xml,...) at 6% opacity with mix-blend-mode: overlay. This simulates analog film grain and unifies the other layers into a single coherent texture rather than discrete digital gradients.

Animation Reference

Name Duration Easing Loop Description
msg-bloom 1.0s cubic-bezier(.2,.7,.2,1) no Message bubble entrance: translateY(12px) + fade in + blur(4px)
rose-pulse 3s ease-in-out infinite Subtle border glow pulse on Melian bubbles
rose-pulse-glow 4.5s ease-in-out infinite Wider ambient glow on the empty-state fleuron
atm-mist-drift 90s ease-in-out infinite Slow horizontal drift of the mist layer
atm-fall 22s-35s linear infinite Leaf fall with per-leaf duration variation

Component Patterns

Message Bubbles

Melian's bubbles use a left border-left: 2px solid var(--rose) and box-shadow: -4px 0 12px var(--rose-glow). Text is set in Sorts Mill Goudy italic at --text-lg. The msg-bloom animation fires on mount via a CSS class added after first render (avoids triggering on scroll-restore).

User bubbles are right-aligned with no border. Font is Atkinson Hyperlegible at --text-base. Background is --moss-2 at 80% opacity.

Both bubble types have max-width: min(640px, 90vw) and border-radius: 4px.

Compose Frame

The textarea wrapper uses backdrop-filter: blur(8px) over a --moss-1 at 85% opacity base. Corner ornaments are ::before and ::after pseudo-elements on the frame div:

.compose-frame::before,
.compose-frame::after {
  content: '';
  position: absolute;
  width: 12px;
  height: 12px;
  border-color: var(--color-rose);
  border-style: solid;
  opacity: 0.6;
}
.compose-frame::before {
  top: 0; left: 0;
  border-width: 1px 0 0 1px;   /* top-left corner */
}
.compose-frame::after {
  bottom: 0; right: 0;
  border-width: 0 1px 1px 0;   /* bottom-right corner */
}

When the textarea is focused, opacity rises to 1 and the border-color transitions to var(--color-rose).

Thread Dividers

Date separators between message groups are <hr> elements with a custom style: border: none; border-top: 1px solid var(--bark-3) with a centered ::after containing the date string in Cormorant Upright small-caps at --text-xs --cream-3. The date label floats on the line via negative margin-top on a background-matched span.

Scrollbar

The message list uses a custom scrollbar to stay within the aesthetic:

.quiet-scroll::-webkit-scrollbar       { width: 4px; }
.quiet-scroll::-webkit-scrollbar-track { background: transparent; }
.quiet-scroll::-webkit-scrollbar-thumb { background: var(--bark-3); border-radius: 2px; }

Firefox uses scrollbar-width: thin; scrollbar-color: var(--bark-3) transparent.

Accessibility

prefers-reduced-motion

All atmosphere animations and the msg-bloom entrance are disabled when the user has prefers-reduced-motion: reduce set:

@media (prefers-reduced-motion: reduce) {
  .atm-mist,
  .atm-leaf,
  .atm-grain { animation: none; }

  .message-bubble { animation: none; opacity: 1; transform: none; }
}

The rose-pulse and rose-pulse-glow animations are also suppressed. Static borders replace the animated glow.

Focus Rings

All interactive elements use outline: 2px solid var(--color-rose); outline-offset: 2px on :focus-visible. The rose color has sufficient contrast against both --moss-1 and --moss-2 backgrounds to meet WCAG AA at all tested font sizes.

Color Contrast

Melian's primary text (--cream-0 on --moss-0) has a contrast ratio of approximately 11:1. Body text (--cream-1 on --moss-1) is approximately 8.5:1. Muted text (--cream-2 on --moss-1) is approximately 4.7:1, above the AA threshold for normal-sized text.