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.