DESIGN.Www
Overview
Section titled “Overview”WarmlyYours WWW is the public-facing marketing and e-commerce site at
warmlyyours.com. It sells radiant heating into a residential audience, so the
visual identity leans warm, premium, and confidence-inspiring — not technical or
corporate. The single non-negotiable brand element is the burgundy primary
(#983333); everything else is interchangeable scaffolding around it.
The system layers Bootstrap 5.3 underneath bespoke SCSS in
client/stylesheets/www/. Bootstrap is the structural skeleton (grid, buttons,
forms, breadcrumbs); brand identity is layered through the variables in
00-variables/ and the components in 03-components/. Treat the tokens here as
the canonical source — when SCSS and this file disagree, the SCSS is drift.
There are two typographic registers:
- Brand pages — sofia-pro (sans) for everything; orpheuspro (serif) only for occasional display moments where warmth needs to be amplified.
- Blog and long-form editorial — Inter for body, Playfair Display for headings. Self-hosted; do not reach for Google Fonts.
Colors
Section titled “Colors”The palette has three layers:
Identity. primary (Rich Burgundy #983333) is the brand. It anchors
buttons, links, headings, and primary surfaces. primary-hover is +10% lightness and is the only sanctioned hover treatment for the primary.
Neutrals. light-shade is the canonical page background — warmer than
pure white, deliberately. Body text is dark-shade. Secondary/muted text
should use gray-700 (the lowest gray that clears WCAG AA 4.5:1 on
light-shade); avoid lighter grays for text. dark-accent is for body-on-body
contrast, not text on light surfaces.
Mood palette. mint-green and warm-cream are page-section moods. They
are not arbitrary accents — pick one to set tone for an extended section, then
let the rest of the section inherit. cream is a translucent overlay tint for
gradients, not a surface color; don’t set it as a section background.
The mood palette is deliberately short. muted-navy-blue, earthy-taupe, and
mint-greener lived here as aspiration and shipped to zero consumers — they
were removed rather than left as tokens nobody honored. Adding a mood means
adding a page-section-* component alongside it, not just a hex.
Warm sub-palette. warm-copper / warm-copper-dark / warm-cream /
warm-cream-light are consumed as CSS custom properties
(var(--warm-copper)), declared in 00-variables/_01-colors.scss and used
from ERB — they are the one part of the palette you won’t find via a $var
grep. warm-copper is a fill color: 3.53:1 on cream, so it fails AA the
moment it becomes text. warm-copper-dark is its text counterpart at 6.46:1.
Cream pairs the same way — warm-cream → warm-cream-light is the sanctioned
180° gradient.
The numeric gray-100 … gray-900 scale exists for borders, dividers, and
disabled states. Reach for it before inventing new hexes. The scale is piped
into Bootstrap’s $grays map in client/stylesheets/bs.scss, which is why
several entries carry no component of their own — they are consumed by
Bootstrap utilities, not by a named component. An unreferenced grey is not a
dead token here.
Typography
Section titled “Typography”Sofia-pro and orpheuspro are the brand pair. Sofia-pro’s regular weight in this
system is 300 (light) — that is intentional and gives the marketing surface
its airy feel. 500 is “medium” (the actual semibold), used for headings and
emphasis. 600 is reserved for buttons and small UI moments where sans-serif
needs to feel solid.
Orpheuspro is a serif tucked in for display moments — hero subheads, pull quotes, occasional editorial sections. Don’t use it for running body copy.
Blog content uses Inter + Playfair Display, self-hosted from
public/fonts/inter/ and public/fonts/playfair-display/. These are
variable fonts; the weight ranges (400 600 for Inter, 400 700 for
Playfair) load once and cover the whole spectrum. Don’t add additional
weight files — use font-weight to interpolate.
font-display: swap is set on every face. Keep it that way — FOIT is worse
than FOUT on a marketing site.
Layout
Section titled “Layout”Bootstrap 5.3 grid with one custom breakpoint added: xxl at 1600px
(default Bootstrap stops at xl: 1200px). Container max-widths likewise extend
to 1540px at xxl. The intent: hero imagery and showcase grids breathe on
large monitors without requiring a per-page max-width override.
Spacing scale follows Bootstrap’s 0–5 ladder (0 / 0.25 / 0.5 / 1 / 1.5 / 3 rem). Levels 6 and 7 (4.5rem, 6rem) are aspirational extensions for
section spacing on marketing landing pages where Bootstrap’s 5 (3rem) is
too tight. Use them for vertical rhythm between full-page sections, not for
inter-component spacing.
A second -em track exists in SCSS (1-em, 2-em, etc.) for components
whose padding should scale with their own font-size. Prefer rem-based spacing
unless you’re authoring a typographic component.
Elevation & Depth
Section titled “Elevation & Depth”Flat by default. The only sanctioned elevation moments:
- Cards lift via background contrast against
light-shade, not shadows. - Modal/overlay uses Bootstrap’s default
box-shadowscale. - Sticky headers use a 1px bottom border in
gray-200, not a shadow.
If you reach for box-shadow on a content surface, reconsider — the brand
voice is calm, not floating.
Shapes
Section titled “Shapes”Subtle, not pill. rounded.md (0.3rem) is the default for buttons, inputs,
selects. rounded.lg (0.5rem) is reserved for promotional cards (warm
palette). Pills are only for badges and tags.
Image thumbnails are explicitly square (rounded.none) — that is a
catalog-imagery decision, not an accident.
Components
Section titled “Components”The button hierarchy is:
- Primary — burgundy, on the warm body color. One per significant CTA moment.
- Secondary — sage (
light-accent) with dark label text. Light text on sage is 3.00:1 and fails WCAG AA;dark-shadeis 4.92:1. For “alternative” or “learn more” alongside a primary. - Link — text-only burgundy. For tertiary or in-flow actions.
Forms use Bootstrap’s standard treatment with the brand layered: white-ish
backgrounds (on-primary), brand burgundy is not the focus ring color —
focus uses Bootstrap’s blue (#86B7FE) at 2px to avoid red-on-red ambiguity
on validation states. This is a deliberate exception to the “primary
everywhere” pattern.
The custom checkbox/radio treatment is unusually large (1.5rem) by spec and
uses success green (not primary burgundy) for the checked state. Keep it.
Breadcrumbs sit on light-shade with subdued type and no underline; they’re
navigational but should never compete with the H1.
Page sections are themed by background (page-section-* tokens). Pick one
mood per section; do not chain three section themes vertically — that breaks
rhythm.
States
Section titled “States”Every interactive element expresses five states. Only hover is brand-themed; the rest are Bootstrap 5.3 defaults on purpose.
- Hover —
primary-hover(#B43E3E) on primary,dark-shadeon links. Hover is a lightness shift, never a new hue. - Focus — Bootstrap’s blue ring (
#86B7FE, 2px). Brand burgundy is deliberately not the focus color. Never remove the ring; use:focus-visibleif a mouse-click ring reads as noise. - Active/pressed — Bootstrap default. The only sanctioned override is the
suppressed
box-shadowon.btn-outline-primary. - Disabled — Bootstrap default (
opacity: .65). Do not author a custom disabled palette. Guard hover rules with:not([disabled], .disabled), as01-base/buttons.scssdoes. - Loading — Turbo owns it (
shared/_turbo.scss). Don’t hand-roll button spinners.
Motion
Section titled “Motion”Motion is functional, never decorative. Transitions use Bootstrap’s
.15s ease-in-out; anything slower feels sluggish on a commerce page.
Smooth scrolling and reveal animations sit behind
@media (prefers-reduced-motion: no-preference) in
02-objects/animations.scss. Every new animation either lives inside that
guard or ships its own prefers-reduced-motion: reduce opt-out.
Iconography
Section titled “Iconography”Font Awesome 7.2 Pro, Sharp only — sharp-solid, sharp-regular, and
brands are the bundles loaded by www.index.scss. Classic, Light, Duotone,
and Thin are not bundled and render as blank boxes.
Emit icons through IconHelper#fa_icon (<%= fa_icon('gear') %>), never a
raw <i class="fa-…"> — the Heatwave/NoInlineFaIcon RuboCop cop enforces
it. Custom marks are inline SVGs from app/assets/images/svgs/custom/. Don’t
introduce a second icon set.
Responsive
Section titled “Responsive”Mobile-first. Bootstrap 5.3 breakpoints with one custom tier
(00-variables/_03-grid.scss):
| Tier | Min-width | Container |
|---|---|---|
sm |
576px | 540px |
md |
768px | 720px |
lg |
992px | 960px |
xl |
1200px | 1140px |
xxl |
1600px | 1540px |
lg (992px) is the desktop boundary — navigation and multi-column layouts
collapse below it, and secondary nav becomes an offcanvas drawer (see
layouts/www/pages/account_portal.html.erb). Design the collapsed state
first; the xxl tier is breathing room for hero and showcase grids, not a
layout the page depends on.
Touch targets are 44 × 44 px minimum on any pointer-coarse surface. Where
the visual control is smaller — the 1.5rem checkbox, for instance — the
label or a wrapping anchor supplies the hit area. Don’t ship a tap target
that is only as big as its icon.
Never hide content at a breakpoint to “fix” a layout. Reflow it, stack it, or
move it behind a disclosure — d-none on real content is a mobile bug.
There is no dark mode. Bootstrap 5.3 ships color modes and we do not use
them; nothing in the codebase sets data-bs-theme or reads
prefers-color-scheme.
Do’s and Don’ts
Section titled “Do’s and Don’ts”Do anchor every page in light-shade background and dark-shade body
text. The warm off-white is the brand’s most recognizable neutral.
Do use gray-700 for muted/secondary text. Anything lighter fails WCAG AA
on the warm background.
Do use named palette colors (mint-green, warm-cream) as section
moods, not as one-off accents inside other sections.
Do lean on Bootstrap utilities (text-primary, bg-light, spacing
classes) before writing new SCSS.
Do emit every icon through fa_icon, in the Sharp family. Raw <i> tags
fail the Heatwave/NoInlineFaIcon cop.
Do design the ≤992px layout first, and give every tap target 44 × 44 px.
Don’t introduce new hex values for greys — the gray-100 … gray-900
scale exists for this. New grey tokens in component SCSS are drift.
Don’t write a raw hex in an ERB style= attribute. If the color is real
it has a token; reach for var(--token) at minimum, a class by preference.
Don’t put text on warm-copper, and don’t use it as the light end of a
gradient that carries text — 3.90:1 with white. It’s a fill. Text goes in
warm-copper-dark (7.13:1 with white).
Don’t use Bootstrap’s deprecated color utilities. All three are gone from this codebase — reintroducing one is drift, and they’re removed in Bootstrap 6:
| Deprecated | Since | Use instead |
|---|---|---|
.text-muted |
5.3.0 | .text-body-secondary |
.text-white-50 |
5.1.0 | .text-white + .text-opacity-50 |
.text-black-50 |
5.1.0 | .text-black + .text-opacity-50 |
.text-body-secondary is the muted-text default on both surfaces. It tracks
--bs-secondary-color (the body color at 75%) rather than a fixed grey, so it
stays correct if the body color moves, and it clears AA everywhere here:
6.38:1 on mint-green, 6.63:1 on warm-cream, 6.45:1 on cream, 6.68:1 on
light-shade.
.text-gray is ours, not Bootstrap’s, and still pins gray-700 in
01-base/typography.scss. Reach for it only when you specifically want that
fixed grey rather than secondary body text.
Don’t use orpheuspro for running body copy. It’s a display face.
Don’t add a third typographic register. Brand vs. blog is the boundary.
Don’t override the form focus ring with brand red. Brand red on a validation-red error state is unreadable.
Don’t stack box shadows. If a surface needs to lift, raise the background contrast or add a 1px border instead.
Don’t style a custom disabled state. Bootstrap’s opacity: .65 is the
convention, and hover rules must exclude [disabled].
Don’t animate outside a prefers-reduced-motion guard.
Don’t add dark mode or data-bs-theme. The surface is light-only.
Don’t use d-none to make a layout fit a small screen. Reflow instead —
hidden content is a mobile bug, not a breakpoint fix.