Skip to main content

surf_parse/
resolve.rs

1//! Shared resolution step — style packs, fonts, and contrast math.
2//!
3//! This module is the single owner of token semantics for BOTH render
4//! targets: `render_html` (websites) consumes these functions for its CSS
5//! emission, and `render_native` projects the same resolved values across
6//! the FFI as a `NativeTheme`. The 0025 style-pack property — "preview
7//! cannot drift from the pack" — holds only because resolution happens
8//! exactly once, here, in Rust. Never reimplement pack→value mapping in
9//! Swift/Kotlin/JS.
10//!
11//! The `--ws-*` token contract (style-pack rendering annex v0):
12//! packs re-set ONLY these tokens; **packs change visuals, never layout**.
13//! Defaults are the "Surf Simple" pack and must stay in lockstep with
14//! `assets/surfdoc.css` `:root` (drift-tested in `render_html`).
15//!
16//! The pack registry here mirrors the production injection tables in
17//! `repos/surf` `frontend/src/sites/container.rs` (`STYLE_PACKS`). The
18//! container remains the web injection point; this module is the canonical
19//! semantic owner for native targets and any future consolidation.
20
21// ═══════════════════════════════════════════════════════════════════════
22// Fonts
23// ═══════════════════════════════════════════════════════════════════════
24
25/// A resolved font preset: CSS font stack + optional Google Fonts import URL.
26pub(crate) struct FontPreset {
27    pub(crate) stack: &'static str,
28    pub(crate) import: Option<&'static str>,
29}
30
31/// Resolve a font preset name to a CSS font stack and optional import.
32pub(crate) fn resolve_font_preset(name: &str) -> Option<FontPreset> {
33    match name.trim().to_lowercase().as_str() {
34        "system" | "sans" => Some(FontPreset {
35            stack: "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Oxygen, sans-serif",
36            import: None,
37        }),
38        "serif" | "editorial" => Some(FontPreset {
39            stack: "Georgia, \"Palatino Linotype\", \"Book Antiqua\", Palatino, serif",
40            import: None,
41        }),
42        "mono" | "monospace" | "technical" => Some(FontPreset {
43            stack: "\"SF Mono\", \"Fira Code\", \"Cascadia Code\", Menlo, Consolas, monospace",
44            import: None,
45        }),
46        "inter" => Some(FontPreset {
47            stack: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
48            import: Some("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"),
49        }),
50        "montserrat" => Some(FontPreset {
51            stack: "'Montserrat', sans-serif",
52            import: Some("https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800&display=swap"),
53        }),
54        "jetbrains-mono" | "jetbrains" => Some(FontPreset {
55            stack: "'JetBrains Mono', monospace",
56            import: Some("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"),
57        }),
58        "playfair" | "playfair-display" => Some(FontPreset {
59            stack: "'Playfair Display', Georgia, serif",
60            import: Some("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&display=swap"),
61        }),
62        // Surf's own display face. No import URL on purpose: the font files
63        // are self-hosted by consumers (@font-face in the host page/app
64        // bundle), so the preset only supplies the stack — with a graceful
65        // system-sans fallback for surfaces that don't ship the files.
66        "surf-display" => Some(FontPreset {
67            stack: "'Surf Display', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
68            import: None,
69        }),
70        "lato" => Some(FontPreset {
71            stack: "'Lato', -apple-system, BlinkMacSystemFont, sans-serif",
72            import: Some("https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap"),
73        }),
74        _ => None,
75    }
76}
77
78// ═══════════════════════════════════════════════════════════════════════
79// Color math (WCAG 2.x)
80// ═══════════════════════════════════════════════════════════════════════
81
82/// Parse a hex color (#RGB, #RRGGBB) into RGB components. `None` when the
83/// string isn't a parseable hex color.
84pub(crate) fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> {
85    let hex = hex.trim().trim_start_matches('#');
86    match hex.len() {
87        3 => Some((
88            u8::from_str_radix(&hex[0..1], 16).ok()? * 17,
89            u8::from_str_radix(&hex[1..2], 16).ok()? * 17,
90            u8::from_str_radix(&hex[2..3], 16).ok()? * 17,
91        )),
92        6 => Some((
93            u8::from_str_radix(&hex[0..2], 16).ok()?,
94            u8::from_str_radix(&hex[2..4], 16).ok()?,
95            u8::from_str_radix(&hex[4..6], 16).ok()?,
96        )),
97        _ => None,
98    }
99}
100
101/// WCAG 2.x relative luminance of an sRGB color.
102pub(crate) fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
103    fn linearize(c: u8) -> f64 {
104        let s = c as f64 / 255.0;
105        if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
106    }
107    0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
108}
109
110/// WCAG 2.x contrast ratio between two hex colors (1.0..=21.0).
111/// Unparseable input yields 0.0 (so "is this AA?" checks fail loudly).
112pub fn contrast_ratio(a: &str, b: &str) -> f64 {
113    let (Some((ar, ag, ab)), Some((br, bg, bb))) = (parse_hex_rgb(a), parse_hex_rgb(b)) else {
114        return 0.0;
115    };
116    let la = relative_luminance(ar, ag, ab);
117    let lb = relative_luminance(br, bg, bb);
118    let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
119    (hi + 0.05) / (lo + 0.05)
120}
121
122/// Parse a hex color (#RGB, #RRGGBB) and return the WCAG-compliant text color.
123/// Returns "#fff" for dark accents, "#1a1a2e" for light accents.
124pub(crate) fn accent_text_color(hex: &str) -> &'static str {
125    let Some((r, g, b)) = parse_hex_rgb(hex) else {
126        return "#fff"; // Can't parse — default to white
127    };
128    let lum = relative_luminance(r, g, b);
129    // Threshold 0.25: ensures minimum ~3.5:1 contrast ratio (WCAG AA for large
130    // text / UI components). Catches greens, yellows, ambers while keeping
131    // standard blues (#3b82f6) and reds (#ef4444) with white text.
132    if lum > 0.25 { "#1a1a2e" } else { "#fff" }
133}
134
135/// The strictest background an accent faces as TEXT per theme: the light
136/// theme's `--surface-alt` (#eef1f7) and the dark theme's `--surface-alt`
137/// (#161616). AA on these implies AA on the lighter/darker page + sheet
138/// surfaces of the same theme.
139const INK_TARGET_LIGHT_BG: &str = "#eef1f7";
140const INK_TARGET_DARK_BG: &str = "#161616";
141
142/// Derive an accent **ink** — the accent adjusted until it reads as normal
143/// TEXT at WCAG AA (≥ 4.5:1) against the theme's surfaces (BR-SITE-A11Y).
144///
145/// `accent_text_color` solves text ON an accent background; this solves the
146/// accent AS text (active nav link, prose links, secondary CTA labels). For
147/// the light theme the accent is darkened in-hue (×0.96 per step toward
148/// black); for the dark theme it is lightened (5% toward white per step).
149/// Accents that already pass are returned unchanged. Unparseable input falls
150/// back to the theme's safe ink.
151pub fn accent_ink_color(hex: &str, dark: bool) -> String {
152    let Some((mut r, mut g, mut b)) = parse_hex_rgb(hex) else {
153        return if dark { "#ffffff".into() } else { "#1a1a2e".into() };
154    };
155    let target = if dark { INK_TARGET_DARK_BG } else { INK_TARGET_LIGHT_BG };
156    let (tr, tg, tb) = parse_hex_rgb(target).expect("ink targets are valid hex");
157    let bg_lum = relative_luminance(tr, tg, tb);
158    for _ in 0..200 {
159        let lum = relative_luminance(r, g, b);
160        let (hi, lo) = if lum >= bg_lum { (lum, bg_lum) } else { (bg_lum, lum) };
161        if (hi + 0.05) / (lo + 0.05) >= 4.5 {
162            break;
163        }
164        if dark {
165            r = (r as f64 + (255.0 - r as f64) * 0.05).round().min(255.0) as u8;
166            g = (g as f64 + (255.0 - g as f64) * 0.05).round().min(255.0) as u8;
167            b = (b as f64 + (255.0 - b as f64) * 0.05).round().min(255.0) as u8;
168        } else {
169            r = (r as f64 * 0.96).round() as u8;
170            g = (g as f64 * 0.96).round() as u8;
171            b = (b as f64 * 0.96).round() as u8;
172        }
173    }
174    format!("#{r:02x}{g:02x}{b:02x}")
175}
176
177// ═══════════════════════════════════════════════════════════════════════
178// Style packs — the --ws-* token contract (annex v0)
179// ═══════════════════════════════════════════════════════════════════════
180
181/// Platform default accent (`--accent` in `assets/surfdoc.css` `:root`).
182pub const DEFAULT_ACCENT: &str = "#2563eb";
183
184/// Default style-pack key. Mirrors `DEFAULT_STYLE_PACK` in the surf
185/// container (0025 DEFAULT).
186pub const DEFAULT_STYLE_PACK: &str = "surf";
187
188/// The resolved `--ws-*` token values for one style pack. Values are the
189/// exact CSS strings the web renderer emits; native targets parse the px
190/// fields into numeric form via `NativeTheme`.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct WsTokens {
193    pub radius_card: &'static str,
194    pub radius_btn: &'static str,
195    pub radius_chip: &'static str,
196    pub radius_img: &'static str,
197    pub border_w: &'static str,
198    pub border_style: &'static str,
199    pub shadow: &'static str,
200    pub shadow_hover: &'static str,
201    pub bg_texture: &'static str,
202    pub hero_bg: &'static str,
203
204    // ── SS-1 additive component tokens (0.9.3) ─────────────────────────
205    // Var names are the de-facto contract set by the cloudsurf-website
206    // app.css `:root` (field name = snake_case of the `--ws-*` var; pinned
207    // by `ss1_additive_token_var_name_contract` below). Defaults are
208    // IDENTITY values: each equals the surfdoc.css `:root` default or the
209    // per-element fallback it mirrors, so a pack that does not re-set a
210    // token renders pixel-identical (drift-tested below).
211    /// Hero action-button corner (`--ws-hero-btn-radius`).
212    pub hero_btn_radius: &'static str,
213    /// `::banner` action-button corner (`--ws-banner-btn-radius`).
214    pub banner_btn_radius: &'static str,
215    /// Standalone CTA button corner (`--ws-cta-radius`).
216    pub cta_radius: &'static str,
217    /// `::form` submit-button corner (`--ws-form-submit-radius`).
218    pub form_submit_radius: &'static str,
219    /// App-control corner (`--ws-control-radius`). Identity value is
220    /// `initial` — the CSS guaranteed-invalid value — because consumers
221    /// carry DIVERGENT per-element fallbacks (forms `var(--radius-sm)`,
222    /// booking/store controls 8px, store badges/qty 6px); `initial` keeps
223    /// every `var(--ws-control-radius, <fallback>)` on its own fallback,
224    /// so injecting the identity pack stays a visual no-op. surfdoc.css
225    /// intentionally declares no `:root` value for it (see its Phase-2
226    /// comment block).
227    pub control_radius: &'static str,
228    /// Feature-card corner (`--ws-feature-card-radius`).
229    pub feature_card_radius: &'static str,
230    /// Feature-card padding (`--ws-feature-card-pad`).
231    pub feature_card_pad: &'static str,
232    /// Feature-card hover lift (`--ws-feature-card-hover-transform`).
233    pub feature_card_hover_transform: &'static str,
234    /// Feature-card surface (`--ws-feature-card-bg`).
235    pub feature_card_bg: &'static str,
236    /// `::product-grid` tile-surface fill (`--ws-tile-surface-bg`).
237    pub tile_surface_bg: &'static str,
238    /// `::post-grid` card surface (`--ws-post-card-bg`).
239    pub post_card_bg: &'static str,
240    /// `::post-grid` card corner (`--ws-post-card-radius`).
241    pub post_card_radius: &'static str,
242    /// `::product-grid` row-card surface (`--ws-pg-card-bg`).
243    pub pg_card_bg: &'static str,
244    /// `::product-grid` row-card corner (`--ws-pg-card-radius`).
245    pub pg_card_radius: &'static str,
246    /// `::product-grid` tile corner (`--ws-pg-tile-radius`; square by spec).
247    pub pg_tile_radius: &'static str,
248    /// `::details` disclosure surface (`--ws-details-bg`).
249    pub details_bg: &'static str,
250    /// `::details` corner (`--ws-details-radius`).
251    pub details_radius: &'static str,
252    /// `::form` input fill (`--ws-form-input-bg`).
253    pub form_input_bg: &'static str,
254    /// Doc-page sheet surface (`--ws-doc-page-bg`).
255    pub doc_page_bg: &'static str,
256    /// Doc-page sheet corner (`--ws-doc-page-radius`).
257    pub doc_page_radius: &'static str,
258    /// Shell drawer link font size (`--ws-drawer-link-size`).
259    pub drawer_link_size: &'static str,
260    /// Shell drawer link font weight (`--ws-drawer-link-weight`).
261    pub drawer_link_weight: &'static str,
262}
263
264/// Surf Simple — the zero-pack default. MUST stay byte-equal to the
265/// `:root` declarations in `assets/surfdoc.css` (drift-tested below and in
266/// `render_html`); injecting nothing on web yields exactly these values.
267pub const SURF_SIMPLE_TOKENS: WsTokens = WsTokens {
268    radius_card: "16px",
269    radius_btn: "10px",
270    radius_chip: "999px",
271    radius_img: "10px",
272    border_w: "1px",
273    border_style: "solid",
274    shadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
275    shadow_hover: "0 10px 30px rgba(15, 23, 42, 0.10)",
276    bg_texture: "none",
277    hero_bg: "radial-gradient(120% 80% at 50% 0%, rgba(255, 255, 255, 0.12), transparent 60%), linear-gradient(160deg, var(--accent), color-mix(in oklab, var(--accent) 68%, #0b1023))",
278    // SS-1 identity defaults — each equals the surfdoc.css `:root` default
279    // or the per-element fallback it mirrors (var() chains resolve against
280    // whatever the pack sets for the base tokens, so they stay correct for
281    // every pack that leaves them untouched).
282    hero_btn_radius: "var(--ws-radius-btn)",
283    banner_btn_radius: "var(--radius-sm)",
284    cta_radius: "var(--ws-radius-btn)",
285    form_submit_radius: "var(--ws-control-radius, var(--radius-sm))",
286    control_radius: "initial", // guaranteed-invalid: per-element fallbacks stay live
287    feature_card_radius: "var(--ws-radius-card)",
288    feature_card_pad: "1.5rem",
289    feature_card_hover_transform: "translateY(-2px)",
290    feature_card_bg: "color-mix(in srgb, var(--surface) 50%, var(--surface-alt) 50%)",
291    tile_surface_bg: "var(--surface)",
292    post_card_bg: "var(--surface)",
293    post_card_radius: "var(--ws-radius-card)",
294    pg_card_bg: "color-mix(in srgb, var(--surface) 72%, transparent)",
295    pg_card_radius: "var(--ws-radius-card-lg, 20px)",
296    pg_tile_radius: "0",
297    details_bg: "var(--surface-alt)",
298    details_radius: "var(--radius-sm)",
299    form_input_bg: "var(--surface)",
300    doc_page_bg: "var(--surface)",
301    doc_page_radius: "var(--ws-radius-card)",
302    drawer_link_size: "0.9375rem",
303    drawer_link_weight: "500",
304};
305
306/// Comic — the dramatic second voice that proves the contract: tight radii,
307/// comic-thick borders, hard-offset panel shadows, flat hero ink, a light
308/// diagonal hatch texture. Values mirror the `comic` pack in the surf
309/// container's `STYLE_PACKS`.
310pub const COMIC_TOKENS: WsTokens = WsTokens {
311    radius_card: "4px",
312    radius_btn: "6px",
313    radius_chip: "6px",
314    radius_img: "4px",
315    border_w: "3px",
316    border_style: "solid",
317    shadow: "4px 4px 0 rgba(15, 23, 42, 0.85)",
318    shadow_hover: "6px 6px 0 rgba(15, 23, 42, 0.85)",
319    bg_texture: "repeating-linear-gradient(45deg, rgba(15, 23, 42, 0.025) 0 2px, transparent 2px 12px)",
320    hero_bg: "var(--accent)",
321    // SS-1 tokens: Comic does not re-set these in the production container
322    // tables, so it carries the identity defaults — the var() chains follow
323    // Comic's own base radii (e.g. hero buttons pick up the 6px btn corner).
324    ..SURF_SIMPLE_TOKENS
325};
326
327/// Resolve a style-pack key to its token set. Unknown keys (DB drift,
328/// forward-compat) fall back to Surf Simple — a stale pack value can never
329/// break a render.
330pub fn resolve_style_pack(key: &str) -> (&'static str, WsTokens) {
331    match key.trim().to_lowercase().as_str() {
332        "comic" => ("comic", COMIC_TOKENS),
333        "surf" | "" => ("surf", SURF_SIMPLE_TOKENS),
334        _ => ("surf", SURF_SIMPLE_TOKENS),
335    }
336}
337
338// ═══════════════════════════════════════════════════════════════════════
339// Resolved theme — what both renderers project
340// ═══════════════════════════════════════════════════════════════════════
341
342/// A document's fully resolved presentation: pack tokens with the pack
343/// applied, accent + derived WCAG colors, and font stacks. Computed once;
344/// `render_html` emits it as CSS variables, `render_native` ships it across
345/// the FFI as `NativeTheme`.
346#[derive(Debug, Clone, PartialEq)]
347pub struct ResolvedTheme {
348    /// The resolved pack key ("surf", "comic", …).
349    pub pack_id: String,
350    /// Brand accent hex.
351    pub accent: String,
352    /// WCAG-compliant text color for content ON an accent background.
353    pub on_accent: String,
354    /// Accent adjusted to read as TEXT at AA on light surfaces.
355    pub accent_ink_light: String,
356    /// Accent adjusted to read as TEXT at AA on dark surfaces.
357    pub accent_ink_dark: String,
358    /// Display/heading font stack (CSS), if a preset resolved.
359    pub font_display: Option<String>,
360    /// Body font stack (CSS), if a preset resolved.
361    pub font_body: Option<String>,
362    /// The pack's `--ws-*` token values.
363    pub tokens: WsTokens,
364}
365
366/// Resolve a theme from site-level inputs. All inputs optional; `None`
367/// yields the platform defaults (Surf Simple pack, `#2563eb` accent,
368/// system fonts).
369pub fn resolve_theme(
370    accent: Option<&str>,
371    font: Option<&str>,
372    style_pack: Option<&str>,
373) -> ResolvedTheme {
374    let accent = accent
375        .map(str::trim)
376        .filter(|a| !a.is_empty())
377        .unwrap_or(DEFAULT_ACCENT)
378        .to_string();
379    let (pack_id, tokens) = resolve_style_pack(style_pack.unwrap_or(DEFAULT_STYLE_PACK));
380    let font_stack = font.and_then(resolve_font_preset).map(|p| p.stack.to_string());
381    ResolvedTheme {
382        on_accent: accent_text_color(&accent).to_string(),
383        accent_ink_light: accent_ink_color(&accent, false),
384        accent_ink_dark: accent_ink_color(&accent, true),
385        accent,
386        font_display: font_stack.clone(),
387        font_body: font_stack,
388        pack_id: pack_id.to_string(),
389        tokens,
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    /// Surf Simple constants must stay in lockstep with surfdoc.css `:root`.
398    /// (render_html's `style_pack_tokens_defined_with_surf_simple_defaults`
399    /// pins the CSS side; this pins the Rust side against the same source.)
400    #[test]
401    fn surf_simple_tokens_match_css_root_defaults() {
402        let css = crate::SURFDOC_CSS;
403        for (token, value) in [
404            ("--ws-radius-card", SURF_SIMPLE_TOKENS.radius_card),
405            ("--ws-radius-btn", SURF_SIMPLE_TOKENS.radius_btn),
406            ("--ws-radius-chip", SURF_SIMPLE_TOKENS.radius_chip),
407            ("--ws-radius-img", SURF_SIMPLE_TOKENS.radius_img),
408            ("--ws-border-w", SURF_SIMPLE_TOKENS.border_w),
409            ("--ws-border-style", SURF_SIMPLE_TOKENS.border_style),
410            ("--ws-shadow-hover", SURF_SIMPLE_TOKENS.shadow_hover),
411            ("--ws-bg-texture", SURF_SIMPLE_TOKENS.bg_texture),
412            ("--ws-hero-bg", SURF_SIMPLE_TOKENS.hero_bg),
413        ] {
414            assert!(
415                css.contains(&format!("{token}: {value};")),
416                "{token} drifted: Rust says `{value}`, surfdoc.css disagrees"
417            );
418        }
419        // Pin --ws-shadow with the full declaration line (its value could be
420        // a substring of other shadow declarations).
421        assert!(css.contains(&format!("--ws-shadow: {};", SURF_SIMPLE_TOKENS.shadow)));
422
423        // SS-1 additive tokens: every identity default must equal the
424        // surfdoc.css `:root` declaration (the DOG-3 no-op pattern).
425        for (token, value) in [
426            ("--ws-hero-btn-radius", SURF_SIMPLE_TOKENS.hero_btn_radius),
427            ("--ws-banner-btn-radius", SURF_SIMPLE_TOKENS.banner_btn_radius),
428            ("--ws-cta-radius", SURF_SIMPLE_TOKENS.cta_radius),
429            ("--ws-form-submit-radius", SURF_SIMPLE_TOKENS.form_submit_radius),
430            ("--ws-feature-card-radius", SURF_SIMPLE_TOKENS.feature_card_radius),
431            ("--ws-feature-card-pad", SURF_SIMPLE_TOKENS.feature_card_pad),
432            (
433                "--ws-feature-card-hover-transform",
434                SURF_SIMPLE_TOKENS.feature_card_hover_transform,
435            ),
436            ("--ws-feature-card-bg", SURF_SIMPLE_TOKENS.feature_card_bg),
437            ("--ws-tile-surface-bg", SURF_SIMPLE_TOKENS.tile_surface_bg),
438            ("--ws-post-card-bg", SURF_SIMPLE_TOKENS.post_card_bg),
439            ("--ws-post-card-radius", SURF_SIMPLE_TOKENS.post_card_radius),
440            ("--ws-pg-card-bg", SURF_SIMPLE_TOKENS.pg_card_bg),
441            ("--ws-pg-card-radius", SURF_SIMPLE_TOKENS.pg_card_radius),
442            ("--ws-pg-tile-radius", SURF_SIMPLE_TOKENS.pg_tile_radius),
443            ("--ws-details-bg", SURF_SIMPLE_TOKENS.details_bg),
444            ("--ws-details-radius", SURF_SIMPLE_TOKENS.details_radius),
445            ("--ws-form-input-bg", SURF_SIMPLE_TOKENS.form_input_bg),
446            ("--ws-doc-page-bg", SURF_SIMPLE_TOKENS.doc_page_bg),
447            ("--ws-doc-page-radius", SURF_SIMPLE_TOKENS.doc_page_radius),
448            ("--ws-drawer-link-size", SURF_SIMPLE_TOKENS.drawer_link_size),
449            ("--ws-drawer-link-weight", SURF_SIMPLE_TOKENS.drawer_link_weight),
450        ] {
451            assert!(
452                css.contains(&format!("{token}: {value};")),
453                "{token} drifted: Rust says `{value}`, surfdoc.css disagrees"
454            );
455        }
456        // --ws-control-radius is intentionally NOT declared in `:root`
457        // (consumers carry divergent per-element fallbacks); the Rust
458        // identity value is the guaranteed-invalid `initial`, which keeps
459        // every fallback live even if injected.
460        assert_eq!(SURF_SIMPLE_TOKENS.control_radius, "initial");
461        assert!(
462            !css.contains("--ws-control-radius:"),
463            "--ws-control-radius must stay fallback-only in surfdoc.css"
464        );
465        assert!(css.contains("var(--ws-control-radius, var(--radius-sm))"));
466        assert!(css.contains("var(--ws-control-radius, 8px)"));
467    }
468
469    /// SS-1: the additive token contract derived from the cloudsurf-website
470    /// app.css `:root` audit (2026-07-18). app.css var names are the de-facto
471    /// contract — this pins the EXACT additive var-name set (audit set-minus
472    /// the pre-existing 10 WsTokens fields; `--ws-radius-img` was in the
473    /// audit but already existed, so it is value-drift, not additive). Any
474    /// rename/add/drop must update this list AND the WsTokens fields.
475    #[test]
476    fn ss1_additive_token_var_name_contract() {
477        let additive = [
478            "--ws-hero-btn-radius",
479            "--ws-banner-btn-radius",
480            "--ws-cta-radius",
481            "--ws-form-submit-radius",
482            "--ws-control-radius",
483            "--ws-feature-card-radius",
484            "--ws-feature-card-pad",
485            "--ws-feature-card-hover-transform",
486            "--ws-feature-card-bg",
487            "--ws-tile-surface-bg",
488            "--ws-post-card-bg",
489            "--ws-post-card-radius",
490            "--ws-pg-card-bg",
491            "--ws-pg-card-radius",
492            "--ws-pg-tile-radius",
493            "--ws-details-bg",
494            "--ws-details-radius",
495            "--ws-form-input-bg",
496            "--ws-doc-page-bg",
497            "--ws-doc-page-radius",
498            "--ws-drawer-link-size",
499            "--ws-drawer-link-weight",
500        ];
501        assert_eq!(additive.len(), 22, "SS-1 contract is exactly 22 additive tokens");
502        let unique: std::collections::BTreeSet<_> = additive.iter().collect();
503        assert_eq!(unique.len(), additive.len(), "duplicate var name in contract");
504        // Every contract var is real: declared in `:root` or consumed via a
505        // per-element fallback in the shipped stylesheet.
506        for name in additive {
507            assert!(
508                crate::SURFDOC_CSS.contains(name),
509                "{name} not present in surfdoc.css — contract/stylesheet drift"
510            );
511        }
512    }
513
514    #[test]
515    fn unknown_pack_falls_back_to_surf() {
516        let (id, tokens) = resolve_style_pack("old-school");
517        assert_eq!(id, "surf");
518        assert_eq!(tokens, SURF_SIMPLE_TOKENS);
519    }
520
521    #[test]
522    fn comic_pack_resolves() {
523        let (id, tokens) = resolve_style_pack("comic");
524        assert_eq!(id, "comic");
525        assert_eq!(tokens.border_w, "3px");
526        assert_eq!(tokens.shadow, "4px 4px 0 rgba(15, 23, 42, 0.85)");
527    }
528
529    #[test]
530    fn default_theme_is_surf_simple_with_platform_accent() {
531        let t = resolve_theme(None, None, None);
532        assert_eq!(t.pack_id, "surf");
533        assert_eq!(t.accent, DEFAULT_ACCENT);
534        assert_eq!(t.tokens, SURF_SIMPLE_TOKENS);
535        assert_eq!(t.on_accent, "#fff");
536        assert!(t.font_display.is_none());
537    }
538
539    /// The AA property the theme ships with: both inks actually pass 4.5:1
540    /// against their target surfaces, for every platform palette accent.
541    #[test]
542    fn resolved_inks_are_aa_for_platform_palettes() {
543        for accent in ["#2563eb", "#0ea5e9", "#10b981", "#f59e0b", "#8b5cf6", "#3b82f6"] {
544            let t = resolve_theme(Some(accent), None, None);
545            assert!(
546                contrast_ratio(&t.accent_ink_light, "#eef1f7") >= 4.5,
547                "{accent} light ink fails AA"
548            );
549            assert!(
550                contrast_ratio(&t.accent_ink_dark, "#161616") >= 4.5,
551                "{accent} dark ink fails AA"
552            );
553        }
554    }
555
556    #[test]
557    fn font_preset_resolves_into_theme() {
558        let t = resolve_theme(None, Some("inter"), None);
559        assert!(t.font_body.as_deref().unwrap().contains("Inter"));
560        // Unknown preset → None (native falls back to system fonts).
561        assert!(resolve_theme(None, Some("nope"), None).font_body.is_none());
562    }
563
564    /// SS-1: `surf-display` resolves to the self-hosted Surf Display face —
565    /// stack present, graceful system fallback, and NO import URL (consumers
566    /// self-host the font files; the crate must not emit a Google Fonts URL).
567    #[test]
568    fn surf_display_font_preset_resolves_without_import() {
569        let p = resolve_font_preset("surf-display").expect("surf-display is a preset");
570        assert!(p.stack.contains("Surf Display"));
571        assert!(p.stack.contains("-apple-system"), "needs a graceful fallback stack");
572        assert!(p.import.is_none(), "surf-display must not emit an import URL");
573        // And it flows into a resolved theme like any other preset.
574        let t = resolve_theme(None, Some("surf-display"), None);
575        assert!(t.font_display.as_deref().unwrap().contains("Surf Display"));
576        assert!(t.font_body.as_deref().unwrap().contains("Surf Display"));
577    }
578}