10 kW LV DC blocks
ModulesParallel-ready rectifier bricks optimised for battery swap, DC wallboxes and telecom backup with unified CAN maps.
Guidelines
Resources
Safe, reliable & high-performance EV chargers, power modules, inverters, and communication controllersβengineered for the toughest conditions.
Built for clarity, precision, and speed. Our design system prioritizes data legibility without sacrificing aesthetic depth.
Interactive elements designed with deliberate feedback. Each state communicates clearly through color shifts, shadows, and micro-animations.
Parallel-ready rectifier bricks optimised for battery swap, DC wallboxes and telecom backup with unified CAN maps.
Pre-engineered cabinets with thermal design tuned for East Africa, India and Southeast Asia ambient profiles.
Module-level telemetry streaming via CAN & MQTT, with cloud dashboards for derating, alarms and predictive maintenance.
High-emphasis actions with gradient fill and dynamic glow. The shadow intensifies on hover and compresses on press.
:default :hover :active :disabled Secondary actions with transparent fill. Border glows orange on interaction, with subtle background tint.
:default :hover :active :disabled Try hovering, clicking, and interacting with these buttons:
Our palette defines the Solterra energy. The usage of Scarlet commands attention, while Solar Flare bridges the gap between technical alerts and brand warmth.
Primary Brand Color, CTAs
Accents, Gradients, Highlights
Tech Elements, Links, Info
Body Text, UI Elements
Cards, Sidebars
Backgrounds
Success, Live Status, Confirmations
The ramp anchors the three documented neutral tokens β Deep Space (N-950), Orbital Gray (N-800) and Lunar Dust (N-100) β and fills the intermediate steps used for surfaces, borders, dividers and text tiers.
We use a combination of geometric sans-serifs, serif long-form text, and monospaced fonts to communicate technical precision.
Most UI elements, body copy, bullets, labels, buttons.
Technical specs, sub-headlines, table data.
Longer descriptive paragraphs in whitepapers/PDFs.
CAN IDs, pin names, code blocks, terminal commands.
The visual anchor of our brand. Ensure clear space and high contrast at all times.
Can be used when there is a size constraint or proportions are not properly adhered to.
Maintain minimum clear space around the logo equal to the height of the logo mark (1x). This ensures visual breathing room and brand integrity.
The logo must never be shown sideways or rotated in print or on physical parts β it always appears upright in its horizontal lockup. As a watermark, a rotated or angled placement may be acceptable, but only with specific prior approval.
The complete, self-contained brand and design-system specification β verbose prose with every exact value embedded inline. Read it below or copy the entire document to your clipboard with one click.
# Solterra EV Chargers β Brand & Design-System Specification
This document is the complete, self-contained specification for the Solterra EV
Chargers brand and the website that presents it. It is written so that a designer
or engineer who has never seen the original files could rebuild both the brand
identity and the entire site from this text alone. Every concrete value β hex
colors, font sizes, weights, line-heights, letter-spacing, box-shadows, animation
durations, easing curves, breakpoints β is stated inline exactly as it exists in
the production code. Where the codebase has deliberate gaps or known
inconsistencies, those are documented honestly rather than smoothed over, because
an accurate rebuild depends on knowing what is genuinely absent as much as what is
present.
The product is Solterra EV Chargers: an industrial, technical brand for electric-
vehicle charging hardware β chargers, power modules, inverters, and communication
controllers. The site is an official brand guide, organized as a single-page
application with a fixed sidebar (or a mobile dropdown menu) that switches between
content sections. The default appearance is a dark, near-black technical interface
with a single hot signal color, and a fully paired light theme is available behind
a toggle.
---
## Table of Contents
1. Core Aesthetic
2. Color System (Dark and Light Modes)
3. Typography
4. UI / UX Components
5. Responsive and Mobile Behavior
6. Mobile Menu (Full Detail)
7. CSS Strategy / Styling Architecture
8. Transitions and Animation
9. Quick Reference
10. Appendix A: Usage Rules & Do's and Don'ts
11. Appendix B: Accessibility (WCAG AA)
12. Appendix C: Motion & Reduced Motion
13. Appendix D: Rebuild Checklist
14. Appendix E: Implementation Guide (Code Walkthrough)
---
## Quick Reference
This section consolidates the load-bearing values into monospace-aligned tables.
Each table doubles as the per-area summary for that part of the system. Every
value is drawn directly from the production token set; nothing here is invented.
### Brand tokens (the seven design tokens)
```
Name | token | HEX | CMYK | Usage
-----------------+------------------+---------+------------------+---------------------------------
Solterra Scarlet | scarlet | #ff380d | C:0 M:78 Y:95 K:0 | Primary Brand Color, CTAs
Solar Flare | solar-flare | #fa913c | C:0 M:42 Y:76 K:2 | Accents, Gradients, Highlights
Atmosphere Blue | atmosphere-blue | #7cb3ff | C:51 M:30 Y:0 K:0 | Tech Elements, Links, Info
Lunar Dust | lunar-dust | #d6d6d6 | C:0 M:0 Y:0 K:16 | Body Text, UI Elements
Deep Space | deep-space | #050505 | C:0 M:0 Y:0 K:98 | Backgrounds
Orbital Gray | orbital-gray | #181818 | C:0 M:0 Y:0 K:91 | Cards, Sidebars
Aurora Green | success | #16a34a | C:87 M:0 Y:55 K:36 | Success, Live Status, Confirmations
```
### Neutral ramp (12 steps, darkest to lightest)
```
Step | HEX | Note
-----+---------+-----------------------
950 | #050505 | Deep Space anchor
900 | #0a0a0a |
850 | #141414 |
800 | #181818 | Orbital Gray anchor
700 | #1f1f1f |
600 | #2a2a2a |
500 | #3d3d3d |
400 | #5c5c5c |
300 | #8a8a8a |
200 | #b3b3b3 |
100 | #d6d6d6 | Lunar Dust anchor
50 | #f9fafb |
```
### Dark-mode role colors
```
Role | Value
----------------+---------------------------------------------------------------
app bg | radial-gradient(circle at top left, #141414 0, #050505 45%, #050505 100%)
text-heading | #f9fafb
text-body | #d6d6d6
text-muted | #8a8a8a
brand-text | #d6d6d6
sidebar | #181818
card | #181818 / border #1f1f1f
divider | #1f1f1f
accent-text | #7cb3ff
nav-item | #a0a0a0 / active #fa913c
```
### Light-mode role colors
```
Role | Value
----------------+---------------------------------------------------------------
app bg | linear-gradient(145deg, #ffffff 0%, #f5f5f5 40%, #fef7f4 100%)
text-heading | #111111
text-body | #333333
text-muted | #666666
brand-text | #111111
sidebar | #fafafa
card | #ffffff / border #d4d4d4
divider | #d4d4d4
accent-text | #1d4ed8
nav-item | #555555 / active #e53200 vs #d62800 (see note)
```
Note on the light active split: the page block in index.astro sets the active
nav-item color to #e53200, while the component block in Navigation.astro sets it
to #d62800. Both render - the component rule wins for the desktop sidebar nav,
the page rule applies to the mobile nav-items. The two values are close but
genuinely different and are preserved, not unified (see Section 2.6).
### Type scale
```
Role | Font | Size | Weight | Line-height | Tracking
---------------------------+---------------+-----------------------+--------+--------------------+-----------
Heading 1 (hero) | font-inter | clamp(28px,4vw,40px) | 600 | 1.1 | -
Heading 1 (type-scale) | font-plex-sans| clamp(32px,4vw,48px) | 700 | - | -
Heading 2 (type-scale) | font-plex-sans| clamp(24px,3vw,36px) | 600 | - | -
Section H2 headings | font-plex-sans| text-4xl (36px) | 700 | - | -
Body (type-scale) | font-inter | text-base (16px) | - | leading-relaxed 1.625 | -
Code / Data (type-scale) | font-plex-mono| 13px | - | 1.6 | -
Intro body paragraph | font-inter | 14px | - | 1.6 | -
Hero stat | font-inter | 26px | 600 | - | -
Brand wordmark | font-inter | text-xs (12px) | 600 | - | 0.04em
Eyebrow / overline | font-inter | 11px | - | - | 0.18em
Section label (uppercase) | font-inter | 13px | 600 | - | 0.14em
State label (button demos) | - | 10px | 600 | - | 0.16em
Nav sidebar label | - | 10px | 700 | - | 0.16em
Nav mobile label | - | 9px | 700 | - | 0.18em
```
### Radius
```
Element | Radius
------------------------+-----------
buttons | 9999px (pill)
sidebar-ctrl | 9999px (pill)
chips | 9999px (pill)
cards | 16px
nav items | 8px
```
### Key shadows
```
Element | Shadow
-----------------------------+----------------------------------------------------------------------------
primary button default (dark)| 0 4px 12px rgba(255,56,13,0.4), 0 2px 4px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.15)
card (dark) | none (relies on gradient background for depth; see Section 2)
card (dark, ColorCard) | 0 2px 8px rgba(0,0,0,0.3)
```
### Breakpoints (Tailwind defaults)
```
Name | px
-----+------
sm | 640
md | 768
lg | 1024
xl | 1280
2xl | 1536
```
Note: lg = 1024px is the single most important boundary - the sidebar <-> mobile
divide. Below 1024px: mobile header + hamburger dropdown. At/above 1024px: fixed
desktop sidebar.
### Animation timings
```
Target | Property | Duration | Easing
----------------------+---------------------------------------+----------+----------------------------
global * | bg-color, border-color, color, shadow | 0.3s | ease-in-out
section fadeIn | opacity + transform | 0.4s | ease-out
buttons | all | 0.2s | cubic-bezier(0.4,0,0.2,1)
sidebar-ctrl | bg-color, border-color, color | 0.25s | ease
ham-line transform | transform / width | 0.35s | cubic-bezier(0.4,0,0.2,1)
ham-line opacity | opacity | 0.25s | cubic-bezier(0.4,0,0.2,1)
mobile-dropdown | opacity + transform | 0.3s | ease-out
theme cross-fade | colors (duration-500) | 0.5s | (Tailwind default)
nav-item | all (duration-200) | 0.2s | (Tailwind default)
mobile-nav-item | all (duration-150) | 0.15s | (Tailwind default)
```
---
## 1. Core Aesthetic
The Solterra aesthetic is built on a deliberately industrial, signal-driven
philosophy. The positioning line is "EV CHARGING β’ POWER MODULES," and the brand
exists to serve an audience of engineers and integrators who care about chargers,
power modules, inverters, and communication controllers. The marketing promise,
surfaced as the hero tagline, is "Build faster with production-ready EV charging
components." Everything about the look and feel is meant to reinforce competence,
precision, and dependability under harsh conditions β not playfulness or
decoration.
Two design principles sit at the very top of the hierarchy and are literally
printed in the interface as the "Core Principles" of the system: **"Function over
Form"** and **"High Contrast Accessibility."** These are not slogans; they are
constraints. "Function over Form" means that legibility and data clarity always
win over ornament β the design refuses to sacrifice readability for visual flair.
"High Contrast Accessibility" means the palette and type are tuned so that
technical data (CAN IDs, voltages, ripple specs, derating curves) stays sharply
legible against its background in both themes. Two further supporting statements
extend this ethos: the system is **"Built for clarity, precision, and speed,"**
and it aims for **"Data legibility without sacrificing aesthetic depth."** The
second of those is the reconciling idea β the interface is dark and atmospheric,
but never at the cost of being able to read a number.
The brand voice is **technical, precise, and signal-driven.** In the words of the
guide itself: Scarlet commands attention; Solar Flare bridges technical alerts and
brand warmth. That single sentence captures the entire color intent β there is one
aggressive, attention-grabbing red (Solterra Scarlet, `#ff380d`) reserved for the
most important signals and calls to action, and a warmer orange (Solar Flare,
`#fa913c`) that softens and humanizes the technical alerting, acting as the bridge
between cold data and brand warmth. The primary signal color of the whole system
is **Scarlet `#ff380d`**, and the hero copy explicitly states the design is
"Designed with #ff380d as the primary signal."
The default theme is **dark**, and this is a hard default, not a preference
inferred from the operating system. The markup ships with `<html lang="en"
class="dark">` hardcoded, and the client script defaults to `'dark'` whenever
there is no stored preference. The mood is therefore a deep, near-black technical
console: a radial-gradient charcoal-to-black backdrop, orbital-gray cards, hot
orange-to-red gradients on the primary actions, and a cool blue accent for tech
elements and links. The personality reads as a piece of precision instrumentation
β closer to an oscilloscope or a power-electronics datasheet than a consumer
marketing page. The light theme exists as a fully realized alternative (warm-white
backgrounds with a faint scarlet tint), but the brand's "home" state, the one it
wants to be seen in, is the dark mode.
What the site looks and feels like in practice: a fixed left sidebar on desktop
carries the SOLTERRA wordmark (with SOL in scarlet), a "Guidelines" nav list,
and pill-shaped theme-toggle and download controls. The main column is centered at
a maximum width of 1120px and presents one section at a time β Introduction, Logo
Guidelines, Typography, UI Components, Color System β each fading in as you switch
to it. The hero pairs a confident headline with a live-data "instrument card"
showing a 98.7% uptime stat and a monospaced spec block (`sttl3000ka β’ 3Ο 380Vac β
20β68Vdc / 150A`, `ripple_v_pp < 0.5% @ full load`, `derate: -1.5% / Β°C above
50Β°C`, `can_id: 0x18FF50E5`). The overall impression is engineered, calm,
high-contrast, and unmistakably technical.
---
## 2. Color System (Dark and Light Modes)
Color is the load-bearing element of the Solterra identity, and the system is
defined twice β once for dark mode and once for light mode β with every themed
surface having an explicit rule in both. There are no CSS custom properties
anywhere in this project; theming is done with literal hex and rgba values
duplicated across paired `html.dark .x` and `html.light .x` selectors. That
duplication is intentional and is described in detail in Section 7. This section
gives every value for both themes and explains where, when, and why each is used.
### 2.1 The seven documented brand colors (design tokens)
Seven named brand colors form the token layer, registered in
`tailwind.config.mjs` under `theme.extend.colors` (extended, not replaced, so
Tailwind's `black`, `white`, and `transparent` still work). Each token generates
utilities such as `bg-scarlet`, `text-solar-flare`, and so on:
- **Solterra Scarlet β `#ff380d`** (token `scarlet`; CMYK C:0 M:78 Y:95 K:0). The
primary brand color, used for CTAs and the most urgent signals. This is the
attention-commanding red.
- **Solar Flare β `#fa913c`** (token `solar-flare`; CMYK C:0 M:42 Y:76 K:2). Used
for accents, gradients, and highlights. It is the warm bridge color and the
start of the primary-button gradient.
- **Atmosphere Blue β `#7cb3ff`** (token `atmosphere-blue`; CMYK C:51 M:30 Y:0
K:0). Used for tech elements, links, and informational accents β this is the
dark-mode accent text color.
- **Lunar Dust β `#d6d6d6`** (token `lunar-dust`; CMYK C:0 M:0 Y:0 K:16). Body
text and UI elements; it is the dark-mode body-text color and the top anchor of
the neutral ramp (step 100).
- **Deep Space β `#050505`** (token `deep-space`; CMYK C:0 M:0 Y:0 K:98).
Backgrounds; the darkest neutral and the anchor of ramp step 950.
- **Orbital Gray β `#181818`** (token `orbital-gray`; CMYK C:0 M:0 Y:0 K:91).
Cards and sidebars; the anchor of ramp step 800.
- **Aurora Green β `#16a34a`** (token `success`; CMYK C:87 M:0 Y:55 K:36). Success
states, live-status indicators, and confirmations.
### 2.2 The twelve-step neutral ramp
A 12-step neutral ramp gives the system its grays, anchored on three brand
neutrals β Deep Space at the dark end, Orbital Gray in the middle, and Lunar Dust
near the light end β with the intermediate steps filling in the surfaces, borders,
and muted text used throughout. The full ramp, from darkest to lightest:
- **950 β `#050505`** (Deep Space anchor; deepest backgrounds, scrollbar track in
dark)
- **900 β `#0a0a0a`** (button-showcase gradient end, deep panels)
- **850 β `#141414`** (app-background gradient start in dark, type-scale box bg)
- **800 β `#181818`** (Orbital Gray anchor; sidebar, cards, surfaces in dark)
- **700 β `#1f1f1f`** (dark dividers, borders, hover backgrounds, scrollbar thumb)
- **600 β `#2a2a2a`** (card/control borders in dark, type-scale code-box border)
- **500 β `#3d3d3d`** (ghost-button border, scrollbar thumb hover in dark)
- **400 β `#5c5c5c`** (footer text in dark, CMYK rows in ColorCard dark)
- **300 β `#8a8a8a`** (muted text in dark)
- **200 β `#b3b3b3`** (a mid neutral)
- **100 β `#d6d6d6`** (Lunar Dust anchor; body text in dark)
- **50 β `#f9fafb`** (heading / app text in dark, near-white)
### 2.3 Dark mode β every surface, text tier, and component color
**App background and base text.** The app container background in dark is a radial
gradient: `radial-gradient(circle at top left, #141414 0, #050505 45%, #050505
100%)`. The base app text color is `#f9fafb`. This gradient is the canvas β a soft
charcoal glow in the top-left corner falling off to pure Deep Space.
**Sidebar.** Background `#181818` (Orbital Gray) with a `1px solid #1f1f1f`
right border.
**Mobile header.** Background `rgba(5, 5, 5, 0.9)` (translucent Deep Space) with a
`1px solid #1f1f1f` bottom border, sitting behind a backdrop blur.
**Text tiers.** Headings use `#f9fafb` (`text-heading`); body text uses `#d6d6d6`
(`text-body`, Lunar Dust); muted/secondary text uses `#8a8a8a` (`text-muted`). The
brand wordmark text (`brand-text`) is `#d6d6d6`. The accent text color
(`accent-text`, used for links and tech callouts) is **Atmosphere Blue `#7cb3ff`**.
Footer text is `#5c5c5c`.
**Dividers.** Both `border-color` and `background-color` are `#1f1f1f`.
**Cards (`card-bg`).** Background `#181818` with a `1px solid #1f1f1f` border.
**Hero card.** Background is its own radial gradient `radial-gradient(circle at top
right, #1a1a1a 0, #050505 60%, #050505 100%)`, with a `1px solid rgba(150, 150,
150, 0.15)` border and a deep drop shadow `0 24px 60px rgba(0, 0, 0, 0.75)`. Inside
the hero card is a radial glow element using
`bg-[radial-gradient(circle,rgba(255,56,13,0.15),transparent_60%)]` with a
`dark:bg-[radial-gradient(circle,rgba(255,56,13,0.25),transparent_60%)]` override β
note this `dark:` variant keys off the OS `prefers-color-scheme` rather than the
app's class toggle (see Section 7), so under the class-toggle dark theme the glow
intensity actually depends on the OS preference.
**Status badge.** Background `rgba(24, 24, 24, 0.9)`, border `1px solid rgba(150,
150, 150, 0.35)`, text `#d6d6d6`. The badge carries a live success dot using
`bg-success` (`#16a34a`) with a glow `shadow-[0_0_10px_rgba(74,222,128,0.8)]`.
**Code block (`code-block`).** Background `rgba(24, 24, 24, 0.9)`, border `1px
solid rgba(150, 150, 150, 0.35)`. This is the surface reused for the brand-spec
document container.
**Badge background (`badge-bg`).** Solid `#050505` in dark.
**Filter button base (`filter-btn`).** Border `1px solid #1f1f1f`, background
`rgba(24, 24, 24, 0.9)`, color `#d6d6d6`.
**Surface utility (`bg-surface`).** `#181818`.
**Custom scrollbar.** Track `#050505`, thumb `#1f1f1f`, thumb-hover `#3d3d3d`.
**Nav items.** Base color `#a0a0a0`; hover background `#1f1f1f` with color
`#f9fafb`; active state background `rgba(250, 145, 60, 0.1)` with color Solar Flare
`#fa913c` and a `2px solid #fa913c` left border.
**Type-scale demo boxes.** The type-scale box background is `#1a1a1a` with a `1px
solid rgba(255, 255, 255, 0.05)` border; the inner code box is `transparent` with a
`1px solid #2a2a2a` border. The button-showcase background is `radial-gradient(circle
at center, #141414 0%, #0a0a0a 100%)` with a `1px solid #2a2a2a` border. The state
label color in the button demos is Solar Flare `#fa913c`.
### 2.4 Light mode β every surface, text tier, and component color
The light theme is a full re-skin, not an inversion. Its character is a clean,
warm white with a faint scarlet tint, and its accent shifts from blue to a deep
royal blue. The key conceptual differences from dark are: backgrounds become white/
near-white gradients, the accent text changes from Atmosphere Blue `#7cb3ff` to a
royal blue `#1d4ed8`, and the scarlet-derived interaction colors are deepened to
`#d62800` / `#e53200` for contrast on light surfaces.
**App background and base text.** Background `linear-gradient(145deg, #ffffff 0%,
#f5f5f5 40%, #fef7f4 100%)` β white sliding into a barely-there warm pink in the
corner. Base text `#111111`.
**Sidebar.** Background `#fafafa`, `1px solid #d4d4d4` right border, plus a soft
shadow `2px 0 12px rgba(0, 0, 0, 0.04)`.
**Mobile header.** Background `rgba(250, 250, 250, 0.97)`, `1px solid #d4d4d4`
bottom border, shadow `0 1px 3px rgba(0, 0, 0, 0.05)`.
**Text tiers.** Headings `#111111`; body `#333333`; muted `#666666`. Brand
wordmark text `#111111`. Accent text (`accent-text`) is **royal blue `#1d4ed8`**
(this is where light most clearly diverges from dark's `#7cb3ff`). Footer text
`#666666`.
**Dividers.** Both `border-color` and `background-color` are `#d4d4d4`.
**Cards (`card-bg`).** Background `#ffffff`, border `1px solid #d4d4d4`, shadow
`0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.03)`.
**Hero card.** Background `linear-gradient(145deg, #ffffff 0%, #fffaf8 100%)`,
border `1px solid #e5d4cc` (a warm taupe), shadow `0 4px 20px rgba(255, 56, 13,
0.06), 0 1px 4px rgba(0, 0, 0, 0.04)` (note the scarlet-tinted glow).
**Status badge.** Background `#ffffff`, border `1px solid #d4d4d4`, text `#333333`.
**Code block (`code-block`).** Background `#f5f5f5`, border `1px solid #d4d4d4`,
text `#333333`.
**Badge background (`badge-bg`).** Background `#efefef` with a `1px solid #d4d4d4`
border.
**Filter button base (`filter-btn`).** Border `1px solid #d4d4d4`, background
`#ffffff`, color `#333333`; on hover, background `#fff5eb` (warm cream) with border
color `#fa913c`.
**Surface utility (`bg-surface`).** `#f0f0f0`.
**Custom scrollbar.** Track `#efefef`, thumb `#c4c4c4`, thumb-hover `#a0a0a0`.
**Nav items.** Base color `#555555`; hover background `#efefef` with color
`#111111`; active state background `rgba(255, 56, 13, 0.1)` with a `2px solid
#ff380d` left border. The active text color is where a known inconsistency lives
(see Section 2.6): the page-level rule in `index.astro` sets it to `#e53200`, while
the component rule in `Navigation.astro` sets it to `#d62800`.
**Type-scale demo boxes.** The type-scale box background is `#f5f5f5` with a `1px
solid #e0e0e0` border; the inner code box is `#f0f4ff` (pale blue) with a `1px
solid #c8d5f0` border. The button-showcase background is `radial-gradient(circle at
center, #fafafa 0%, #f0f0f0 100%)` with a `1px solid #e0e0e0` border. The state
label color in the button demos is `#d62800`.
### 2.5 How dark and light map and differ
The mapping is consistent in role but not in literal value. Backgrounds invert
from near-black gradients to near-white gradients; the warm tint stays in the
corner of the app background in both (a `#fef7f4` corner in light echoing the
`#141414` glow corner in dark). Cards go from `#181818`/`#1f1f1f` borders to
`#ffffff`/`#d4d4d4` borders and pick up soft shadows in light (dark cards rely on
the gradient background rather than shadows for depth). Dividers move from `#1f1f1f`
to `#d4d4d4`. The biggest deliberate divergence is the accent: dark uses the cool,
luminous Atmosphere Blue `#7cb3ff`, while light uses a denser royal blue `#1d4ed8`
for sufficient contrast on white. Similarly, the scarlet family is used at full
strength `#ff380d` in dark contexts but is frequently deepened to `#d62800` (or
`#e53200`) in light, because the pure scarlet does not hold enough contrast against
white surfaces. The primary-button gradient (`#fa913c β #ff380d`) is identical in
both themes β the brand's hero action looks the same regardless of mode β but its
shadows are tuned slightly (lower opacity in light).
### 2.6 Literal hex used directly in markup, and the known inconsistency
A handful of values appear as literal hex directly in component markup rather than
through theme rules. The logo dark card uses `bg-deep-space` (`#050505`) with a
`#1A1A1A` border and `#4A4A4A` label text. The logo light card uses `#FAFAFA`
background, `#EAEAEA` border, `#999999` label text, and `#d62800` for its "1x"
clear-space marks. The logo monochrome card uses `#EBEBEB` background, `#DCDCDC`
border, `#888888` label text, and `#444444` for its 1x marks and body text. The
live success dot is `bg-success` with `shadow-[0_0_10px_rgba(74,222,128,0.8)]`, and
the primary-signal dot is `bg-scarlet`.
**Known inconsistency (documented honestly):** the light-mode active nav-item text
color is not the same in both places it is defined. The page-level `<style>` in
`index.astro` sets `html.light .nav-item.active { color: #e53200 }`, while the
scoped component `<style>` in `Navigation.astro` sets the same selector to
`#d62800`. Both rules exist and both render: the component-scoped rule wins for the
desktop sidebar nav rendered by `Navigation.astro`, while the mobile nav-items
(which live in `index.astro`) take the page rule `#e53200`. The two values are very
close, so the discrepancy is subtle, but it is real and is preserved here rather
than silently unified.
---
## 3. Typography
The Solterra type system uses four families, each with a defined role, loaded from
Google Fonts and exposed as Tailwind utilities generated from
`tailwind.config.mjs` `theme.extend.fontFamily`.
### 3.1 The four families and their roles
- **Inter** (`font-inter`, stack `Inter, sans-serif`) β the UI / Body face. It
carries most UI elements, body copy, bullets, labels, and buttons. It is the
workhorse and the default body font of the whole interface.
- **IBM Plex Sans** (`font-plex-sans`, stack `"IBM Plex Sans", sans-serif`) β the
Technical / Sub-heads face, used for technical specs, sub-headlines, and table
data. Importantly, it is also the face used for the section `h2`/`h3` headings.
- **IBM Plex Serif** (`font-plex-serif`, stack `"IBM Plex Serif", serif`) β the
Long-Form face, intended for longer descriptive paragraphs in whitepapers and
PDFs. (See the honest note in 3.4 β on the site itself it is essentially only
shown in its own type specimen.)
- **IBM Plex Mono** (`font-plex-mono`, stack `"IBM Plex Mono", monospace`) β the
Code / IDs face, for CAN IDs, pin names, code blocks, and terminal commands.
This is the font used to render the brand-spec document container.
### 3.2 Google Fonts loading and the exact weights
Fonts load via a `preconnect` to `fonts.googleapis.com` and a crossorigin
`preconnect` to `fonts.gstatic.com`, followed by a single `css2` stylesheet link
with `display=swap`. The exact URL is:
`https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;600;700&family=IBM+Plex+Serif:wght@400;600&family=Inter:wght@400;500;600;700&display=swap`.
The weights actually requested per family are therefore: **Inter β 400, 500, 600,
700**; **IBM Plex Sans β 400, 600, 700**; **IBM Plex Serif β 400, 600**; **IBM Plex
Mono β 400, 600**. Any weight used in the UI must come from this set; for example
Inter is the only family loaded at 500, which is why medium-weight body labels use
Inter.
### 3.3 The full type scale (exact values)
The following are the exact sizes, weights, line-heights, fonts, colors, and
letter-spacing as they appear in the markup:
- **Heading 1 (hero):** font-size `clamp(28px, 4vw, 40px)`, weight `600`
(`font-semibold`), line-height `1.1` (`leading-[1.1]`), font `font-inter`, color
`text-heading`. This is the main hero `h1`. Note it uses **Inter**, not Plex
Sans.
- **Heading 1 (type-scale demo):** font-size `clamp(32px, 4vw, 48px)`, weight `700`
(`font-bold`), font `font-plex-sans` (inherited from its container), shown in the
Typography section's type-scale demonstration.
- **Heading 2 (type-scale demo):** font-size `clamp(24px, 3vw, 36px)`, weight `600`
(`font-semibold`), font `font-plex-sans`.
- **Section H2 headings:** font-size `text-4xl` (`2.25rem` / `36px`), weight `700`
(`font-bold`), font `font-plex-sans`, color `text-heading`. These are the
per-section titles ("UI Components," "Typography," etc.).
- **Body (type-scale demo):** font-size `text-base` (`1rem` / `16px`), line-height
`leading-relaxed` (`1.625`), font `font-inter`, color `text-body`.
- **Code / Data (type-scale demo):** font-size `13px` (`text-[13px]`), line-height
`1.6` (`leading-[1.6]`), font `font-plex-mono`, color `accent-text`.
- **Intro body paragraph:** font-size `14px`, line-height `1.6`, font `font-inter`,
color `text-body`.
- **Hero stat:** font-size `26px`, weight `600`, font `font-inter` (the "98.7%"
figure).
- **Brand wordmark (sidebar / mobile):** font-size `text-xs` (`12px`), weight
`600`, letter-spacing `tracking-[0.04em]`, uppercase, font `font-inter`.
- **Eyebrow / overline:** font-size `11px`, letter-spacing `0.18em`, uppercase,
color `text-solar-flare`, font `font-inter`.
- **Section label (uppercase):** font-size `13px`, letter-spacing `0.14em`,
uppercase, weight `600`, color `text-muted`, font `font-inter`.
- **State label (button demos):** font-size `10px`, letter-spacing `0.16em`,
uppercase, weight `600`.
- **Nav sidebar label:** font-size `10px`, letter-spacing `0.16em`, uppercase,
weight `700`.
- **Nav mobile label:** font-size `9px`, letter-spacing `0.18em`, uppercase,
weight `700`.
The letter-spacing (tracking) tokens in active use are `tracking-[0.04em]`,
`tracking-[0.12em]`, `tracking-[0.14em]`, `tracking-[0.16em]`, `tracking-[0.18em]`,
and `tracking-wider` (`0.05em`). The line-height tokens in use are `leading-[1.1]`,
`leading-[1.5]`, `leading-[1.6]`, `leading-relaxed` (`1.625`), and `leading-tight`
(`1.25`).
### 3.4 Pairing rules and honest notes
The pairing logic is: Inter for UI chrome and body copy; Plex Sans for technical
sub-heads, table/spec data, and the section headings; Plex Mono for any
machine-readable string (IDs, code, terminal output); and Plex Serif reserved for
long-form prose in exported documents. A few honest caveats a rebuilder must know:
- **Plex Serif is loaded but barely used.** It is fetched from Google Fonts (at 400
and 600) and appears in its own type specimen card in the Typography section, but
it is not used for actual long-form body content anywhere on the live site. Its
documented role (whitepapers/PDFs) is aspirational relative to the current site.
- **The hero H1 uses Inter, while section H2s use Plex Sans.** This is a real,
intentional split: the single largest headline (the hero) is set in Inter
`font-semibold` at `clamp(28px,4vw,40px)`, whereas every section title below it
is Plex Sans `font-bold` at `text-4xl`. A rebuild must not "unify" these onto one
family.
---
## 4. UI / UX Components
This section documents every interactive component and its states with exact
values. States are described as default, hover, active, focus, and disabled where
they exist. A recurring pattern across the showcase components is a dual mechanism:
real `:hover` / `:active` pseudo-classes provide live interactivity, while parallel
static `-hover` / `-active` / `-disabled` classes can freeze a state for the
demonstration galleries β both produce identical visuals.
### 4.1 Button (primary and ghost)
The Button component (`Button.astro`) takes `variant` (`'primary' | 'ghost'`,
default `primary`), `state` (`'default' | 'hover' | 'active' | 'disabled'`,
default `default`), a `disabled` boolean, and a `className`. Its base styles are:
`relative inline-flex items-center justify-center gap-1.5 px-5 py-2.5 rounded-full
text-[13px] font-semibold text-center font-inter select-none sm:whitespace-nowrap`.
The pill shape (`rounded-full`), the `13px` semibold Inter label, and the
`px-5 py-2.5` padding are constant across variants. The transition on both
`.btn-primary` and `.btn-ghost` is `all 0.2s cubic-bezier(0.4, 0, 0.2, 1)` in both
themes β this is what produces the smooth lift and gradient swap.
**Primary button β dark mode.** The base gradient is `linear-gradient(135deg,
#fa913c 0%, #ff380d 100%)` (Solar Flare into Scarlet), text color `#000000` (black,
for contrast on the hot gradient), no border. In the **default** state the shadow
is `0 4px 12px rgba(255,56,13,0.4), 0 2px 4px rgba(0,0,0,0.2), inset 0 1px 0
rgba(255,255,255,0.15)` with `transform: translateY(0)`. On **hover** the gradient
brightens to `linear-gradient(135deg, #ffb366 0%, #ff5530 100%)`, the shadow grows
to `0 8px 24px rgba(255,56,13,0.5), 0 4px 8px rgba(0,0,0,0.3), inset 0 1px 0
rgba(255,255,255,0.2)`, and it lifts with `transform: translateY(-2px)`. On
**active** the gradient darkens to `linear-gradient(135deg, #e07a30 0%, #d62b00
100%)`, the shadow tightens to `0 2px 8px rgba(255,56,13,0.3), 0 1px 2px
rgba(0,0,0,0.2)` plus an inset of `inset 0 2px 4px rgba(0,0,0,0.1)` (live `:active`)
or `inset 0 2px 4px rgba(0,0,0,0.15)` (static frozen class), and it presses down
with `transform: translateY(1px)`. The **disabled** state swaps to a dead gradient
`linear-gradient(135deg, #4a4a4a 0%, #3a3a3a 100%)`, text `#666666`, `box-shadow:
none`, `cursor: not-allowed`, `opacity: 0.7`.
**Primary button β light mode.** Same base gradient `#fa913c β #ff380d` and black
text. **Default** shadow `0 4px 12px rgba(255,56,13,0.35), 0 2px 4px
rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.2)`, `translateY(0)`. **Hover**
gradient `#ffb366 β #ff5530`, shadow `0 8px 24px rgba(255,56,13,0.45), 0 4px 8px
rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.25)`, `translateY(-2px)`.
**Active** gradient `#e07a30 β #d62b00`, shadow `0 2px 8px rgba(255,56,13,0.25),
0 1px 2px rgba(0,0,0,0.1), inset 0 2px 4px rgba(0,0,0,0.1)`, `translateY(1px)`.
**Disabled** gradient `linear-gradient(135deg, #D4D4D4 0%, #BFBFBF 100%)`, text
`#888888`, `box-shadow: none`, `cursor: not-allowed`, `opacity: 0.7`.
**Ghost button β dark mode.** Base is `transparent` background, color `#B3B3B3`,
border `1.5px solid #3A3A3A`. **Default** shadow `0 2px 8px rgba(0,0,0,0.2)`. On
**hover**, background `rgba(250,145,60,0.1)`, border-color and text both Solar Flare
`#fa913c`, shadow `0 4px 16px rgba(250,145,60,0.2)` (live) or `0.25` (static) plus
`0 0 0 1px rgba(250,145,60,0.1)`. On **active**, background `rgba(255,56,13,0.15)`,
border-color and text Scarlet `#ff380d`, shadow `0 2px 8px rgba(255,56,13,0.15)`
(live) or `0.2` (static), `translateY(1px)`. **Disabled** background `transparent`,
border-color `#2A2A2A`, text `#4A4A4A`, `box-shadow: none`, `cursor: not-allowed`,
`opacity: 0.6`.
**Ghost button β light mode.** Base `transparent`, color `#444444`, border `1.5px
solid #C4C4C4`. **Default** shadow `0 2px 6px rgba(0,0,0,0.06)`. **Hover**
background `rgba(255,56,13,0.06)`, border-color `#fa913c`, text `#d62800`, shadow
`0 4px 16px rgba(250,145,60,0.15)` (live) or `0.18` (static) plus `0 0 0 1px
rgba(250,145,60,0.08)`. **Active** background `rgba(255,56,13,0.1)`, border-color
`#ff380d`, text `#c42a00`, shadow `0 2px 8px rgba(255,56,13,0.12)` (live) or `0.15`
(static), `translateY(1px)`. **Disabled** background `transparent`, border-color
`#E0E0E0`, text `#AAAAAA`, `box-shadow: none`, `cursor: not-allowed`, `opacity:
0.6`.
### 4.2 ColorCard
`ColorCard.astro` takes a `color` object `{ name, hex, cmyk, usage }`. Its
structure is an `h-32` swatch whose background-color is the color's hex, overlaid
with a `from-transparent to-black/20 opacity-50` gradient for depth, above a
content area showing the name, usage, a HEX copy button, and a CMYK row. The copy
behavior: clicking calls `navigator.clipboard.writeText(hex)` and swaps the
clipboard icon (π) for a check (β) for 2000ms via `setTimeout`. This 2000ms
icon-swap convention is the model the brand-spec copy button mirrors.
In **dark mode** the card is `#181818` with `1px solid #2A2A2A` border and shadow
`0 2px 8px rgba(0,0,0,0.3)`; on hover the border becomes `#fa913c` and the shadow
`0 4px 16px rgba(250,145,60,0.2)`. The name text is `#F9FAFB`, the usage text
`#8A8A8A`, the copy button background `rgba(0,0,0,0.4)` with `#A0A0A0` icon (hover:
white icon on `rgba(0,0,0,0.6)`), and the CMYK row text `#5C5C5C`.
In **light mode** the card is `#FFFFFF` with `1px solid #D4D4D4` border and shadow
`0 1px 4px rgba(0,0,0,0.08)`; hover border `#fa913c`, shadow `0 4px 16px
rgba(250,145,60,0.18)`. Name `#111111`, usage `#555555`, copy button background
`#F0F0F0` with `#444444` icon and a `1px solid #D4D4D4` border (hover: `#111111`
icon on `#E5E5E5`), and the CMYK row `#666666` text on `#F8F8F8` with a `4px`
border-radius.
### 4.3 ProductCard
`ProductCard.astro` takes `title`, `badge`, `desc`, `meta1`, `meta2`. The structure
is an `article` with `rounded-[16px] p-5`: a title row (title plus an uppercase
badge), a description, and a meta row showing meta1 and meta2. The title gets
`group-hover:text-solar-flare group-hover:underline underline-offset-[3px]
decoration-1` so it warms to Solar Flare and underlines on card hover.
**Dark mode:** card background `rgba(24,24,24,0.96)`, border `1px solid #2A2A2A`;
hover border `#fa913c`, shadow `0 4px 16px rgba(250,145,60,0.15)`. Title `#E8E8E8`,
desc `#A0A0A0`, meta `#666666`, badge text `#fa913c`. **Light mode:** card
`#FFFFFF`, border `1px solid #D4D4D4`, shadow `0 1px 3px rgba(0,0,0,0.06)`; hover
border `#fa913c`, shadow `0 4px 16px rgba(250,145,60,0.15)`. Title `#111111`, desc
`#444444`, meta `#666666`, badge `#d62800`.
### 4.4 Navigation (desktop sidebar nav)
`Navigation.astro` takes `activeSection: string` and renders five buttons. Each
button carries `data-section` and the class `nav-item w-full flex items-center
gap-3 px-4 py-3 text-sm font-medium transition-all duration-200 rounded-lg group
font-inter`, with `active` appended when it matches. The five items are:
Introduction (`intro`, icon π), Logo Guidelines (`logo`, icon β), Typography
(`typography`, icon `Aa`), UI Components (`components`, icon ⬑), and Color System
(`colors`, icon β). The icon span uses `opacity-80 group-hover:opacity-100
transition-opacity`.
**Dark:** item color `#A0A0A0`; non-active hover background `#1F1F1F`, color
`#F9FAFB`; active background `rgba(250,145,60,0.1)`, color `#fa913c`, left border
`2px solid #fa913c`. **Light:** item color `#555555`; non-active hover background
`#EFEFEF`, color `#111111`; active background `rgba(255,56,13,0.1)`, color `#d62800`
(see Section 2.6 inconsistency), left border `2px solid #ff380d`, font-weight `500`.
### 4.5 Breadcrumb
Defined in the UI Components section of `index.astro`. Each item uses `breadcrumb-
item px-2.5 py-1 rounded-md transition-all`. **Dark:** item `#888888`; item hover
background `rgba(250,145,60,0.1)`, color `#fa913c`; active background
`rgba(250,145,60,0.15)`, color `#fa913c`, font-weight `500`; clear button `#666666`
with hover background `rgba(255,56,13,0.1)`, color `#ff380d`. **Light:** item
`#555555`; item hover background `rgba(255,56,13,0.08)`, color `#d62800`; active
background `rgba(255,56,13,0.1)`, color `#d62800`, font-weight `500`; clear button
`#666666` with hover background `rgba(255,56,13,0.08)`, color `#d62800`.
### 4.6 Filter Chips
Three states: `chip-selected`, `chip-default`, `chip-disabled`. **Dark:** selected
uses the brand gradient `linear-gradient(135deg, #fa913c 0%, #ff380d 100%)` with
`#000000` text, no border, shadow `0 4px 12px rgba(255,56,13,0.35)`; default
background `#1f1f1f`, color `#d6d6d6`, border `1px solid #3a3a3a`; default hover
border `#fa913c`, background `rgba(250,145,60,0.1)`, color `#fa913c`, shadow `0 4px
12px rgba(250,145,60,0.15)`; disabled background `#1a1a1a`, color `#4a4a4a`, border
`1px solid #2a2a2a`, `cursor: not-allowed`. **Light:** selected same gradient and
black text, shadow `0 4px 12px rgba(255,56,13,0.25)`; default background `#ffffff`,
color `#333333`, border `1px solid #d4d4d4`; default hover border `#fa913c`,
background `rgba(255,56,13,0.05)`, color `#d62800`, shadow `0 4px 12px
rgba(250,145,60,0.12)`; disabled background `#f5f5f5`, color `#aaaaaa`, border `1px
solid #e0e0e0`, `cursor: not-allowed`.
### 4.7 Status Badge
In the hero card, the status badge reads "Live field data β’ 48 h window" with a 2x2
success dot (`bg-success` / `#16a34a`, glow `shadow-[0_0_10px_rgba(74,222,128,
0.8)]`). Its dark styling is background `rgba(24, 24, 24, 0.9)`, border `1px solid
rgba(150, 150, 150, 0.35)`, text `#d6d6d6`; its light styling is background
`#ffffff`, border `1px solid #d4d4d4`, text `#333333`.
### 4.8 Sidebar Controls (theme toggle + download)
The `.sidebar-ctrl` controls β the theme toggle and the Download Assets button, in
both mobile and desktop β are **pill-shaped with `border-radius: 9999px`** in both
themes. Their transition is `background-color 0.25s ease, border-color 0.25s ease,
color 0.25s ease`. Their icon, `.ctrl-icon`, is `width: 15px; height: 15px; flex-
shrink: 0`. **Dark:** background `#181818`, border `1px solid #2a2a2a`, color
`#d6d6d6`; hover background `#1f1f1f`, border-color `#fa913c`, color `#fa913c`.
**Light:** background `#ffffff`, border `1px solid #d4d4d4`, color `#333333`; hover
background `#fff5eb`, border-color `#ff380d`, color `#d62800`. The moon/sun icons
are swapped purely by CSS (`html.dark .icon-sun, html.light .icon-moon { display:
none }`); JavaScript only updates the text label.
### 4.9 Custom scrollbar
`.custom-scrollbar` styling is WebKit-only. The scrollbar width is `4px` and the
thumb radius is `4px`. **Dark:** track `#050505`, thumb `#1f1f1f`, thumb-hover
`#3d3d3d`. **Light:** track `#efefef`, thumb `#c4c4c4`, thumb-hover `#a0a0a0`.
### 4.10 Clearspace diagram
The clearspace diagram in the Logo section computes the exclusion-zone padding to
equal exactly 1x (the visual logo-mark height) using a `ResizeObserver` plus
`requestAnimationFrame`, iterating `solveZonePaddingToOneX` up to 6 times with a
0.25px tolerance. **Dark:** container background `rgba(124,179,255,0.03)`, border
`1px solid rgba(124,179,255,0.2)`, radius `8px`; exclusion zone `2px dashed
rgba(124,179,255,0.4)`; corner brackets and arrow gradient `#7cb3ff`; unit box
background `rgba(124,179,255,0.2)` with `1px solid rgba(124,179,255,0.5)` border;
measure lines `rgba(124,179,255,0.5)`. **Light:** container background
`rgba(29,78,216,0.03)`, border `1px solid rgba(29,78,216,0.2)`, radius `8px`;
exclusion zone `2px dashed rgba(29,78,216,0.35)`; corner brackets and arrow
gradient `#1d4ed8`; unit box background `rgba(29,78,216,0.1)` with `1px solid
rgba(29,78,216,0.4)` border; measure lines `rgba(29,78,216,0.4)`. Documented
minimum sizes: digital width 200, print width 50mm, icon-only 32px, favicon 16px.
### 4.11 Spacing scale, layout grids, and micro-interactions
The layout is built on Tailwind's spacing scale (`px-4`, `py-3`, `gap-3`, `gap-6`,
`gap-8`, `p-5`, `p-6`, `p-8`, `mb-4`, `mb-8`, `space-y-1`, `space-y-12`, etc.). The
main content column is centered at a maximum width of **`max-w-[1120px]`**. The
desktop sidebar is `w-72` (18rem / 288px). Key grid patterns: the hero uses
`grid-cols-1 lg:grid-cols-[3fr_2fr]`; card grids use `grid-cols-1 md:grid-cols-2`,
`md:grid-cols-2 lg:grid-cols-3`, `sm:grid-cols-2 lg:grid-cols-4`, and `sm:grid-
cols-6 lg:grid-cols-12` in various places. Cards typically use `rounded-[16px]`.
Micro-interactions throughout favor a subtle 2px lift on buttons (`translateY(-2px)`
hover, `translateY(1px)` active), border-color warming to Solar Flare `#fa913c` on
card and control hover, and the ProductCard title underline-on-hover. All of these
ride on short transitions (0.2sβ0.5s) detailed in Section 8.
---
## 5. Responsive and Mobile Behavior
The responsive system uses Tailwind's default breakpoints: **`sm` = 640px, `md` =
768px, `lg` = 1024px, `xl` = 1280px, `2xl` = 1536px.** The single most important
boundary in the whole layout is **`lg` = 1024px**, which is the sidebar/mobile
divide. Below 1024px the interface presents a fixed mobile header with a hamburger
button and a dropdown menu; at or above 1024px it presents the fixed desktop
sidebar and hides the mobile chrome.
The exact classes that switch at this boundary:
- The **mobile header** is `lg:hidden` β visible only below 1024px.
- The **mobile dropdown menu** is `lg:hidden` β visible only below 1024px.
- The **sidebar** is `hidden lg:flex` β hidden below 1024px, shown at/above.
- The **main content** is `lg:ml-72` β it gets a left margin equal to the sidebar
width (18rem / 288px) only at/above 1024px, so the content clears the fixed
sidebar on desktop and runs full-width on mobile.
- **Main height** is `h-screen h-dvh lg:h-screen` (the `h-dvh` dynamic-viewport-
height fallback accommodates mobile browser chrome).
- **Content padding** is `px-4 sm:px-6 lg:px-8` horizontally and `pt-20 pb-12
lg:pt-16 lg:pb-16` vertically β more top padding on mobile (`pt-20`) to clear the
fixed mobile header, less on desktop (`lg:pt-16`).
- The **hero grid** reflows from a single column to `lg:grid-cols-[3fr_2fr]` at
1024px.
- **Card grids** reflow at their own breakpoints: single column up to `md`/`sm`,
then two, three, four, or twelve columns as defined per grid (e.g. `grid-cols-1
md:grid-cols-2`, `md:grid-cols-2 lg:grid-cols-3`, `sm:grid-cols-2 lg:grid-cols-4`,
`sm:grid-cols-6 lg:grid-cols-12`).
Other responsive techniques in use: `min-h-screen` paired with `min-h-dvh` on the
app container (dynamic viewport-height fallback), `overflow-x-hidden` on the app
container to prevent horizontal scroll, `flex-col sm:flex-row` stacking in the
breadcrumb and button-demo headers, `w-full sm:w-auto` on the clear-all button, and
responsive logo-card padding via `clamp()` and `sm:`/`lg:` paddings. The desktop
sidebar width is `w-72` (288px); the mobile dropdown is `w-64` (16rem / 256px); the
mobile header height is `h-14` (3.5rem / 56px).
Touch behavior: the controls are large enough for touch (nav items at `px-3 py-2.5`
in the mobile menu, pill controls at `py-2.5`), the dropdown closes on an outside
tap, and the hamburger is a `w-9 h-9` (36px) target. Both themes behave identically
under responsive reflow β the layout logic is theme-independent; only the colors
differ β so a light-mode user on a phone gets the same hamburger, the same dropdown
geometry, and the same grid reflows as a dark-mode user, just rendered in the light
palette.
---
## 6. Mobile Menu (Full Detail)
The mobile menu is the most animation-rich part of the interface, and it is
critical to state up front what it is **not**: **it is a FLAT navigation list.
There are NO accordions, no expand/collapse sub-sections, and no nested
disclosure.** The dropdown contains a simple flat list of the same five nav buttons
the desktop sidebar has, followed by a divider and the theme/download controls.
Anyone rebuilding this must not invent accordion behavior β the real structure is
flat.
**Trigger and header.** The trigger is `#mobile-menu-toggle` (class
`.mobile-hamburger`), which lives inside `.mobile-header` β a `lg:hidden`,
`fixed top-0`, `z-50`, `h-14` bar with `backdrop-blur-md`. The header also shows the
SOLTERRA β’ EV wordmark.
**Dropdown.** The dropdown is `#mobile-menu`, `lg:hidden`, positioned `fixed top-14
right-3 z-50`, width `w-64` (16rem), radius `rounded-2xl`, with `shadow-2xl` (and in
light mode an additional CSS box-shadow `0 8px 32px rgba(0,0,0,0.10), 0 2px 8px
rgba(0,0,0,0.06)`). Its transform origin is `origin-top-right`. Its closed state is
the classes `opacity-0 scale-95 pointer-events-none`; its open state is `opacity-100
scale-100` with pointer events restored. Tailwind supplies `transform transition-all
duration-300 ease-out`, and a CSS rule reinforces it: `.mobile-dropdown { transition:
opacity 0.3s ease-out, transform 0.3s ease-out, background-color 0.3s ease, border-
color 0.3s ease }`. So the dropdown fades and scales from 95% to 100% out of its
top-right corner over 0.3s ease-out. **Dark** dropdown background `rgba(18,18,18,
0.97)`, border `1px solid #2a2a2a`, `backdrop-filter: blur(20px)`. **Light**
dropdown background `rgba(255,255,255,0.98)`, border `1px solid #e0e0e0`, `backdrop-
filter: blur(20px)`, plus the box-shadow above.
**Contents (flat).** Inside, a "Guidelines" label (`text-[9px]`, bold, uppercase,
`tracking-[0.18em]`), then the flat `nav` (`id="mobile-nav"`) of five `.mobile-nav-
item.nav-item` buttons with `data-section` attributes and emoji icons, each styled
`px-3 py-2.5 rounded-xl text-[13px]` font-medium with `transition-all duration-150`.
Below the nav is a divider (`mx-3 h-px divider`) and then the theme-toggle and
"Download Assets" `.sidebar-ctrl` controls. That is the entire menu β flat list plus
controls.
**Hamburger-to-X morph.** The hamburger button is `w-9 h-9 rounded-lg transition-all
duration-200` containing three `.ham-line` spans (`.ham-top`, `.ham-mid`,
`.ham-bot`). Each line is `position: absolute; left: 50%; width: 20px; height: 2px;
border-radius: 999px; transform-origin: center center; margin-left: -10px`. Their
rest positions are `top: calc(50% - 8px)`, `mid: calc(50% - 1px)`, `bot: calc(50% +
6px)`. The line transition is `transform 0.35s cubic-bezier(0.4,0,0.2,1), opacity
0.25s cubic-bezier(0.4,0,0.2,1), width 0.35s cubic-bezier(0.4,0,0.2,1)`. When the
button gains the `.is-open` class (toggled by JS), the lines morph into an X: the
**top** line does `translateY(7px) rotate(45deg)`, the **middle** line goes
`opacity: 0` with `transform: scaleX(0)`, and the **bottom** line does
`translateY(-7px) rotate(-45deg)`. So the transform animates over 0.35s on a
`cubic-bezier(0.4,0,0.2,1)` curve while the middle line's opacity fades over 0.25s.
Line color is `#a0a0a0` in dark (`#fa913c` on hover) and `#444444` in light
(`#ff380d` on hover).
**Open/close logic and accessibility.** Clicking the toggle calls
`e.stopPropagation()` then opens if closed or closes if open. Opening removes
`opacity-0`/`scale-95`/`pointer-events-none`, adds `opacity-100`/`scale-100`, adds
`.is-open` to the hamburger, and sets `aria-expanded="true"`. Closing reverses all
of that and sets `aria-expanded="false"`. A document click listener closes the menu
when a click lands outside it (`mobileOpen && !mobileMenu.contains(e.target)`). A
document keydown listener closes it on `Escape`. Clicking any `.nav-item` runs
`activateSection(section)` and then `closeMobileMenu()` (a no-op on desktop). ARIA:
the toggle has `aria-label="Toggle navigation menu"`, `aria-controls="mobile-menu"`,
and a toggled `aria-expanded`; the theme toggle has `aria-label="Toggle dark and
light theme"`; all decorative SVG icons are `aria-hidden="true"`; a live region
`#section-announcer` (`sr-only`, `role="status"`, `aria-live="polite"`) announces
"<Section> section" on navigation; and active nav-items receive
`aria-current="page"`.
---
## 7. CSS Strategy / Styling Architecture
The styling architecture is determined from the build configuration and dependency
manifest β not inferred from rendered CSS β and it is deliberately minimal and
literal. The stack is **Astro 5.16.2** with the **`@astrojs/tailwind` ^6.0.2**
integration driving **tailwindcss ^3.4.19** in its default utility-first JIT mode.
The site deploys to Cloudflare Workers via the `@astrojs/cloudflare` ^12.6.5 adapter
(platformProxy enabled), and also uses `@astrojs/sitemap` 3.6.0. The `tsconfig`
extends `astro/tsconfigs/strict` with `strictNullChecks` on.
Four β and only four β styling techniques are in play:
1. **Tailwind utility-first JIT classes.** The content glob is `./src/**/*.{astro,
html,js,jsx,md,mdx,svelte,ts,tsx,vue}`. This is the bulk of the styling.
2. **Astro scoped component `<style>` blocks** β vanilla CSS, hashed by Astro via
`[data-astro-cid-*]` attributes. Used in Button, ColorCard, ProductCard, and
Navigation. These reach the global theme classes via `:global(html.dark)` /
`:global(html.light)`. The page (`index.astro`) also has one large `<style>`
block whose selectors are all prefixed with `html.dark`/`html.light`, which β
because they hang off the `html` ancestor β behave global-ish even though Astro
technically scopes them.
3. **Design tokens in `tailwind.config.mjs` `theme.extend`** β the seven brand
colors under `colors` (extend, so black/white/transparent survive) and the four
font families (`inter`, `plex-sans`, `plex-serif`, `plex-mono`) under
`fontFamily`, generating the `font-*` and `bg-*`/`text-*` utilities.
4. **Class-based theme switching** β a `dark` or `light` class toggled on the
`<html>` element (`document.documentElement`), with paired `html.dark .x` /
`html.light .x` rules for every themed surface, persisted in `localStorage`
under the key **`solterra-theme`** (values `'dark'` | `'light'`, default
`'dark'` when unset). The initial markup hardcodes `<html lang="en"
class="dark">`; the script overrides `className` from `localStorage` on
`DOMContentLoaded`. The moon/sun icon swap is pure CSS (`html.dark .icon-sun,
html.light .icon-moon { display: none }`); JS only updates the text label
(`themeLabel.textContent = theme === 'light' ? 'Light Mode' : 'Dark Mode'`).
What is explicitly **ABSENT** matters as much as what is present, and a rebuild must
preserve these absences:
- **No CSS-in-JS** β no styled-components, emotion, or vanilla-extract.
- **No CSS Modules** β no `*.module.css`.
- **No Sass/SCSS** β there is no `sass` dependency.
- **No CSS custom properties** β there are **zero `--var` custom properties**
anywhere. Theming is done with literal hex/rgba values **duplicated** per theme
block. Every color you see in this document is hardcoded twice (once in each
theme block), not referenced through a variable.
- **No standalone PostCSS config** β Tailwind's PostCSS pipeline is managed by the
`@astrojs/tailwind` integration.
- **No global stylesheet** β `src/styles/global.css` was deleted and is not
imported. All CSS lives in the page `<style>`, the component `<style>` blocks, and
Tailwind utilities.
- **No content collections** β `content.config.ts` was deleted; there are no Astro
content collections.
Finally, a genuine subtlety about Tailwind's own dark-mode variant:
**`darkMode` is NOT configured in `tailwind.config.mjs`,** so it defaults to the
`'media'` strategy. That means Tailwind's built-in `dark:` variant keys off the OS
`prefers-color-scheme`, which is a **separate mechanism** from the app's class-based
`.dark`/`.light` toggle. The `dark:` utility is used in exactly one place β the hero
card's radial glow override (`dark:bg-[radial-gradient(circle,rgba(255,56,13,0.25),
transparent_60%)]`) β and that single utility therefore follows the operating
system's preference, not the in-app toggle. Do not conflate the two: the app's real
theme system is the custom class toggle; the lone `dark:` utility is a different,
OS-driven thing. (Relatedly, a `.toggle-thumb` sliding element referenced in earlier
revisions no longer exists; the theme control is icon-swap based, not a sliding
thumb.)
---
## 8. Transitions and Animation
All motion in the system is **pure CSS** β CSS transitions and `@keyframes`
animations. There is **no JavaScript animation library** anywhere; JS only toggles
classes and the CSS does the animating. The values below are verbatim.
**The global transition rule.** A universal-selector rule provides the smooth
theme cross-fade: `* { transition-property: background-color, border-color, color,
box-shadow; transition-duration: 0.3s; transition-timing-function: ease-in-out; }`.
This deliberately **omits `opacity` and `transform`** so that component-specific
transitions can own those properties without the global rule interfering. The
global rule is intentionally overridden by higher-specificity component transitions
(`.sidebar-ctrl`, `.mobile-dropdown`, `.ham-line`, `.btn-primary`/`.btn-ghost`),
which add `transform`/`opacity` and use their own durations and easings.
**The fadeIn keyframe.** `@keyframes fadeIn { from { opacity: 0; transform:
translateY(10px) } to { opacity: 1; transform: translateY(0) } }`, exposed as
`.animate-fadeIn { animation: fadeIn 0.4s ease-out forwards }`. It is applied to
each content section, and is re-added whenever a section is activated, so every
section gently fades and rises into place over 0.4s on switch.
**Component transitions (exact values):**
- **Buttons** (`.btn-primary`, `.btn-ghost`, both themes): `all 0.2s cubic-bezier
(0.4, 0, 0.2, 1)`. Technique: gradient swap plus box-shadow change plus a
`translateY` lift (hover `-2px`, active `+1px`).
- **Sidebar controls** (`.sidebar-ctrl`): `background-color 0.25s ease, border-color
0.25s ease, color 0.25s ease`.
- **Hamburger lines** (`.ham-line`): `transform 0.35s cubic-bezier(0.4,0,0.2,1),
opacity 0.25s cubic-bezier(0.4,0,0.2,1), width 0.35s cubic-bezier(0.4,0,0.2,1)`.
Technique: the hamburger-to-X morph (rotate `45deg`/`-45deg`, translateY
`+/-7px`, middle line `scaleX(0)` + `opacity 0`). These values must agree exactly
with Section 6, and they do.
- **Mobile dropdown** (`.mobile-dropdown`): `opacity 0.3s ease-out, transform 0.3s
ease-out, background-color 0.3s ease, border-color 0.3s ease`. Technique: fade
plus scale-95β100 from `origin-top-right`. Again, this agrees with Section 6.
- **App container** (`#app-container`): Tailwind `transition-colors duration-500` β
a 500ms theme-background cross-fade.
- **Many themed surfaces** (mobile header, divider, hero card, status badge, code
block, card grids, etc.): Tailwind `transition-colors duration-500`, or
`transition-all duration-500` on the hero card and card grids β all theme
cross-fades.
- **Nav items** (`Navigation.astro`): Tailwind `transition-all duration-200` for
the color/background shift on hover and active; the nav-item icon uses
`transition-opacity` for a `group-hover` opacity shift from 0.8 to 1.
- **Mobile nav items** (`.mobile-nav-item`): Tailwind `transition-all duration-150`.
- **Mobile hamburger button**: Tailwind `transition-all duration-200`.
- **ProductCard title**: Tailwind `transition-all`, with `group-hover` underline and
`text-solar-flare`.
- **Breadcrumb items, filter chips, clear button, copy buttons**: Tailwind
`transition-all` or `transition-colors`.
**Duration catalog:** 0.2s (buttons), 0.25s (sidebar-ctrl, hamburger opacity), 0.3s
(global `*` rule, mobile dropdown), 0.35s (hamburger transform/width), 0.4s
(fadeIn), `duration-150` (mobile nav items), `duration-200` (nav items, hamburger
button), and `duration-500` (theme color cross-fades). **Easing catalog:**
`ease-in-out` (global `*`), `ease-out` (fadeIn, mobile dropdown, Tailwind
`ease-out`), `ease` (sidebar-ctrl, dropdown background/border), and
`cubic-bezier(0.4, 0, 0.2, 1)` (buttons and hamburger lines).
**Honest gap β no reduced-motion handling.** There is **no `prefers-reduced-motion`
media query anywhere** in the codebase. None of the transitions or the fadeIn
animation are disabled or attenuated for users who prefer reduced motion. This is a
real accessibility gap and is documented here as an absence, not invented behavior.
A faithful rebuild reproduces this absence (or, if improving the system, would add
reduced-motion handling as a deliberate enhancement β but it is not present today).
---
## Appendix β Documented Gaps and Inconsistencies (Summary)
For a faithful rebuild, the following deliberate absences and known inconsistencies
must be reproduced or at least understood:
1. **Flat mobile menu** β no accordion or expand/collapse; five flat nav buttons
plus theme toggle and download.
2. **No `prefers-reduced-motion`** β motion is never attenuated; this gap is real.
3. **Global `*` transition rule** β animates only `background-color, border-color,
color, box-shadow` at `0.3s ease-in-out`, intentionally omitting `opacity`/
`transform`, which component-specific rules (`.sidebar-ctrl`, `.mobile-dropdown`,
`.ham-line`, `.btn-primary`/`.btn-ghost`) override.
4. **No CSS custom properties** β every theme value is literal hex/rgba duplicated
across the `html.dark` and `html.light` blocks.
5. **Light active-color inconsistency** β `index.astro` uses `#e53200` for
`html.light .nav-item.active`, while `Navigation.astro` uses `#d62800`. Both
exist and both render (component scoped rule wins for the desktop sidebar;
`index.astro`'s mobile nav-items use `#e53200`).
6. **`darkMode` unset in Tailwind** β the single `dark:` utility (the hero radial
glow) follows `prefers-color-scheme`, NOT the in-app `.dark`/`.light` class
toggle.
7. **No global stylesheet, no content collections, no `.toggle-thumb`** β
`src/styles/global.css` and `content.config.ts` were deleted; the theme control
is icon-swap based, not a sliding thumb.
---
## Appendix A: Usage Rules & Do's and Don'ts
These rules are grounded in the system's real values; they restate intent so a
rebuild stays faithful to how the tokens are actually used.
### Color
- DO reserve Solterra Scarlet `#ff380d` and Solar Flare `#fa913c` for accents,
CTAs, active states, and the most urgent signals. The primary button gradient
runs `#fa913c -> #ff380d`; the active nav border is `2px solid #fa913c` (dark)
or `2px solid #ff380d` (light).
- DON'T use scarlet or orange for body text. Body text is `#d6d6d6` in dark and
`#333333` in light; the warm signal colors are interaction-only.
- DO keep one accent per view. Scarlet commands attention and Solar Flare bridges
to brand warmth - using both at full strength everywhere dilutes the signal.
- DO pull surfaces and text from the 12-step neutral ramp (`#050505` through
`#f9fafb`) rather than arbitrary grays. Every surface and border in the doc
resolves to a ramp step or a documented rgba.
- DON'T invent new grays outside the ramp; the ramp anchors (Deep Space `#050505`,
Orbital Gray `#181818`, Lunar Dust `#d6d6d6`) carry the system.
### Typography
- DO use IBM Plex Sans for section headings (`h2`/`h3`) and technical sub-heads /
spec data; it is the section-title face at `text-4xl` `font-bold`.
- DO use Inter for UI chrome, body copy, labels, and buttons; it is the workhorse
and the only family loaded at weight 500. Note the hero `h1` is Inter
`font-semibold`, an intentional split from the Plex Sans section titles - do not
unify them.
- DO use IBM Plex Mono for data, IDs, code, and terminal strings (CAN IDs, pin
names); it is also the face that renders this very document.
- DO reserve IBM Plex Serif for long-form prose only. It is loaded but barely used
on the live site - do not press it into UI duty.
- DON'T mix families within a role. Each family has exactly one job.
### Buttons
- DO use exactly one primary button (the gradient pill) per view for the main
action; use the ghost button for secondary actions.
- DON'T stack multiple primaries - the gradient pill is the hero action and loses
meaning if repeated.
- DO keep the pill radius (`border-radius: 9999px` / `rounded-full`) on buttons,
sidebar controls, and chips. The pill shape is constant across variants.
### Spacing / Layout
- DO center main content at a maximum width of `1120px` (`max-w-[1120px]`).
- DO keep consistent section rhythm using the Tailwind spacing scale
(`gap-3`/`gap-6`/`gap-8`, `p-5`/`p-6`/`p-8`, `space-y-12`, etc.) and generous
negative space - this is the "Function over Form" principle made literal.
- DON'T crowd the layout. Legibility and data clarity always win over ornament;
whitespace is a feature, not wasted room.
---
## Appendix B: Accessibility (WCAG AA)
This appendix states the measured contrast picture honestly, including where a
value is comfortable and where it is merely adequate.
### Dark mode (measured)
- Heading `#f9fafb` and body `#d6d6d6` sit well above AA on the dark surfaces
`#050505` and `#181818` (both > 10:1) - comfortable for all text sizes.
- Muted `#8a8a8a` measures about 5.14:1 on `#181818` and about 5.9:1 on `#050505`.
Both pass AA for normal text (>= 4.5:1), though they are closer to the line than
the heading/body tiers.
- Accent `#7cb3ff` measures about 9:1 on the dark surfaces - passes comfortably.
### Light mode (measured)
- Heading `#111111` measures about 18:1 and body `#333333` about 12:1 on the
light surfaces - well above AA.
- Muted `#666666` measures about 5.7:1 - passes AA for normal text.
- Accent `#1d4ed8` measures about 7:1 - passes.
### History: the muted tier was raised specifically to meet AA
The muted text tier was deliberately raised from an earlier `#5c5c5c` (roughly 3:1,
which FAILED AA for normal text) to `#8a8a8a` in dark and `#666666` in light, for
the express purpose of meeting AA. This is a real accessibility decision, not an
incidental color tweak: `#5c5c5c` now survives only in non-text-critical roles
(footer text, CMYK metadata rows), while the readable muted tier carries the
passing contrast.
### Accessibility features already shipping
- `aria-current="page"` on the active nav-item.
- An `sr-only` live region (`#section-announcer`, `role="status"`,
`aria-live="polite"`) that announces "<Section> section" on navigation.
- `aria-expanded` on the mobile menu toggle, plus Escape-to-close and
outside-click-to-close behavior; the toggle also has `aria-controls="mobile-menu"`.
- `aria-hidden="true"` on decorative SVG icons.
- Pill controls and buttons use comfortable touch-target sizing on mobile (nav
items at `px-3 py-2.5`, pill controls at `py-2.5`, hamburger at `w-9 h-9` /
36px). The 36px hamburger is acceptable but sits slightly under the 44px comfort
ideal - honest to note it is adequate rather than generous.
Borderline-honesty note: the muted tiers pass AA but by a smaller margin than the
heading/body tiers, and the 36px hamburger target is adequate rather than ideal.
Everything documented here passes AA for normal text; none of it reaches AAA.
---
## Appendix C: Motion & Reduced Motion
This appendix summarizes the motion system by reference to Section 8. The durations
and easings here are the same values stated there - they must agree, and they do.
### Summary (see Section 8 for the full catalog)
- The standard easing across interactive motion is `cubic-bezier(0.4,0,0.2,1)`
(buttons and hamburger lines).
- Standard durations fall in the 150-350ms band: mobile nav items 150ms, nav items
and the hamburger button 200ms (buttons 0.2s), sidebar controls and hamburger
opacity 0.25s, the global `*` rule and mobile dropdown 0.3s, hamburger transform
and width 0.35s.
- The theme cross-fade is 500ms (`duration-500`).
- The section entrance is the `fadeIn` keyframe at 0.4s `ease-out`.
These are restatements of Section 8, not new numbers - refer to Section 8 for the
authoritative per-selector list and the duration/easing catalogs.
### Reduced motion: recommended, not yet implemented
The current build does NOT implement `prefers-reduced-motion`. No transition or
keyframe is disabled or attenuated for users who prefer reduced motion (this gap is
also documented in Section 8 and the gaps appendix).
As a recommendation for a conformant rebuild (not a description of current
behavior), wrap non-essential transitions, transforms, and keyframes in a
`@media (prefers-reduced-motion: reduce)` block to reduce or remove motion while
preserving state changes: make section switches instant (drop the `fadeIn`
translateY rise), remove the button `translateY` lifts, and remove the scale/slide
on the mobile menu (the `scale-95 -> scale-100` and dropdown transform). State
itself - which section is active, which theme is on, open vs. closed - must still
change; only the animation between states is reduced. This is framed as
recommended, not yet implemented.
---
## Appendix D: Rebuild Checklist
An ordered, step-by-step path to recreate the theme from scratch. Each step is a
single actionable line pointing at where the detail lives in this document.
1. Scaffold Astro 5 with Tailwind v3 via the `@astrojs/tailwind` integration in
utility-first JIT mode (Section 7).
2. Register the 7 color tokens and the 4 font families in `tailwind.config.mjs`
under `theme.extend.colors` and `theme.extend.fontFamily` - extend, not replace,
so black/white/transparent survive (Sections 2.1, 3.1, 7; Quick Reference brand
tokens table).
3. Load IBM Plex Sans / Mono / Serif and Inter from Google Fonts via preconnect +
a single `css2` stylesheet with `display=swap`, using the exact weights per
family (Section 3.2).
4. Set `<html lang="en" class="dark">` as the default and add the
`localStorage 'solterra-theme'` class toggle, with paired `html.dark .x` /
`html.light .x` rules for every themed surface (Sections 2, 7).
5. Build the type scale - hero `h1` in Inter, section `h2` in Plex Sans, and the
label/eyebrow/mono tiers (Section 3.3; Quick Reference type scale table).
6. Build the primary and ghost buttons plus the pill controls with the documented
gradients (`#fa913c -> #ff380d`), shadows, and `translateY` transforms, keeping
`border-radius: 9999px` (Sections 4.1, 4.8; Quick Reference radius/shadows).
7. Build the cards (`rounded-[16px]`), nav items (8px radius), and filter chips
(pill) with their dark/light surface, border, and hover values (Sections 4.2,
4.3, 4.4, 4.6).
8. Add the global `* { transition: bg-color, border-color, color, box-shadow 0.3s
ease-in-out }` rule and the `fadeIn` keyframe (0.4s ease-out) applied per
section (Section 8; Quick Reference animation timings).
9. Wire the responsive layout: a fixed `w-72` sidebar at `>= lg` (1024px), and a
mobile header plus hamburger dropdown below `lg` (Sections 5, 6; Quick Reference
breakpoints).
10. Wire ARIA and the section announcer: `aria-current`, the `sr-only`
`aria-live="polite"` region, `aria-expanded` + Escape + outside-click on the
mobile menu, and `aria-hidden` on decorative icons (Section 6; Appendix B).
11. Verify AA contrast for every text tier on both themes - confirm the muted tier
stays at `#8a8a8a` / `#666666`, not the failing `#5c5c5c` (Appendix B).
---
## Appendix E: Implementation Guide (Per-Feature / Per-Function Code Walkthrough)
This appendix is a code-level walkthrough of the actual repository as it exists
today. Where the rest of this document describes the brand and the rendered
result, this appendix documents the source: every config file, every component,
every data structure, and every function. Each entry follows a fixed template:
Name + File:line - the identifier and where it lives, with the line numbers
observed in the current source.
Signature / props - the call signature or component Props.
Purpose - what the thing is for.
How it works - a step-by-step of the real logic.
Gotchas - edge cases, ordering constraints, and why it is written
the way it is.
Line numbers are accurate to the source at the time of writing. Treat them as a
strong hint, not a contract: an edit above an entry shifts everything below it.
The subsections E.1 through E.10 map one-to-one onto the build/config, data,
components, theme, mobile menu, section switching, clear-space solver, brand-spec
copy, theming CSS, and responsive-layout groupings.
---
### E.1 Build and configuration files
#### E.1.1 astro.config.mjs - defineConfig (astro.config.mjs:9-17)
Signature: `export default defineConfig({ site, integrations, adapter })`.
Purpose: the single Astro project configuration. It pins the canonical site URL,
registers build integrations, and selects the deployment adapter.
How it works:
1. `site: "https://brand.solterratech.com"` (line 10) sets the absolute base
URL. `@astrojs/sitemap` uses it to emit absolute `<loc>` entries, and Astro
uses it for any absolute-URL helpers.
2. `integrations: [sitemap(), tailwind()]` (line 11) runs the sitemap generator
and the Tailwind integration. `tailwind()` injects Tailwind into the build
and auto-applies the base stylesheet, so no manual `@tailwind` directives are
needed in the project.
3. `adapter: cloudflare({ platformProxy: { enabled: true } })` (lines 12-16)
builds for Cloudflare Workers. `platformProxy.enabled` lets `astro dev`
emulate Cloudflare bindings locally (the Workers runtime, env, etc.).
Gotchas: the file begins with `// @ts-check` (line 1), so the config is itself
type-checked. The `site` value here is the production hostname; it does not have
to match the Worker name in wrangler.json. Because the Cloudflare adapter is set,
`astro build` produces a `dist/_worker.js` bundle (consumed by wrangler.json
`main`), not a plain static folder.
#### E.1.2 tailwind.config.mjs - theme.extend.colors (tailwind.config.mjs:11-19)
Purpose: declares the seven brand color tokens as Tailwind utilities. This is the
single source of truth for the palette across the whole project.
How it works: under `theme.extend.colors` it defines `scarlet: '#ff380d'`,
`'solar-flare': '#fa913c'`, `'atmosphere-blue': '#7cb3ff'`, `'lunar-dust':
'#d6d6d6'`, `'deep-space': '#050505'`, `'orbital-gray': '#181818'`, and
`success: '#16a34a'`. Because they sit under `extend`, Tailwind merges them with
its defaults rather than replacing them, so `black`, `white`, and `transparent`
still resolve. Each token becomes a full utility family: `text-solar-flare`,
`bg-scarlet`, `border-solar-flare/50`, `from-solar-flare`, `to-scarlet`, etc.
Gotchas: `success` is the seventh token and is named semantically (not
`aurora-green`), so the utilities are `bg-success` / `text-success`. The hyphenated
keys must be quoted in the object literal. The opacity-slash syntax
(`border-solar-flare/50`) works only because these are real theme colors, not
arbitrary values.
#### E.1.3 tailwind.config.mjs - theme.extend.fontFamily (tailwind.config.mjs:22-27)
Purpose: declares the four brand font families as `font-*` utilities.
How it works: `inter: ['Inter', 'sans-serif']`, `'plex-sans': ['"IBM Plex Sans"',
'sans-serif']`, `'plex-serif': ['"IBM Plex Serif"', 'serif']`, and `'plex-mono':
['"IBM Plex Mono"', 'monospace']`. This generates `font-inter`, `font-plex-sans`,
`font-plex-serif`, and `font-plex-mono`. Each entry carries a generic fallback so
text still renders before the web fonts load.
Gotchas: the multi-word family names are double-quoted inside the array
(`'"IBM Plex Sans"'`) so the generated CSS emits a quoted family name, which is
required for names containing spaces. These utilities are what index.astro and the
components reference; the fonts themselves are loaded from Google Fonts in the
page `<head>` (index.astro:108-113).
#### E.1.4 tailwind.config.mjs - content glob (tailwind.config.mjs:3)
Purpose: tells Tailwind which files to scan so JIT only emits utilities that are
actually used.
How it works: `content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}']`
globs the whole `src` tree across many extensions. `plugins: []` (line 30) adds no
plugins.
Gotchas: the glob includes `md`/`mdx`, but the brand spec is imported via Vite
`?raw` (a string), not compiled as content, so utility class names mentioned only
inside brand-spec.md text are not guaranteed to be generated. Any class used on
the page must appear in an actual `.astro` file to survive purge.
#### E.1.5 package.json - dependencies and scripts (package.json:14-34)
Purpose: declares the toolchain and the npm scripts that drive dev/build/deploy.
How it works:
- dependencies (lines 14-19): `@astrojs/cloudflare ^12.6.5`, `@astrojs/sitemap
3.6.0`, `astro 5.16.2`, `typescript 5.9.3`.
- devDependencies (lines 20-24): `@astrojs/tailwind ^6.0.2`, `tailwindcss
^3.4.19`, `wrangler 4.56.0`.
- scripts (lines 26-34): `astro` (raw CLI), `build: astro build`, `dev: astro
dev`, `preview: astro build && wrangler dev` (build then serve under the
Workers runtime, not `astro preview`), `deploy: wrangler deploy`, `check:
astro build && tsc && wrangler deploy --dry-run` (full gate: build, type-check,
and a dry-run deploy), and `cf-typegen: wrangler types`.
Gotchas: `preview` deliberately routes through wrangler so you exercise the
Cloudflare runtime rather than a generic static server; this matters because the
site ships as a Worker. `"type": "module"` (line 35) makes the `.mjs`/`.js`
config files ES modules. `"private": true` (line 25) prevents accidental publish.
#### E.1.6 wrangler.json - Cloudflare Workers config (wrangler.json:1-14)
Purpose: configures the Cloudflare Worker that serves the built site.
How it works: `name: "brand-guide"`, `compatibility_date: "2025-10-08"`,
`compatibility_flags: ["nodejs_compat"]` (enables Node built-ins in the Worker),
`main: "./dist/_worker.js/index.js"` (the Astro Cloudflare adapter output entry),
and an `assets` block pointing `directory: "./dist"` with `binding: "ASSETS"` so
static files are served from the build output. `observability.enabled: true` turns
on Workers logging, and `upload_source_maps: true` ships source maps for readable
stack traces.
Gotchas: `main` must match the adapter's emitted path exactly; the Astro
Cloudflare adapter writes `dist/_worker.js/index.js`, so this is coupled to that
adapter, not interchangeable with a static-only setup. `nodejs_compat` is required
because the SSR worker may touch Node APIs.
#### E.1.7 tsconfig.json - strict TypeScript (tsconfig.json:1-8)
Purpose: configures TypeScript for the project.
How it works: `extends: "astro/tsconfigs/strict"` inherits Astro's strict preset,
`include: [".astro/types.d.ts", "**/*"]` pulls in generated Astro types plus all
source, `exclude: ["dist"]` skips build output, and `compilerOptions.strictNullChecks:
true` re-asserts null-safety on top of the strict base.
Gotchas: strict mode is why the inline scripts in index.astro use the `?.`
optional-chaining and explicit `as HTMLElement | null` casts on every
`getElementById` - those elements are typed as possibly null, and the strict
config forces the code to handle the null branch.
#### E.1.8 src/consts.ts - site metadata (src/consts.ts:4-6)
Signature: `export const SITE_TITLE: string` and `export const SITE_DESCRIPTION:
string`.
Purpose: centralizes the two pieces of global page metadata.
How it works: `SITE_TITLE = "Solterra EV Chargers - Brand Guide"` and
`SITE_DESCRIPTION` is the longer description string. index.astro imports both
(index.astro:6) and binds them into `<title>{SITE_TITLE}</title>` (line 106) and
`<meta name="description" content={SITE_DESCRIPTION} />` (line 107).
Gotchas: this is the only place the title/description live; editing the page head
means editing this file. It is plain TS (no Astro-specific imports), so it can be
imported anywhere.
#### E.1.9 src/env.d.ts - Cloudflare runtime types (src/env.d.ts:1-5)
Purpose: augments Astro's `App.Locals` with the Cloudflare runtime type so
`Astro.locals` is typed with the Worker `Env` bindings.
How it works: it aliases `type Runtime = import("@astrojs/cloudflare").Runtime<Env>`
and declares `namespace App { interface Locals extends Runtime {} }`.
Gotchas: `Env` is the Cloudflare-generated binding type (produced by `wrangler
types` / the `cf-typegen` script). This file carries no runtime code; it exists
purely for editor/compiler type information about Cloudflare locals.
---
### E.2 index.astro frontmatter data and imports
#### E.2.1 Component and data imports (index.astro:2-9)
Purpose: pulls in the four BrandGuide components, the site constants, and the raw
brand-spec string.
How it works: lines 2-5 import `Button`, `Navigation`, `ColorCard`, and
`ProductCard` from `../components/BrandGuide/`. Line 6 imports `SITE_TITLE` and
`SITE_DESCRIPTION` from `../consts`. Line 9 imports the spec via Vite `?raw`:
`import brandSpec from "../data/brand-spec.md?raw"`.
Gotchas: the `?raw` suffix is the load-bearing detail. It makes Vite inline the
file's exact bytes as a JS string at build time, rather than parsing the markdown.
That single string is then used in two places - the `<pre>` display and the copy
handler - guaranteeing they are identical (see E.8). The header comment on lines
7-8 states this intent explicitly.
#### E.2.2 colors object (index.astro:11-54)
Shape: an object with seven keys (`primary`, `secondary`, `accent`, `text`,
`dark`, `surface`, `success`), each a `{ name, hex, cmyk, usage }` record.
Purpose: the data backing the Color System section's ColorCards.
How it works: each entry pairs a brand name with its `hex`, a `cmyk` string, and a
`usage` blurb - e.g. `primary` is Solterra Scarlet `#ff380d`, `success` is Aurora
Green `#16a34a`. These objects are passed directly into `<ColorCard color={...} />`
(index.astro:733-763) and their shape matches ColorCard's `Props.color` exactly.
Gotchas: the keys are semantic role names (`primary`, `surface`, `success`), not
the Tailwind token names (`scarlet`, `orbital-gray`). The card grid groups them by
hand (Primary Spectrum, Neutral & Surface, Semantic), so adding a key here does not
automatically place it - it must be wired into the markup.
#### E.2.3 neutrals ramp array (index.astro:58-71)
Shape: a 12-element array of `{ step, hex }`.
Purpose: backs the Neutral Ramp swatch grid in the Colors section.
How it works: it lists steps `950` (`#050505`) down to `50` (`#f9fafb`), anchoring
Deep Space (950), Orbital Gray (800), and Lunar Dust (100) and filling the
intermediate steps. The Colors section maps over it (index.astro:775-790) to render
a swatch per step with an inline `style={`background:${n.hex}`}` and an `N-${n.step}`
label.
Gotchas: the ramp is documentation data, not Tailwind tokens - only three of these
twelve values exist as `theme.extend.colors`. The swatch background is set via
inline style precisely because the other nine hexes are not utilities.
#### E.2.4 fonts array (index.astro:73-98)
Shape: a 4-element array of `{ name, role, usage, family }`.
Purpose: backs the Typography section's four font cards.
How it works: each entry names a font (Inter, IBM Plex Sans/Serif/Mono), its role,
a usage note, and the matching Tailwind `family` class (`font-inter`,
`font-plex-sans`, `font-plex-serif`, `font-plex-mono`). The Typography section maps
over it (index.astro:819-857) and applies `font.family` to the specimen elements so
each card renders in its own typeface.
Gotchas: the `family` strings must exactly match the utilities generated in
tailwind.config.mjs (E.1.3) or the specimen would silently fall back to the body
font.
---
### E.3 BrandGuide components
#### E.3.1 Button.astro - component (Button.astro:1-31)
Props (lines 2-8): `variant?: 'primary' | 'ghost'` (default `'primary'`),
`state?: 'default' | 'hover' | 'active' | 'disabled'` (default `'default'`),
`disabled?: boolean` (default `false`), `className?: string` (default `''`), plus
an index signature `[key: string]: any` to forward arbitrary attributes.
Purpose: the single button primitive used both as a real interactive control and
as a static state specimen in the Components showcase.
How it works:
1. Line 10 destructures props with defaults and gathers the rest into `...props`.
2. Line 12 computes `isDisabled = disabled || state === 'disabled'` - either the
boolean prop or the `'disabled'` state disables the button.
3. Line 14 defines `baseStyles`, a long Tailwind class string shared by every
button (inline-flex layout, `rounded-full`, `text-[13px]`, `font-semibold`,
`font-inter`, `select-none`, etc.).
4. `getVariantClass()` (lines 17-22) returns `btn-primary btn-primary-${state}`
for the primary variant, otherwise `btn-ghost btn-ghost-${state}`.
5. The `<button>` (lines 25-30) renders `class:list={[baseStyles,
getVariantClass(), className]}`, sets `disabled={isDisabled}`, spreads
`{...props}`, and renders `<slot />` for the label.
Gotchas - the dual mechanism: this is the key design. A button gets BOTH a base
class (`btn-primary`) and a state class (`btn-primary-${state}`). The `*-default`
state CSS includes real `:hover` and `:active` pseudo-class rules (Button.astro:52-67),
so a `state="default"` button is genuinely interactive. The other state classes
(`btn-primary-hover`, `btn-primary-active`, lines 70-87) are STATIC - they paint
the hover/active appearance unconditionally, with no pseudo-class, so the showcase
grid can display all four states side by side without the user having to interact.
`isDisabled` both sets the native `disabled` attribute (blocking clicks) and, via
`state="disabled"`, selects the disabled styling.
#### E.3.2 Button.astro - per-theme button CSS (Button.astro:33-279)
Purpose: defines all visual states for both variants in both themes.
How it works: the `<style>` block is organized into four labeled sections -
primary/dark (37-96), ghost/dark (101-155), primary/light (160-219), ghost/light
(224-278). Every rule is scoped with `:global(html.dark)` or `:global(html.light)`
so the theme class on `<html>` selects the active set. The primary variant uses a
`linear-gradient(135deg, #fa913c 0%, #ff380d 100%)` fill with layered box-shadows
and `transform: translateY(...)` lifts; the ghost variant is transparent with a
border that glows on interaction. The shared transition is `all 0.2s
cubic-bezier(0.4, 0, 0.2, 1)`.
Gotchas: `:global(...)` is required because Astro scopes component styles by
default, but the theme class lives on `<html>` (outside the component), and these
classes are applied to the rendered button. The static `-hover`/`-active` rules
intentionally duplicate the `:hover`/`:active` rules of `-default` so the demo and
the live behavior match pixel for pixel.
#### E.3.3 ColorCard.astro - component (ColorCard.astro:1-45)
Props (lines 2-9): `color: { name: string; hex: string; cmyk: string; usage:
string }`.
Purpose: renders one color swatch with name, usage, a copyable HEX row, and a CMYK
row.
How it works: line 11 destructures `color`. The markup paints a 128px-tall swatch
via inline `style={`background-color: ${color.hex}`}` (lines 15-18) with a subtle
gradient overlay, then a content block with the name/usage and two rows. The HEX row
is a `<button class="copy-hex" data-hex={color.hex}>` (lines 28-38) holding the hex
text, a clipboard `copy-icon`, and a hidden `check-icon`.
Gotchas: the swatch color must be an inline style because arbitrary card colors are
data, not Tailwind tokens. The `data-hex` attribute is how the click handler reads
the value without re-parsing the DOM text.
#### E.3.4 ColorCard.astro - copy-hex click handler (ColorCard.astro:98-118)
Purpose: copies the swatch hex to the clipboard with visual confirmation.
How it works: on `DOMContentLoaded` it selects all `.copy-hex` buttons and binds a
click listener to each. On click it reads `data-hex`; if present it calls
`navigator.clipboard.writeText(hex)`, then adds `hidden` to `.copy-icon` and removes
`hidden` from `.check-icon` (swapping the clipboard glyph for a green check). A
`setTimeout(..., 2000)` reverses the swap after 2 seconds.
Gotchas: this script runs once per page and binds every ColorCard on the page in a
single pass, because Astro hoists and dedupes identical component `<script>` tags -
the handler is shared, not duplicated per card. There is no clipboard fallback here
(unlike the brand-spec copy in E.8); on a non-secure context the write would simply
fail silently. The icon swap is class-toggle only, so it is independent of whether
the clipboard write succeeded.
#### E.3.5 ProductCard.astro - component (ProductCard.astro:1-29)
Props (lines 2-8): `title: string`, `badge: string`, `desc: string`, `meta1:
string`, `meta2: string`.
Purpose: a static product tile used in the Components showcase.
How it works: line 10 destructures all five props. The `<article>` (line 13) is a
`group` flex column. The title `<h3>` (lines 15-17) carries `group-hover:text-solar-flare
group-hover:underline` so hovering anywhere on the card highlights and underlines
the title. The badge, description (`mb-auto` pushes the meta row to the bottom),
and the two meta spans fill the rest.
Gotchas: `mb-auto` on the description is what makes a row of cards bottom-align their
meta rows regardless of description length. The hover effect is driven by the
parent `group` class, not a hover on the title itself, so the whole card is the
hover target. This component has no script - it is purely presentational.
#### E.3.6 Navigation.astro - component (Navigation.astro:1-31)
Props (lines 2-4): `activeSection: string`.
Purpose: renders the desktop sidebar's five guideline nav buttons.
How it works: lines 8-14 define `navItems`, an array of `{ id, label, icon }` for
intro/logo/typography/components/colors. Lines 18-30 map over it, emitting a
`<button data-section={item.id}>` per entry with the shared `nav-item` classes plus
`active` when `activeSection === item.id`. Each button shows its icon and label.
Gotchas: this component only paints the INITIAL active state (it is passed
`activeSection="intro"` at index.astro:208). Live switching is handled by the page
script via the `data-section` attribute and the `.nav-item` selector (E.6) - the
component does not own click behavior. The `<style>` block (lines 33-62) defines the
dark/light `nav-item` idle, hover, and active colors with `:global` theme scoping;
the page restates `.nav-item.active` (index.astro:1482-1491), and the light active
color differs between the two (component `#d62800` vs page `#e53200`).
---
### E.4 Theme system (index.astro inline script)
#### E.4.1 Theme bootstrap and savedTheme (index.astro:1864-1872)
Purpose: applies the persisted (or default) theme as soon as the DOM is ready.
How it works: inside the main `DOMContentLoaded` handler it caches
`html = document.documentElement`, the mobile `#theme-toggle` button, and the
`#theme-label`. It reads `savedTheme = localStorage.getItem("solterra-theme") ||
"dark"`, sets `html.className = savedTheme` (replacing whatever class the markup
shipped with), and calls `updateThemeUI(savedTheme)`.
Gotchas: the storage key is the literal `'solterra-theme'` and the default is
`"dark"`, matching the `class="dark"` already on `<html>` (index.astro:102).
`html.className = savedTheme` wholesale-replaces the class string, which is safe
here only because `<html>` carries no other classes. Because this runs in
`DOMContentLoaded` rather than a render-blocking head script, a light-mode user can
see a brief dark flash before the class is corrected.
#### E.4.2 updateThemeUI(theme) (index.astro:1874-1877)
Signature: `updateThemeUI(theme: string): void`.
Purpose: syncs the MOBILE toggle's text label to the current theme.
How it works: if `themeLabel` exists, it sets its `textContent` to `"Light Mode"`
when `theme === "light"`, otherwise `"Dark Mode"`.
Gotchas: it only touches the label text. The moon/sun icon is swapped purely by CSS
(E.4.6), so this function never manipulates the icons. The comment on line 1875
states exactly that.
#### E.4.3 Mobile theme toggle handler (index.astro:1879-1889)
Purpose: flips the theme when the mobile toggle is clicked.
How it works: it reads the current theme by testing `html.classList.contains("dark")`,
computes `newTheme` as the opposite, removes both `dark` and `light` classes, adds
`newTheme`, persists it with `localStorage.setItem("solterra-theme", newTheme)`, and
calls `updateThemeUI(newTheme)`.
Gotchas: it always removes BOTH classes before adding one, which keeps `<html>` in a
single, well-defined state. Note this handler updates only the mobile label, not the
desktop one; the desktop handler (E.4.5) is the one that updates both. So toggling
from mobile leaves the (hidden) desktop label momentarily stale until the next
desktop toggle - harmless because only one is visible per breakpoint.
#### E.4.4 updateDesktopThemeUI(theme) (index.astro:1992-1995)
Signature: `updateDesktopThemeUI(theme: string): void`.
Purpose: syncs the DESKTOP toggle's text label.
How it works: identical pattern to `updateThemeUI` but targets `themeLabelDesktop`
(`#theme-label-desktop`). It is also called once on init at line 1996 with
`savedTheme`.
Gotchas: a separate function exists because the desktop and mobile toggles are
distinct DOM nodes with distinct label elements; the icon swap is again CSS-only.
#### E.4.5 Desktop theme toggle handler (index.astro:1998-2006)
Purpose: flips the theme from the desktop sidebar control.
How it works: same flip logic as the mobile handler (read current, compute
opposite, swap classes, persist), then calls BOTH `updateThemeUI(newTheme)` and
`updateDesktopThemeUI(newTheme)` so the mobile and desktop labels stay in sync.
Gotchas: this is the more complete of the two handlers - it updates both labels.
The asymmetry with the mobile handler (E.4.3) is intentional but minor, since the
hidden label of the other breakpoint is not visible.
#### E.4.6 CSS-only icon swap (index.astro:1357-1360)
Purpose: shows the correct moon/sun glyph per theme with zero JavaScript.
How it works: the rule `html.dark .icon-sun, html.light .icon-moon { display:
none; }` hides the sun in dark mode and the moon in light mode. Both icons are
always in the DOM inside each toggle (index.astro:173-174, 232-233); only one is
visible at a time based on the `<html>` class.
Gotchas: this is why none of the JS theme functions ever touch the icons - the swap
is declarative. Toggling the theme class instantly flips which icon shows, with no
flicker and no script branch to maintain.
---
### E.5 Mobile menu
#### E.5.1 mobileOpen flag and element refs (index.astro:1892-1894)
Purpose: tracks open/closed state and caches the toggle and menu nodes.
How it works: `mobileToggle = #mobile-menu-toggle`, `mobileMenu = #mobile-menu`,
and `let mobileOpen = false`. The flag is the single source of truth for whether the
dropdown is open.
Gotchas: `mobileOpen` is a plain boolean closure variable, not derived from the DOM.
Every open/close path keeps it in sync, which is what the outside-click and Escape
handlers read.
#### E.5.2 openMobileMenu() (index.astro:1896-1902)
Purpose: opens the dropdown and updates ARIA.
How it works: sets `mobileOpen = true`, removes `opacity-0 scale-95
pointer-events-none` and adds `opacity-100 scale-100` on the menu (driving the
fade+scale-in), adds `is-open` to the toggle (the hamburger morph, E.5.7), and sets
`aria-expanded="true"`.
Gotchas: `pointer-events-none` is removed on open so the menu becomes clickable; in
the closed state it is non-interactive even though it remains in the DOM. The visual
transition is owned by the `.mobile-dropdown` CSS (E.9), not by the class toggle
itself.
#### E.5.3 closeMobileMenu() (index.astro:1903-1909)
Purpose: closes the dropdown and reverses ARIA.
How it works: the exact inverse of open - `mobileOpen = false`, re-adds `opacity-0
scale-95 pointer-events-none`, removes `opacity-100 scale-100`, removes `is-open`
from the toggle, and sets `aria-expanded="false"`.
Gotchas: it is safe to call when already closed (idempotent), which is why the
nav-item click handler can call it unconditionally on every navigation (E.6.5),
including on desktop where it is a no-op.
#### E.5.4 Toggle click handler (index.astro:1911-1914)
Purpose: opens or closes the menu when the hamburger is clicked.
How it works: on click it calls `e.stopPropagation()` then `mobileOpen ?
closeMobileMenu() : openMobileMenu()`.
Gotchas: `e.stopPropagation()` is essential - without it, the same click would
bubble to the document outside-click handler (E.5.5) and immediately close the menu
the toggle just opened. Stopping propagation lets the toggle own its own click.
#### E.5.5 Outside-click handler (index.astro:1917-1921)
Purpose: closes the menu when the user clicks anywhere outside it.
How it works: a document-level click listener checks `if (mobileOpen &&
!mobileMenu?.contains(e.target as Node))` and, if true, calls `closeMobileMenu()`.
Gotchas: it is gated on `mobileOpen` so it does nothing while closed. The
`contains` check excludes clicks inside the menu. Because the toggle calls
`stopPropagation`, toggle clicks never reach here. The `as Node` cast is required by
strict TypeScript.
#### E.5.6 Escape-key handler (index.astro:1924-1926)
Purpose: closes the menu on the Escape key.
How it works: a document `keydown` listener calls `closeMobileMenu()` when
`e.key === "Escape" && mobileOpen`.
Gotchas: also gated on `mobileOpen`, so Escape is a no-op when the menu is already
closed. This is a standard accessibility affordance for dismissible overlays.
#### E.5.7 Hamburger .is-open morph (index.astro:1771-1808)
Purpose: animates the three hamburger lines into an X.
How it works: `.ham-line` (1771-1784) absolutely positions each line, 20px wide and
2px tall, centered via `left: 50%; margin-left: -10px`, with a transition on
`transform`, `opacity`, and `width`. The resting offsets are `.ham-top` at
`calc(50% - 8px)`, `.ham-mid` at `calc(50% - 1px)`, `.ham-bot` at `calc(50% + 6px)`
(1788-1790). When `.is-open` is present, the top line becomes `translateY(7px)
rotate(45deg)`, the middle line `opacity: 0; transform: scaleX(0)`, and the bottom
line `translateY(-7px) rotate(-45deg)` (1793-1802), forming an X. Lines 1805-1808
set the per-theme line colors and the hover color.
Gotchas: the morph is pure CSS keyed off the `is-open` class that openMobileMenu/
closeMobileMenu toggle - the JS never touches individual lines. The exact pixel
offsets are tuned so the rotated top/bottom lines cross at the center.
---
### E.6 Section and navigation switching (index.astro inline script)
#### E.6.1 SECTION_LABELS map (index.astro:1929-1936)
Shape: `Record<string, string>` mapping section ids to human labels (intro ->
"Introduction", logo, typography, components, colors, specs -> "Solterra Brand
Specifications").
Purpose: provides the spoken label used by the screen-reader announcer.
How it works: `activateSection` looks up `SECTION_LABELS[section]` to build the
announcement string.
Gotchas: it includes `specs` even though that is an action-style item, because the
announcer should still describe it. The lookup uses `?? section` as a fallback so an
unknown id still announces something.
#### E.6.2 setActiveNav(section) (index.astro:1941-1954)
Signature: `setActiveNav(section: string): void`.
Purpose: marks the correct nav buttons active and sets `aria-current`.
How it works: it first clears `active` and removes `aria-current` from EVERY
`.nav-item` (cached in `navItems`, line 1939). Then it selects all
`.nav-item[data-section="${section}"]` and, for each, adds `active` and sets
`aria-current="page"`.
Gotchas: it targets ALL matching nav-items, so the desktop sidebar button and the
mobile dropdown button for the same section both light up together - they share the
`data-section` value. This is why the desktop and mobile navs stay in sync without
extra wiring.
#### E.6.3 activateSection(section, announce) (index.astro:1956-1973)
Signature: `activateSection(section: string, announce = true): void`.
Purpose: the master switch - sets the active nav, shows the target section, hides
the rest, and announces the change.
How it works:
1. Calls `setActiveNav(section)`.
2. Adds `hidden` to every `.content-section` (hiding all).
3. Looks up `#section-${section}`; if found, removes `hidden` and adds
`animate-fadeIn` so the section fades/rises in.
4. If `announce` and the announcer exists, sets its `textContent` to
`` `${SECTION_LABELS[section] ?? section} section` ``.
Gotchas: re-adding `animate-fadeIn` on every activation re-triggers the keyframe
because the class is removed-by-hide and re-added on show. The `announce` flag lets
the initial setup skip the announcement (see E.6.6). The target lookup is defensive
(`if (targetSection)`) so an unknown section id simply hides everything.
#### E.6.4 .nav-item click handlers (index.astro:1975-1982)
Purpose: wire every nav button (desktop and mobile) to switch sections.
How it works: it iterates `navItems` and binds a click listener that reads
`data-section`; if absent it returns, otherwise it calls `activateSection(section)`
and then `closeMobileMenu()`.
Gotchas: a single loop covers both navs because both use the `.nav-item` class and
`data-section` attribute. `closeMobileMenu()` is called unconditionally; on desktop
the menu is already closed so it is a harmless no-op, and on mobile it dismisses the
dropdown after the user picks a section.
#### E.6.5 section-announcer live region (index.astro:252)
Purpose: announces section changes to assistive tech.
How it works: `<div id="section-announcer" class="sr-only" role="status"
aria-live="polite">` is a visually hidden live region. `activateSection` writes the
current section label into it, and `aria-live="polite"` makes screen readers
announce the update without interrupting.
Gotchas: `sr-only` keeps it off-screen but in the accessibility tree. `polite`
(not `assertive`) is correct here because a section change is informational, not
urgent.
#### E.6.6 Initial setActiveNav('intro') (index.astro:1986)
Purpose: establishes Introduction as the landing section without announcing.
How it works: after wiring the handlers, the script calls `setActiveNav("intro")`
once. The intro `<section>` already ships visible (`class="content-section
animate-fadeIn"`, index.astro:255) while every other section ships `hidden`, so only
the nav highlight needs syncing at load.
Gotchas: it calls `setActiveNav`, not `activateSection`, precisely so it does NOT
announce on first paint and does NOT re-toggle the already-correct section
visibility - it only sets the active nav highlight and `aria-current`.
---
### E.7 Clear-space solver (index.astro inline script)
The clear-space feature draws a "1x" exclusion zone around the logo, where 1x
equals the visual height of the logo mark. Because the logo is responsive
(`clamp`-based padding, fluid widths), the 1x value cannot be hard-coded; it must be
measured from the rendered image and re-solved on every resize. The functions below
build that solver bottom-up.
#### E.7.1 LOGO_MARK_HEIGHT_RATIO map (index.astro:2007-2009)
Shape: `Record<string, number>` mapping a logo path to a mark-height ratio.
Purpose: a fallback table of "visual mark height / rendered image height" for logos
whose visible mark does not fill the full image box.
How it works: it holds one entry, `"/BW SOLTERRA LOGO (R).png": 448 / 466`, meaning
the visible mark is 448/466 of the rendered image height for that asset.
Gotchas: this is only a fallback. The preferred source is the per-image
`data-mark-ratio` attribute (E.7.3); the map is consulted only when that attribute
is missing or invalid. The monochrome card image carries `data-mark-ratio="0.9613733906"`
inline (index.astro:1012), which is the same ratio expressed as a decimal.
#### E.7.2 getLogoPath(img) (index.astro:2011-2019)
Signature: `getLogoPath(img: HTMLImageElement): string`.
Purpose: resolves a stable path key for an image, used to look up the ratio map.
How it works: it reads the raw `src` attribute; if it starts with `/` it returns it
directly. Otherwise it tries `decodeURIComponent(new URL(img.currentSrc ||
img.src).pathname)` to extract a clean pathname, and on any error falls back to the
raw `src` or `""`.
Gotchas: the early return for `/`-prefixed src avoids URL parsing for the common
case. The `try/catch` guards against malformed URLs. `decodeURIComponent` matters
because the asset filenames contain spaces and parentheses, which appear
percent-encoded in `img.src`.
#### E.7.3 getMarkHeightRatio(img) (index.astro:2021-2025)
Signature: `getMarkHeightRatio(img: HTMLImageElement): number`.
Purpose: returns the mark-height ratio for an image, preferring the inline data
attribute.
How it works: it parses `img.dataset.markRatio`; if that is a finite number > 0 it
is returned. Otherwise it falls back to `LOGO_MARK_HEIGHT_RATIO[getLogoPath(img)]`,
and if that is missing, to `1`.
Gotchas: the precedence is data-attribute -> path map -> `1` (treat the whole image
box as the mark). The `> 0` guard rejects zero/negative/NaN attributes. A ratio of
`1` means no correction, which is what the full-logo images use
(`data-mark-ratio="1"`).
#### E.7.4 getVisualMarkHeight(img) (index.astro:2027-2031)
Signature: `getVisualMarkHeight(img: HTMLImageElement): number`.
Purpose: computes the on-screen height of the visible mark in pixels.
How it works: it reads `img.getBoundingClientRect().height`; if that is `< 1` (image
not laid out yet) it returns `0`, otherwise it returns `renderedHeight *
getMarkHeightRatio(img)`.
Gotchas: the `< 1` guard is the "not ready" sentinel - a freshly inserted or
unloaded image reports near-zero height, and returning `0` lets callers bail out
rather than apply a bogus padding.
#### E.7.5 solveZonePaddingToOneX(zone, img, iterations, tolerancePx) (index.astro:2033-2062)
Signature: `solveZonePaddingToOneX(zone: HTMLElement, img: HTMLImageElement,
iterations = 6, tolerancePx = 0.25): number`.
Purpose: sets a zone's padding equal to 1x (the visual mark height), iterating to
convergence, and returns the final 1x value.
How it works:
1. Loops up to `iterations` (default 6). Each pass computes `oneX =
getVisualMarkHeight(img)`; if `oneX < 1` it returns `0` (not ready).
2. Applies `zone.style.padding = `${oneX}px``.
3. If the previous applied value is finite and `|oneX - lastApplied| <
tolerancePx`, it breaks (converged). Otherwise it records `lastApplied = oneX`
and loops.
4. After the loop it measures once more (`finalOneX`); if that differs from `oneX`
by >= tolerance, it applies and returns `finalOneX`, otherwise returns `oneX`.
Why iteration is needed: setting the zone's padding changes the zone's box, which in
a flex/clamp layout can change the image's rendered height, which changes the next
1x. So padding and 1x are mutually dependent. The loop applies, re-measures, and
repeats until the value stops moving (within 0.25px), reaching a stable fixed point.
The final re-measure catches a last-pass layout shift that the loop's break
condition might otherwise miss.
Gotchas: `lastApplied` starts as `NaN` so the first iteration never short-circuits
(the `Number.isFinite` check fails on NaN). Six iterations is a pragmatic cap;
typical convergence is one to three passes. The 0.25px tolerance prevents infinite
oscillation from sub-pixel rounding.
#### E.7.6 scaleClearSpace() (index.astro:2065-2137)
Signature: `scaleClearSpace(): void`.
Purpose: builds the main 1x diagram - solves the padding, then positions the four
measurement arms (arrows + labels) so each represents exactly 1x.
How it works:
1. Grabs `#clearspace-logo` and `#clearspace-box`; bails if either is missing.
2. Calls `solveZonePaddingToOneX(box, logo)` to get `oneX`; bails if `oneX < 1`.
3. Positions the four measurement containers (`#cs-top/#cs-bottom/#cs-left/
#cs-right`) by setting `top/bottom/left/right = `${oneX}px`` so each sits at the
padding edge (lines 2080-2083).
4. Sizes the four arrows to `oneX` and offsets them so they span the gap: the top
arrow gets `top = `${-oneX}px`` and `height = `${oneX}px``; bottom/left/right
are positioned analogously (lines 2085-2105).
5. Positions the four `1x` labels just outside each arrow with an 8px gap and the
appropriate `transform` to center them (lines 2107-2131).
6. Sets the outer exclusion-zone element's `margin` to `-2px` (lines 2133-2136).
Gotchas: every read of the four arms uses optional chaining and a typed
`querySelector<HTMLElement>`, so a missing arm is skipped rather than throwing. The
math is all relative to `oneX`, so the diagram scales continuously with the logo.
The `-oneX` offsets place arrows in the padding gap that the solver just created.
#### E.7.7 scaleCardClearSpaces() (index.astro:2140-2147)
Signature: `scaleCardClearSpaces(): void`.
Purpose: applies the same 1x padding solve to each of the three logo cards (dark/
light/monochrome).
How it works: it selects every `.logo-clearspace-zone`, finds the
`.logo-clearspace-img` inside, and calls `solveZonePaddingToOneX(zone, img)` for
each. Cards with no image are skipped.
Gotchas: the cards only need the padding solved (their `1x` corner labels are
static markup), so this reuses the solver without the arrow/label positioning of
`scaleClearSpace`. The monochrome card's image uses the corrected ratio via its
`data-mark-ratio`.
#### E.7.8 scheduleClearSpaceScale() (index.astro:2149-2158)
Signature: `scheduleClearSpaceScale(): void`.
Purpose: coalesces many rapid scale requests into one per animation frame.
How it works: a module-scoped `clearSpaceScaleQueued` flag guards a
`requestAnimationFrame`. If a frame is already queued it returns immediately;
otherwise it sets the flag, schedules an rAF that clears the flag and runs
`scaleClearSpace()` then `scaleCardClearSpaces()`.
Gotchas: this is a debounce/throttle. Resize events, ResizeObserver callbacks, and
image loads can all fire in bursts; without coalescing, the solver would run many
times per frame. Running inside rAF also means the solve happens right before paint,
when layout is settled.
#### E.7.9 Clear-space wiring (index.astro:2160-2191)
Purpose: connects the solver to every event that can change the logo's size.
How it works:
- Main logo (2160-2166): if `#clearspace-logo.complete` it schedules immediately,
and it always adds a `load` listener that schedules.
- Card logos (2168-2174): for each `.logo-clearspace-img`, schedule if already
`complete`, and add a `load` listener.
- ResizeObserver (2176-2188): one observer calls `scheduleClearSpaceScale` on any
resize. It observes `#section-logo`, the clearspace box's parent
(`#clearspace-box`'s `parentElement`), and each clearspace zone's parent.
- Window (2190): a `resize` listener also schedules.
- Initial kick (2191): a final `scheduleClearSpaceScale()` runs once so the
diagram is solved on first load even if every image was already cached.
Gotchas: handling both the `complete` (cached) case and the `load` event is
necessary because a cached image may never fire `load`. Observing PARENT elements
(not the zones themselves) avoids a feedback loop - the solver mutates the zone's
own padding, so observing the zone would retrigger itself; observing the parent
reacts only to externally driven size changes. All paths funnel through the rAF
debounce, so the burst of observers at startup collapses to a single solve.
---
### E.8 Brand-spec render and copy
#### E.8.1 brandSpec ?raw import and <pre> render (index.astro:9, 1261-1264)
Purpose: display the exact bytes of brand-spec.md on the page.
How it works: line 9 imports the file as a raw string (`?raw`). The Specs section
renders it with `<pre ... set:text={brandSpec}></pre>` (lines 1261-1264). Astro's
`set:text` directive assigns the string as text content, so markdown is shown
verbatim (not parsed), inside a monospace, scrollable `code-block`.
Gotchas: `set:text` escapes the content, so any `<`, `>`, or `&` in the spec render
literally rather than as HTML - this is what makes the document safe to show as-is.
The `whitespace-pre-wrap break-words` classes preserve the document's own line
breaks and indentation. This is the rendering half of the byte-for-byte guarantee.
#### E.8.2 copySpec() handler via define:vars (index.astro:2200-2237)
Purpose: copy the entire brand spec to the clipboard, byte-for-byte, with a
label swap and a robust fallback.
How it works:
1. The script tag is `<script define:vars={{ brandSpec }}>` (line 2200), which
serializes the SAME `brandSpec` string into the client bundle - the copy uses
the imported string, not the DOM.
2. On `DOMContentLoaded` it grabs `#copy-spec-btn` and `#copy-spec-label`; bails
if either is missing.
3. `copySpec()` (2209-2234) tries `await navigator.clipboard.writeText(brandSpec)`.
4. On failure (catch), it builds an off-screen `<textarea>`, sets its value to
`brandSpec`, marks it readonly, positions it at `left: -9999px`, appends it,
`select()`s it, runs `document.execCommand("copy")` inside a nested try/catch,
then removes the textarea.
5. Either way it sets the label to `"Copied!"`, clears any pending reset timer,
and sets a 2000ms `setTimeout` to restore `"Copy to Clipboard"`.
6. Line 2236 binds `copySpec` to the button's click.
Why it copies the raw string, not the DOM: copying `brandSpec` directly guarantees
the clipboard content is byte-for-byte identical to the source file. Scraping the
`<pre>`'s `textContent` would risk normalization differences (collapsed whitespace,
re-encoded entities, trailing-newline handling) introduced by the browser. Using the
same imported constant for both display and copy removes that entire class of
mismatch.
Gotchas: the `execCommand` fallback exists for non-secure contexts (HTTP) and older
browsers where `navigator.clipboard` is unavailable or throws. The reset timer is
cleared before being reset so rapid clicks do not stack timers and prematurely
revert the label. The label swap happens regardless of whether the copy actually
succeeded - it is optimistic feedback.
---
### E.9 Theming CSS architecture
#### E.9.1 Paired html.dark / html.light rule system (index.astro:1276-1737; component styles)
Purpose: implement two complete themes selected by a single class on `<html>`.
How it works: nearly every themed surface has two rules - `html.dark .x { ... }`
and `html.light .x { ... }` - covering backgrounds, text tiers, borders, cards,
badges, the clearspace diagram, chips, breadcrumbs, and more. The dark block spans
roughly 1276-1383 and the light block 1385-1479, with component-specific pairs
continuing through 1737. Toggling the `<html>` class (E.4) swaps which half of every
pair applies. Components (Button, ColorCard, ProductCard, Navigation) carry their own
`:global(html.dark)` / `:global(html.light)` pairs.
Gotchas: the descendant combinator (`html.dark .card-bg`) means a single class flip
restyles the entire tree at once. Components must use `:global(...)` because Astro
scopes component CSS, but the theme class lives on `<html>` outside the component.
#### E.9.2 Design-token utilities (Tailwind) plus .text-* helpers
Purpose: combine Tailwind's generated brand utilities with hand-written semantic
text classes.
How it works: brand colors are applied via Tailwind utilities (`text-solar-flare`,
`bg-scarlet`, `from-solar-flare`, etc.) generated from tailwind.config.mjs (E.1.2).
Semantic tiers that must change per theme - `.text-heading`, `.text-body`,
`.text-muted`, `.brand-text`, `.accent-text` - are NOT Tailwind utilities; they are
hand-written and themed in paired `html.dark` / `html.light` rules (e.g. 1297-1305,
1408-1416).
Gotchas: the split is deliberate. Fixed brand hues use Tailwind utilities (one value
in both themes); tiers that must shift between themes are custom classes so they can
have two values. Mixing the two is why some color comes from utilities and some from
the `<style>` block.
#### E.9.3 Global transition rule (index.astro:1856-1860)
Selector: `* { transition-property: background-color, border-color, color,
box-shadow; transition-duration: 0.3s; transition-timing-function: ease-in-out; }`.
Purpose: cross-fade every element's themeable color properties when the theme flips.
How it works: it applies a 0.3s ease-in-out transition to exactly four properties on
every element. When the `<html>` class changes, all backgrounds, borders, text
colors, and shadows ease between their dark and light values simultaneously.
Gotchas: it deliberately OMITS `transform`, `opacity`, and `border-radius`. Animating
those globally would make every layout shift and every hidden element animate
awkwardly. Components that need those animated must opt in with their own, more
specific transition declarations (E.9.4).
#### E.9.4 Component rules beating the global rule by specificity (index.astro:1345-1351, 1771-1819; Button.astro:41)
Purpose: let specific components animate properties the global rule excludes
(transform/opacity/border-radius).
How it works: because the global rule uses the `*` selector (specificity 0,0,0), any
class-level rule outranks it for the `transition` shorthand. `.sidebar-ctrl`
(1345-1351) declares its own transition over background/border/color. `.ham-line`
(1771-1784) transitions `transform`, `opacity`, and `width`. `.mobile-dropdown`
(1813-1819) transitions `opacity` and `transform` plus colors. Buttons set
`transition: all 0.2s cubic-bezier(...)` (Button.astro:41 and the per-state blocks).
Each of these wins over `*` and so animates the extra properties.
Gotchas: this is intentional specificity layering, not an accident. The global rule
is the baseline color cross-fade; component rules are surgical overrides for the few
elements that also need motion. Removing a component's own transition would drop it
back to color-only.
#### E.9.5 @keyframes fadeIn and .animate-fadeIn (index.astro:1841-1853)
Purpose: the section-entrance animation.
How it works: `@keyframes fadeIn` goes from `opacity: 0; transform: translateY(10px)`
to `opacity: 1; transform: translateY(0)`. `.animate-fadeIn { animation: fadeIn 0.4s
ease-out forwards; }` runs it once and holds the end state (`forwards`).
Gotchas: `activateSection` (E.6.3) re-adds `animate-fadeIn` each time a section is
shown, which re-runs the keyframe because the class was effectively removed while the
section was `hidden`. `forwards` keeps the section fully visible after the animation
rather than snapping back to the `from` state.
---
### E.10 Responsive layout and section system
#### E.10.1 .content-section show/hide (index.astro:255, 375, 709, 806, 934, 1234)
Purpose: implement the single-page section switching.
How it works: each top-level view is a `<section class="content-section ...">`. The
intro section also has `animate-fadeIn` and ships visible (line 255); the other five
(components 375, colors 709, typography 806, logo 934, specs 1234) ship with
`hidden`. `activateSection` (E.6.3) adds `hidden` to all and removes it from the
target, so exactly one section shows at a time.
Gotchas: `hidden` is Tailwind's `display: none`, so hidden sections are fully removed
from layout (not just transparent). This is why switching sections does not leave
scroll gaps. The intro is the only one without `hidden` so the page has content on
first paint before any JS runs.
#### E.10.2 Desktop sidebar vs mobile header (index.astro:122-123, 190-192)
Purpose: show a fixed sidebar on large screens and a header+dropdown on small ones.
How it works: the mobile header is `class="lg:hidden fixed top-0 ... h-14 ..."`
(lines 122-123) - visible below `lg` and hidden at/above it. The sidebar is
`class="hidden lg:flex fixed inset-y-0 left-0 z-40 w-72 ..."` (lines 190-192) -
hidden below `lg` and a flex column at/above it, fixed full-height at width `w-72`
(18rem / 288px).
Gotchas: the two are mutually exclusive by breakpoint (`lg:hidden` vs `hidden
lg:flex`), so only one navigation surface is ever visible. Both reference the same
`.nav-item[data-section]` mechanism, so switching logic is shared (E.6).
#### E.10.3 Main offset lg:ml-72 (index.astro:250)
Purpose: keep the main content clear of the fixed sidebar on desktop.
How it works: `<main class="... lg:ml-72">` adds a left margin of `18rem` at/above
`lg`, exactly matching the sidebar's `w-72`. Below `lg` there is no margin (the
sidebar is hidden), and the mobile header is `fixed` so the content uses top padding
instead.
Gotchas: `ml-72` must equal the sidebar's `w-72` or the content would either overlap
the sidebar or leave a gap. The pairing of `w-72` + `lg:ml-72` is the whole
desktop layout contract.
#### E.10.4 The lg = 1024px boundary
Purpose: the single breakpoint that flips the entire layout between mobile and
desktop modes.
How it works: every responsive switch in the shell keys off Tailwind's default `lg`
(min-width 1024px): `lg:hidden` (mobile header), `hidden lg:flex` (sidebar),
`lg:ml-72` (main offset), and the dropdown's `lg:hidden`. At >= 1024px the desktop
shell is active; below it the mobile shell is active.
Gotchas: there is one boundary, not a spectrum - the design is effectively two
layouts with a hard switch at 1024px. Knowing this single number lets a rebuild
reproduce the responsive behavior exactly. Content inside sections still uses other
breakpoints (`sm`, `md`) for grids, but the navigation shell pivots only at `lg`.
---
End of Appendix E.