diff --git a/Cargo.lock b/Cargo.lock index fd47617c9..d05b243dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2252,6 +2252,7 @@ dependencies = [ "microsoft-webui-dev-server", "microsoft-webui-discovery", "microsoft-webui-handler", + "microsoft-webui-parser", "microsoft-webui-tokens", "rayon", "serde", diff --git a/DESIGN.md b/DESIGN.md index b25da55dd..ffec97e4e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -371,7 +371,7 @@ non-streaming output does not acquire streaming markers or state. | `webui` Rust library and CLI | Build/inspect protocols, render requests, and serve development apps. Watch rebuilds replace the served artifact only after a successful build. | | `@microsoft/webui` | Node build API and native-addon runtime; its projection subpath is build-only. Browser framework and router are separate packages. | | C FFI, .NET, Python, WASM | Load a protocol once and expose the same logical rendering, partial, token, and streaming capabilities with host-native ownership and errors. WASM can also build protocols when its parser feature is selected. | -| `webui-press` | Convert Markdown/site templates to WebUI page inputs and share a validated client projection across page builds. | +| `webui-press` | Convert Markdown/site templates to WebUI page inputs, resolve nested site regions and theme-family styles at build time, and share a validated client projection across page builds. | | `webui-desktop` | Run a compiled artifact through a native webview and Rust host, or opt into a native window for an existing loopback HTTP host without rendering its artifact again. | FFI uses opaque handles and explicit ownership; .NET wraps those lifetimes in @@ -385,6 +385,13 @@ Press and desktop are opt-in native command sidecars. The Rust CLI discovers their installed binaries, verifies compatible versions, and launches them directly rather than loading their dependencies into the core CLI. Package installation can use Node, but command dispatch has no JavaScript wrapper. +Press's optional browser appearance layer owns document preferences and native +theme transitions, independently of control presentation. Pre-paint restoration +uses a build-generated, shared same-origin script loaded before stylesheet +links, not a per-response inline script. It and interactive controls consume the same +build-validated family metadata; token maps remain compiled CSS, not browser +application state. Header integrations participate in normal component discovery +and hydration rather than modifying the generated shell after rendering. Desktop browser IPC assets are compiled from their TypeScript source into build output, not retained as generated source files. Release staging includes those compiled bytes in the Rust crate archive, so downstream Cargo builds diff --git a/crates/webui-press/Cargo.toml b/crates/webui-press/Cargo.toml index 0a20584d5..16477b7ed 100644 --- a/crates/webui-press/Cargo.toml +++ b/crates/webui-press/Cargo.toml @@ -23,6 +23,7 @@ path = "src/main.rs" microsoft-webui = { path = "../webui", version = "3.0.4", features = ["cli"] } microsoft-webui-discovery = { path = "../webui-discovery", version = "3.0.4" } microsoft-webui-handler = { path = "../webui-handler", version = "3.0.4" } +microsoft-webui-parser = { path = "../webui-parser", version = "3.0.4" } microsoft-webui-tokens = { path = "../webui-tokens", version = "3.0.4" } # Markdown + syntax highlighting diff --git a/crates/webui-press/src/appearance.rs b/crates/webui-press/src/appearance.rs new file mode 100644 index 000000000..137bffe6d --- /dev/null +++ b/crates/webui-press/src/appearance.rs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Normalize appearance once for SSR, pre-paint restoration, CSS, and validation. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::Path; + +use serde::Serialize; +use serde_json::Value; +use webui_tokens::TokenFile; + +use crate::error::{Error, Result}; +use crate::state::StateDependencies; +use crate::types::{AppearanceMode, AppearanceStorage, DocsConfig, ShowMode}; + +pub(crate) const BOOTSTRAP_SCRIPT: &str = include_str!("../template/appearance-bootstrap.js"); + +#[derive(Serialize)] +pub(crate) struct FamilyMetadata { + pub(crate) id: String, + pub(crate) label: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AppearanceMetadata { + themes: Vec, + default_theme: String, + default_mode: AppearanceMode, + storage: AppearanceStorage, +} + +pub(crate) struct PreparedAppearance { + metadata: AppearanceMetadata, + pub(crate) css: String, + legacy_tokens: Option, + // The compiler only checks token presence, never values. Keep a compact + // validation inventory instead of copying full snapshots into every page. + validation: Option, +} + +impl PreparedAppearance { + pub(crate) fn prepare( + config: &DocsConfig, + config_dir: &Path, + dependencies: &mut StateDependencies, + ) -> Result { + if config.theme.is_some() && config.appearance.is_some() { + return Err(invalid("theme and appearance cannot be combined; move the legacy theme into appearance.themes[].source")); + } + let metadata = normalize(config)?; + let legacy_tokens = config + .theme + .as_deref() + .map(|source| load_source(source, config_dir, dependencies)) + .transpose()?; + let mut sources = Vec::with_capacity(metadata.themes.len()); + if let Some(appearance) = &config.appearance { + for family in &appearance.themes { + let tokens = family + .source + .as_deref() + .map(|source| load_source(source, config_dir, dependencies)) + .transpose()?; + if let Some(tokens) = &tokens { + for mode in ["light", "dark"] { + if !tokens.themes.contains_key(mode) { + return Err(invalid(&format!("family '{}' is missing its '{mode}' map; add themes.{mode} to its source", family.id))); + } + } + } + sources.push(tokens); + } + } + let validation = validation_inventory(&sources, legacy_tokens.as_ref()); + let css = crate::appearance_css::compile( + &metadata.themes, + &sources, + &metadata.default_theme, + config.show, + )?; + Ok(Self { + metadata, + css, + legacy_tokens, + validation, + }) + } + + pub(crate) fn has_multiple_themes(&self) -> bool { + self.metadata.themes.len() > 1 + } + + pub(crate) fn legacy_tokens(&self) -> Option<&TokenFile> { + self.legacy_tokens.as_ref() + } + + pub(crate) fn validation_tokens(&self) -> Option { + self.validation.clone() + } + + /// External styles are linked through render state, so the component + /// compiler cannot discover their declarations itself. Augment only its + /// validation inventory, never the family's emitted token snapshot. + pub(crate) fn add_css_validation_context(&mut self, css: &str) -> Result<()> { + let Some(validation) = self.validation.as_mut() else { + return Ok(()); + }; + let definitions = webui_parser::CssParser::new() + .extract_definitions(css) + .map_err(|error| { + invalid(&format!( + "cannot parse site CSS for token validation: {error}" + )) + })?; + for tokens in validation.themes.values_mut() { + for name in &definitions { + tokens.entry(name.clone()).or_default(); + } + } + Ok(()) + } + + pub(crate) fn state(&self, show: ShowMode) -> Value { + let mode = if show == ShowMode::Content { + AppearanceMode::System + } else { + self.metadata.default_mode + }; + let themes = self + .metadata + .themes + .iter() + .map(|family| { + Self::object([ + ("id", Value::String(family.id.clone())), + ("label", Value::String(family.label.clone())), + ]) + }) + .collect(); + Self::object([ + ("themes", Value::Array(themes)), + ("theme", Value::String(self.metadata.default_theme.clone())), + ( + "selected", + Self::object([ + ("system", Value::Bool(mode == AppearanceMode::System)), + ("light", Value::Bool(mode == AppearanceMode::Light)), + ("dark", Value::Bool(mode == AppearanceMode::Dark)), + ]), + ), + ( + "mode", + Value::String( + match mode { + AppearanceMode::System => "system", + AppearanceMode::Light => "light", + AppearanceMode::Dark => "dark", + } + .into(), + ), + ), + ( + "resolvedMode", + Value::String( + if mode == AppearanceMode::Dark { + "dark" + } else { + "light" + } + .into(), + ), + ), + ]) + } + + fn object(entries: [(&str, Value); N]) -> Value { + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(), + ) + } + + pub(crate) fn head_tags(&self, show: ShowMode, bootstrap_url: &str) -> Result { + if show == ShowMode::Content { + return Ok(String::new()); + } + let json = serde_json::to_string(&self.metadata) + .map_err(|error| invalid(&format!("cannot serialize appearance: {error}")))? + .replace('<', "\\u003c") + .replace('>', "\\u003e") + .replace('&', "\\u0026") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029"); + Ok(format!( + "\n", + html_escape::encode_double_quoted_attribute(bootstrap_url) + )) + } +} + +fn normalize(config: &DocsConfig) -> Result { + let Some(appearance) = &config.appearance else { + return Ok(AppearanceMetadata { + themes: vec![FamilyMetadata { + id: "default".into(), + label: "Default".into(), + }], + default_theme: "default".into(), + default_mode: AppearanceMode::System, + storage: default_storage(config), + }); + }; + let mut ids = HashSet::with_capacity(appearance.themes.len()); + for theme in &appearance.themes { + if !valid_id(&theme.id) || !ids.insert(theme.id.as_str()) { + return Err(invalid(&format!("family ID '{}' is unsafe or duplicated; use unique ASCII letters, digits, hyphens or underscores, starting with a letter", theme.id))); + } + if theme.label.trim().is_empty() { + return Err(invalid(&format!( + "family '{}' needs a nonempty label", + theme.id + ))); + } + } + if !ids.contains(appearance.default_theme.as_str()) { + return Err(invalid("defaultTheme must name a configured family; add that family to themes or correct defaultTheme")); + } + let storage = appearance + .storage + .clone() + .unwrap_or_else(|| default_storage(config)); + if storage.theme.trim().is_empty() + || storage.mode.trim().is_empty() + || storage.theme == storage.mode + { + return Err(invalid( + "storage.theme and storage.mode must be distinct, nonempty keys", + )); + } + Ok(AppearanceMetadata { + themes: appearance + .themes + .iter() + .map(|theme| FamilyMetadata { + id: theme.id.clone(), + label: theme.label.clone(), + }) + .collect(), + default_theme: appearance.default_theme.clone(), + default_mode: appearance.default_mode, + storage, + }) +} + +fn valid_id(id: &str) -> bool { + id.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + && !matches!(id, "__proto__" | "prototype" | "constructor") +} + +fn default_storage(config: &DocsConfig) -> AppearanceStorage { + // localStorage is origin-scoped already; canonical deployment base separates + // sites on the same origin without tying preferences to a build machine. + let base = config.base_path.trim_matches('/'); + let scope = if base.is_empty() { + "webui-press:/".to_string() + } else { + format!("webui-press:/{base}/") + }; + AppearanceStorage { + theme: format!("{scope}:theme"), + mode: format!("{scope}:mode"), + } +} + +fn load_source( + source: &str, + config_dir: &Path, + dependencies: &mut StateDependencies, +) -> Result { + let candidate = config_dir.join(source); + let path = if candidate.exists() || source.starts_with('.') || Path::new(source).is_absolute() { + candidate.canonicalize().unwrap_or(candidate) + } else { + webui_tokens::resolve_theme_path(source, config_dir) + .map_err(|error| invalid(&format!("cannot resolve source '{source}': {error}; install its npm package or use a config-relative file")))? + }; + let content = fs::read_to_string(&path); + dependencies.record_input( + path.clone(), + content.as_ref().ok().map(|value| value.as_bytes()), + ); + let content = content.map_err(|error| { + invalid(&format!( + "cannot read {}: {error}; check the source path and permissions", + path.display() + )) + })?; + webui_tokens::parse_token_content(&content, &path).map_err(|error| { + invalid(&format!( + "cannot load {}: {error}; provide a token JSON object", + path.display() + )) + }) +} + +fn validation_inventory( + sources: &[Option], + legacy: Option<&TokenFile>, +) -> Option { + let mut themes = HashMap::new(); + for (index, source) in sources + .iter() + .filter_map(Option::as_ref) + .chain(legacy) + .enumerate() + { + for (scheme, tokens) in &source.themes { + themes.insert( + format!("{index}:{scheme}"), + tokens + .keys() + .map(|key| (key.clone(), String::new())) + .collect(), + ); + } + } + (!themes.is_empty()).then_some(TokenFile { themes }) +} + +#[cold] +#[inline(never)] +fn invalid(message: &str) -> Error { + Error::Build(format!("Invalid Press appearance: {message}.\nhelp: Configure appearance with a defaultTheme, ordered themes and light/dark token sources; omit source for CSS-only families.")) +} + +#[cfg(test)] +#[path = "appearance_tests.rs"] +mod tests; diff --git a/crates/webui-press/src/appearance_css.rs b/crates/webui-press/src/appearance_css.rs new file mode 100644 index 000000000..238963227 --- /dev/null +++ b/crates/webui-press/src/appearance_css.rs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Deterministic baseline/delta CSS, borrowing complete source token snapshots. + +use std::collections::{BTreeMap, BTreeSet}; + +use webui_tokens::TokenFile; + +use crate::appearance::FamilyMetadata; +use crate::error::{Error, Result}; +use crate::types::ShowMode; + +const EXPLICIT_DARK: &str = + ":where([data-theme-mode=\"dark\"], [data-theme=\"dark\"]:not([data-theme-mode]))"; +const SYSTEM: &str = + ":where([data-theme-mode=\"system\"], :not([data-theme-mode]):not([data-theme]))"; + +type DeclarationGroups = BTreeMap, String>; + +pub(crate) fn compile( + families: &[FamilyMetadata], + sources: &[Option], + default_theme: &str, + show: ShowMode, +) -> Result { + let default = families + .iter() + .position(|family| family.id == default_theme) + .ok_or_else(|| { + Error::Build( + "Cannot compile appearance: defaultTheme must identify a configured family".into(), + ) + })?; + let names: BTreeSet<&str> = sources + .iter() + .filter_map(Option::as_ref) + .flat_map(|source| source.themes.iter()) + .filter(|(scheme, _)| matches!(scheme.as_str(), "light" | "dark")) + .flat_map(|(_, tokens)| tokens.keys().map(String::as_str)) + .collect(); + let active: Vec = (0..families.len()) + .filter(|index| show != ShowMode::Content || *index == default) + .collect(); + let mut css = String::with_capacity(names.len().saturating_mul(48)); + css.push_str(":root {\ncolor-scheme: light;\n"); + // Every source token is emitted, so no transitive closure or cloned token + // maps are needed. var() values remain literal, including cycles/initial. + for name in &names { + declaration(&mut css, name, value(sources, default, "light", name)); + } + css.push_str("}\n"); + + let light = deltas(sources, &names, &active, Some(default)); + write_groups(&mut css, &light, families, default, ""); + drop(light); + let dark = deltas(sources, &names, &active, None); + if show == ShowMode::Content { + css.push_str("@media (prefers-color-scheme: dark) {\n:root {\ncolor-scheme: dark;\n"); + for declarations in dark.values() { + css.push_str(declarations); + } + css.push_str("}\n}\n"); + return Ok(css); + } + css.push_str(":root"); + css.push_str(EXPLICIT_DARK); + css.push_str(" {\ncolor-scheme: dark;\n}\n"); + write_groups(&mut css, &dark, families, default, EXPLICIT_DARK); + css.push_str("@media (prefers-color-scheme: dark) {\n:root"); + css.push_str(SYSTEM); + css.push_str(" {\ncolor-scheme: dark;\n}\n"); + write_groups(&mut css, &dark, families, default, SYSTEM); + css.push_str("}\n"); + Ok(css) +} + +/// Group declarations by the exact set of families where they apply. Common +/// values are emitted once, not repeated in each family's rule. +fn deltas( + sources: &[Option], + names: &BTreeSet<&str>, + active: &[usize], + light_baseline: Option, +) -> DeclarationGroups { + let mut groups = DeclarationGroups::new(); + for name in names { + let mut values: BTreeMap<&str, Vec> = BTreeMap::new(); + for &family in active { + let (scheme, baseline) = + light_baseline.map_or(("dark", family), |index| ("light", index)); + let selected = value(sources, family, scheme, name); + if selected != value(sources, baseline, "light", name) { + values.entry(selected).or_default().push(family); + } + } + for (selected, families) in values { + declaration(groups.entry(families).or_default(), name, selected); + } + } + groups +} + +fn value<'a>(sources: &'a [Option], family: usize, scheme: &str, name: &str) -> &'a str { + sources + .get(family) + .and_then(Option::as_ref) + .and_then(|source| source.themes.get(scheme)) + .and_then(|tokens| tokens.get(name)) + .map_or("initial", String::as_str) +} + +fn declaration(css: &mut String, name: &str, value: &str) { + css.push_str("--"); + css.push_str(name); + css.push_str(": "); + css.push_str(value); + css.push_str(";\n"); +} + +fn write_groups( + css: &mut String, + groups: &DeclarationGroups, + families: &[FamilyMetadata], + default: usize, + mode: &str, +) { + for (indices, declarations) in groups { + for (position, &index) in indices.iter().enumerate() { + if position != 0 { + css.push_str(",\n"); + } + css.push_str(":root:where([data-press-theme=\""); + css.push_str(&families[index].id); + css.push_str("\"]"); + if index == default { + css.push_str(", :not([data-press-theme])"); + } + css.push(')'); + css.push_str(mode); + } + css.push_str(" {\n"); + css.push_str(declarations); + css.push_str("}\n"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::Path; + + #[test] + fn grouped_deltas_reconstruct_every_literal_and_missing_reset() -> Result<()> { + let sources = vec![ + Some(webui_tokens::parse_token_content( + r#"{"themes":{"light":{"common":"same","tone":"light","missing":"4px","initial":"initial","alias":"var(--tone)"},"dark":{"common":"same","tone":"dark","initial":"initial","alias":"var(--tone)"}}}"#, + Path::new("first.json"), + ).map_err(|error| Error::Build(error.to_string()))?), + Some(webui_tokens::parse_token_content( + r#"{"themes":{"light":{"common":"same","tone":"light","initial":"2px","alias":"var(--tone)"},"dark":{"common":"same","tone":"dark","initial":"initial","alias":"var(--tone)"}}}"#, + Path::new("second.json"), + ).map_err(|error| Error::Build(error.to_string()))?), + None, + ]; + let families: Vec<_> = (0..3) + .map(|index| FamilyMetadata { + id: format!("family-{index}"), + label: format!("Family {index}"), + }) + .collect(); + let names = BTreeSet::from(["alias", "common", "initial", "missing", "tone"]); + let active = [0, 1, 2]; + let light = deltas(&sources, &names, &active, Some(0)); + let dark = deltas(&sources, &names, &active, None); + for family in active { + let mut effective: HashMap<&str, &str> = names + .iter() + .map(|&name| (name, value(&sources, 0, "light", name))) + .collect(); + for (scheme, groups) in [("light", &light), ("dark", &dark)] { + for (indices, declarations) in groups { + if indices.contains(&family) { + for declaration in declarations.lines() { + let (name, selected) = declaration + .split_once(": ") + .ok_or_else(|| Error::Build("Invalid declaration".into()))?; + effective.insert( + name.trim_start_matches("--"), + selected.trim_end_matches(';'), + ); + } + } + } + for name in &names { + assert_eq!( + effective.get(name).copied(), + Some(value(&sources, family, scheme, name)), + "family {family}, {scheme}, {name}" + ); + } + } + } + let css = compile(&families, &sources, "family-0", ShowMode::All)?; + assert_eq!(css.matches("--common: same;").count(), 1); + assert_eq!(css.matches("--tone: dark;").count(), 2); + assert_eq!(css.matches("--alias: var(--tone);").count(), 1); + Ok(()) + } +} diff --git a/crates/webui-press/src/appearance_tests.rs b/crates/webui-press/src/appearance_tests.rs new file mode 100644 index 000000000..cde6aa820 --- /dev/null +++ b/crates/webui-press/src/appearance_tests.rs @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use super::*; + +type TestResult = std::result::Result<(), Box>; + +fn config(appearance: &str) -> std::result::Result { + let mut value: Value = serde_json::from_str( + r#"{ + "site": {"title": "Appearance"}, + "basePath": "/docs/", + "contentDir": ".", + "nav": [], + "sidebar": [] + }"#, + )?; + value["appearance"] = serde_json::from_str(appearance)?; + serde_json::from_value(value) +} + +#[test] +fn metadata_is_normalized_safe_and_shared_with_ssr() -> TestResult { + let config = config( + r#"{ + "defaultTheme": "default", + "defaultMode": "dark", + "themes": [ + {"id": "default", "label": ""}, + {"id": "compact", "label": "Compact"} + ], + "storage": {"theme": "mai-design-theme", "mode": "theme"} + }"#, + )?; + let appearance = + PreparedAppearance::prepare(&config, Path::new("."), &mut StateDependencies::default())?; + assert!(appearance.has_multiple_themes()); + let state = appearance.state(ShowMode::All); + assert_eq!(state["theme"], "default"); + assert_eq!(state["mode"], "dark"); + assert_eq!(state["resolvedMode"], "dark"); + assert_eq!(state["themes"][1]["id"], "compact"); + assert!(state.get("tokens").is_none()); + let head = appearance.head_tags(ShowMode::All, "/docs/appearance-init.js")?; + let metadata = head.split("").next().ok_or("missing JSON")?; + assert!(metadata.contains("\\u003c/script\\u003e")); + assert!(metadata.contains("\"theme\":\"mai-design-theme\"")); + assert!(!metadata.contains("")); + assert!(!head.contains(BOOTSTRAP_SCRIPT)); + assert!(appearance + .head_tags(ShowMode::Content, "/docs/appearance-init.js")? + .is_empty()); + assert!(appearance + .head_tags(ShowMode::All, "/docs/\"init.js")? + .contains("/docs/"init.js")); + assert_eq!(appearance.state(ShowMode::Content)["mode"], "system"); + Ok(()) +} + +#[test] +fn invalid_configuration_is_actionable() -> TestResult { + for appearance in [ + r#"{"defaultTheme":"unknown", "themes":[{"id":"a","label":"A"}]}"#, + r#"{"defaultTheme":"a", "themes":[{"id":"a","label":"A"},{"id":"a","label":"Duplicate"}]}"#, + r#"{"defaultTheme":"x\"]", "themes":[{"id":"x\"]","label":"Unsafe"}]}"#, + r#"{"defaultTheme":"a", "themes":[{"id":"a","label":" "}]}"#, + r#"{"defaultTheme":"a", "themes":[{"id":"a","label":"A"}], "storage":{"theme":"same","mode":"same"}}"#, + ] { + let config = config(appearance)?; + let error = + PreparedAppearance::prepare(&config, Path::new("."), &mut StateDependencies::default()) + .err() + .ok_or("expected configuration error")?; + assert!(error.to_string().contains("help:")); + } + let mut config = config(r#"{"defaultTheme":"a", "themes":[{"id":"a","label":"A"}]}"#)?; + config.theme = Some("./tokens.json".into()); + assert!(PreparedAppearance::prepare( + &config, + Path::new("."), + &mut StateDependencies::default() + ) + .is_err()); + Ok(()) +} + +#[test] +fn snapshots_preserve_literals_resets_and_external_input_versions() -> TestResult { + let dir = tempfile::tempdir()?; + let source = dir.path().join("family.json"); + fs::write( + &source, + r##"{"themes":{"light":{"surface":"#fff","spacing":"initial","alias":"var(--surface)"},"dark":{"surface":"#000","spacing":"12px"}}}"##, + )?; + let mut config = config( + r#"{ + "defaultTheme": "official", + "themes": [ + {"id":"official", "label":"Official"}, + {"id":"css", "label":"CSS only"} + ] + }"#, + )?; + config + .appearance + .as_mut() + .ok_or("missing appearance")? + .themes[0] + .source = Some(source.to_string_lossy().into_owned()); + let mut dependencies = StateDependencies::default(); + let appearance = PreparedAppearance::prepare(&config, Path::new("."), &mut dependencies)?; + assert!(appearance.css.contains("--spacing: initial;")); + assert!(appearance.css.contains("--spacing: 12px;")); + assert!(appearance.css.contains("--alias: var(--surface);")); + assert!(appearance.css.contains("--alias: initial;")); + assert!(appearance + .css + .contains(":root:where([data-press-theme=\"css\"])")); + assert!(appearance.css.contains("prefers-color-scheme: dark")); + assert_eq!(dependencies.paths, vec![source.canonicalize()?]); + assert!(dependencies.changed_paths().is_empty()); + let again = + PreparedAppearance::prepare(&config, Path::new("."), &mut StateDependencies::default())?; + assert_eq!(appearance.css, again.css); + let validation = appearance.validation_tokens().ok_or("missing validation")?; + assert!(validation + .themes + .values() + .all(|tokens| tokens.values().all(String::is_empty))); + assert!(webui_tokens::validate_required_tokens(&["surface".into()], &validation).is_ok()); + assert!(webui_tokens::validate_required_tokens(&["alias".into()], &validation).is_err()); + fs::write(&source, r#"{"themes":{"light":{}}}"#)?; + assert_eq!(dependencies.changed_paths(), vec![source.canonicalize()?]); + let error = + PreparedAppearance::prepare(&config, Path::new("."), &mut StateDependencies::default()) + .err() + .ok_or("missing scheme accepted")?; + assert!(error.to_string().contains("missing its 'dark' map")); + Ok(()) +} + +#[test] +fn legacy_and_content_contracts_remain_separate() -> TestResult { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("tokens.json"), + r##"{"themes":{"light":{"color":"#fff"},"dark":{"color":"#000"}}}"##, + )?; + let mut config = config("null")?; + config.theme = Some("./tokens.json".into()); + let legacy = + PreparedAppearance::prepare(&config, dir.path(), &mut StateDependencies::default())?; + assert!(legacy.legacy_tokens().is_some()); + assert!(!legacy.has_multiple_themes()); + assert_eq!(legacy.metadata.storage.mode, "webui-press:/docs/:mode"); + config.theme = None; + config.appearance = serde_json::from_str( + r#"{ + "defaultTheme":"official", "defaultMode":"dark", + "themes":[{"id":"official","label":"Official","source":"./tokens.json"},{"id":"css","label":"CSS"}] + }"#, + )?; + config.show = ShowMode::Content; + let content = + PreparedAppearance::prepare(&config, dir.path(), &mut StateDependencies::default())?; + assert!(!content.css.contains("data-theme")); + assert!(!content.css.contains("data-press-theme")); + assert!(content.css.contains("--color: #000;")); + assert!(content + .head_tags(config.show, "/docs/appearance-init.js")? + .is_empty()); + Ok(()) +} + +#[test] +fn build_emits_shared_artifacts_and_metadata_for_pages_and_404() -> TestResult { + let dir = tempfile::tempdir()?; + let content = dir.path().join("content"); + let template = dir.path().join("template"); + fs::create_dir_all(&content)?; + fs::create_dir_all(&template)?; + fs::write( + content.join("index.md"), + "# Appearance\n\nA complete build.", + )?; + fs::write( + template.join("index.html"), + r#"{{{headTags}}}

{{page.title}}

{{{page.content}}}{{webuiPress.appearance.resolvedMode}}{{appearance}}"#, + )?; + fs::write( + dir.path().join("tokens.json"), + r##"{"themes":{"light":{"surface":"#fff"},"dark":{"surface":"#000"}}}"##, + )?; + fs::write(dir.path().join("custom.css"), ":root { --surface: pink; }")?; + let mut config = config( + r#"{ + "defaultTheme":"official", "defaultMode":"dark", + "themes":[{"id":"official","label":"Official","source":"./tokens.json"}] + }"#, + )?; + config.content_dir = content.to_string_lossy().into_owned(); + config.out_dir = dir.path().join("output").to_string_lossy().into_owned(); + config.public_dir = dir.path().join("no-public").to_string_lossy().into_owned(); + config.css = Some(dir.path().join("custom.css").to_string_lossy().into_owned()); + config.state = Some(serde_json::from_str(r#"{"appearance":"subtle"}"#)?); + crate::build::build_docs(&config, dir.path(), &template)?; + let output = Path::new(&config.out_dir); + let mut bootstrap_file = None; + for entry in fs::read_dir(output)? { + let path = entry?.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("appearance-init-") && name.ends_with(".js")) + { + assert!(bootstrap_file.is_none(), "bootstrap must be shared"); + bootstrap_file = Some(path); + } + } + let bootstrap_file = bootstrap_file.ok_or("missing bootstrap asset")?; + assert_eq!(fs::read_to_string(&bootstrap_file)?, BOOTSTRAP_SCRIPT); + let bootstrap_tag = format!( + "", + bootstrap_file + .file_name() + .and_then(|name| name.to_str()) + .ok_or("invalid asset name")? + ); + for path in ["index.html", "404.html"] { + let html = fs::read_to_string(output.join(path))?; + assert!(html.contains("data-press-theme=\"official\"")); + assert!(html.contains("data-theme-mode=\"dark\"")); + assert!(html.contains("dark")); + assert!(html.contains("subtle")); + let bootstrap = html + .find("id=\"webui-press-appearance\"") + .ok_or("missing bootstrap")?; + let script = html + .find(&bootstrap_tag) + .ok_or("missing bootstrap script")?; + let appearance = html + .find("/docs/appearance.css") + .ok_or("missing appearance CSS")?; + let custom = html.find("/docs/theme.css").ok_or("missing consumer CSS")?; + assert!(bootstrap < script && script < appearance && appearance < custom); + assert!(!html.contains(BOOTSTRAP_SCRIPT)); + assert!(!html.contains("#fff")); + } + + assert!(fs::read_to_string(output.join("appearance.css"))?.contains("--surface: #000;")); + config.show = ShowMode::Content; + crate::build::build_docs(&config, dir.path(), &template)?; + assert!(!bootstrap_file.exists()); + for path in ["index.html", "404.html"] { + let html = fs::read_to_string(output.join(path))?; + assert!(!html.contains("id=\"webui-press-appearance\"")); + assert!(!html.contains("localStorage")); + assert!(html.contains("/docs/appearance.css")); + } + config.state = Some(serde_json::from_str( + r#"{"webuiPress":{"appearance":"conflict"}}"#, + )?); + let error = crate::build::build_docs(&config, dir.path(), &template) + .err() + .ok_or("reserved namespace accepted")? + .to_string(); + assert!( + error.contains("webuiPress") && error.contains("reserved"), + "{error}" + ); + Ok(()) +} + +#[test] +fn sourced_families_use_external_css_validation_context() -> TestResult { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("tokens.json"), + r##"{"themes":{"light":{"surface":"#fff"},"dark":{"surface":"#000"}}}"##, + )?; + let config = config( + r#"{"defaultTheme":"official","themes":[{"id":"official","label":"Official","source":"./tokens.json"}]}"#, + )?; + let mut appearance = + PreparedAppearance::prepare(&config, dir.path(), &mut StateDependencies::default())?; + let required = ["docs-color-bg-alt".into(), "consumer-surface".into()]; + let before = appearance.validation_tokens().ok_or("missing validation")?; + assert!(webui_tokens::validate_required_tokens(&required, &before).is_err()); + appearance.add_css_validation_context(include_str!("../template/docs.css"))?; + appearance.add_css_validation_context(":root { --consumer-surface: var(--surface); }")?; + let validation = appearance.validation_tokens().ok_or("missing validation")?; + webui_tokens::validate_required_tokens(&required, &validation)?; + assert!( + webui_tokens::validate_required_tokens(&["consumer-surfac".into()], &validation).is_err() + ); + assert!(!appearance.css.contains("--docs-color-bg-alt:")); + assert!(!appearance.css.contains("--consumer-surface:")); + assert!(appearance.css.contains("--surface: #fff;")); + Ok(()) +} diff --git a/crates/webui-press/src/build.rs b/crates/webui-press/src/build.rs index c144509bb..fede9979a 100644 --- a/crates/webui-press/src/build.rs +++ b/crates/webui-press/src/build.rs @@ -17,6 +17,7 @@ use webui::BuildOptions; use webui_handler::{Protocol, RenderOptions, WebUIHandler}; use webui_tokens::TokenFile; +use crate::appearance::{PreparedAppearance, BOOTSTRAP_SCRIPT}; use crate::bundler::{ bundle_assets, collect_component_scripts_for_html, discover_component_scripts, extract_bundle_scripts, inject_module_script_tags, inject_script_tag, module_script_tag, @@ -112,20 +113,6 @@ impl BuildCache { } } -fn load_theme(theme: &str, config_dir: &Path) -> Result { - let config_relative = config_dir.join(theme); - let resolved = if config_relative.exists() { - config_relative - .canonicalize() - .map_err(|e| Error::Build(format!("Failed to canonicalize {theme}: {e}")))? - } else { - webui_tokens::resolve_theme_path(theme, config_dir) - .map_err(|e| Error::Build(format!("Failed to resolve theme {theme}: {e}")))? - }; - webui_tokens::load_token_file(&resolved) - .map_err(|e| Error::Build(format!("Failed to load theme {}: {e}", resolved.display()))) -} - fn inject_theme_tokens( state: &mut Value, token_file: &TokenFile, @@ -334,10 +321,8 @@ pub fn build_docs_with_cache( .as_ref() .and_then(|p| fs::read_to_string(p).ok()) .unwrap_or_default(); - let token_file = match &config.theme { - Some(theme) => Some(load_theme(theme, config_dir)?), - None => None, - }; + let mut appearance = PreparedAppearance::prepare(config, config_dir, &mut cache.dependencies)?; + let appearance_bootstrap = format!("appearance-init-{:x}.js", fxhash(BOOTSTRAP_SCRIPT)); print_header(cache, &config.site.title); eprintln!(); @@ -351,6 +336,9 @@ pub fn build_docs_with_cache( // happens after content processing succeeds, so a content failure // can't corrupt the previous valid output. let base_css = template_css(template_dir, config.show)?; + appearance.add_css_validation_context(&base_css)?; + appearance.add_css_validation_context(&custom_css)?; + let token_file = appearance.legacy_tokens(); let has_base_css = !base_css.is_empty(); let base_css_link = if has_base_css { format!("") @@ -364,10 +352,18 @@ pub fn build_docs_with_cache( String::new() }; let head_injection = { - let mut parts: Vec = Vec::with_capacity(2 + config.head.len()); + let mut parts: Vec = Vec::with_capacity(4 + config.head.len()); + let appearance_head = + appearance.head_tags(config.show, &format!("{base_path}{appearance_bootstrap}"))?; + if !appearance_head.is_empty() { + parts.push(appearance_head); + } if !base_css_link.is_empty() { parts.push(base_css_link.clone()); } + parts.push(format!( + "" + )); if !theme_css_link.is_empty() { parts.push(theme_css_link.clone()); } @@ -394,12 +390,19 @@ pub fn build_docs_with_cache( dependencies.paths.append(&mut cache.dependencies.paths); dependencies.paths.sort(); dependencies.paths.dedup(); + } else { + cache.dependencies.retain_current_paths(); } + dependencies.extend(std::mem::take(&mut cache.dependencies)); cache.dependencies = dependencies; // Restore the highlighter for the next rebuild. cache.highlighter = Some(highlighter); let mut pages = content?; + let press_state = json_obj([("appearance", appearance.state(config.show))]); + for page in &mut pages { + page.state["webuiPress"] = press_state.clone(); + } let search_entries = prepare_page_search(&mut pages); // Step 2: Resolve component sources for the per-page builds @@ -429,7 +432,7 @@ pub fn build_docs_with_cache( let regions = RegionSet::load(&config.regions, config_dir, template_html)?; // Validate configured region names even when the selected presentation // makes every shell region inactive. Authored page content is never filtered. - let regions = if config.show == ShowMode::Content { + let mut regions = if config.show == ShowMode::Content { RegionSet::load( &Default::default(), config_dir, @@ -438,6 +441,9 @@ pub fn build_docs_with_cache( } else { regions }; + if !appearance.has_multiple_themes() { + regions.deactivate("header.themePicker"); + } crate::outline::prepare(&mut pages, config, regions.contains(crate::outline::REGION))?; let component_script_index = discover_component_scripts(&component_sources)?; @@ -464,6 +470,12 @@ pub fn build_docs_with_cache( fs::write(site_dir.join("theme.css"), &custom_css) .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?; } + fs::write(site_dir.join("appearance.css"), &appearance.css) + .map_err(|e| Error::Io(format!("Cannot write appearance.css: {e}")))?; + if config.show != ShowMode::Content { + fs::write(site_dir.join(&appearance_bootstrap), BOOTSTRAP_SCRIPT) + .map_err(|e| Error::Io(format!("Cannot write appearance bootstrap: {e}")))?; + } let handler = WebUIHandler::with_plugin(|| { Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new()) @@ -741,7 +753,7 @@ pub fn build_docs_with_cache( entry: "index.html".to_string(), plugin: Some(webui::Plugin::WebUI), components: component_sources.clone(), - theme: token_file.clone(), + theme: appearance.validation_tokens(), projection_manifests: vec![projection_source.clone()], ..BuildOptions::default() }) @@ -776,7 +788,7 @@ pub fn build_docs_with_cache( let layout = region_layout(page).to_string(); regions.apply_state(&layout, &mut page.state, omitted)?; - if let Some(token_file) = token_file.as_ref() { + if let Some(token_file) = token_file { inject_theme_tokens(&mut page.state, token_file, &build_result.protocol.tokens)?; } @@ -853,6 +865,7 @@ pub fn build_docs_with_cache( head_injection: &head_injection, global_state: render_states.global(), }); + not_found_state["webuiPress"] = press_state; regions.apply_state("doc", &mut not_found_state, not_found_omitted)?; let (protected_not_found, not_found_pre_blocks) = protect_pre_blocks(¬_found_content); @@ -875,7 +888,7 @@ pub fn build_docs_with_cache( entry: "index.html".to_string(), plugin: Some(webui::Plugin::WebUI), components: component_sources.clone(), - theme: token_file.clone(), + theme: appearance.validation_tokens(), projection_manifests: vec![projection_source], ..BuildOptions::default() }) @@ -888,7 +901,7 @@ pub fn build_docs_with_cache( nf_build.protocol.module_preloads = hrefs; nf_build.warnings.append(&mut preload_warnings); - if let Some(token_file) = token_file.as_ref() { + if let Some(token_file) = token_file { inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?; } @@ -1541,7 +1554,7 @@ fn restore_pre_blocks(html: &str, blocks: &[String]) -> String { out } -/// Cheap deterministic hash for building unique temp directory names. +/// Cheap deterministic hash for build-time artifact and temporary-file identities. fn fxhash(s: &str) -> u64 { fxhash_bytes(s.as_bytes()) } diff --git a/crates/webui-press/src/bundler.rs b/crates/webui-press/src/bundler.rs index 557201375..e642f4f17 100644 --- a/crates/webui-press/src/bundler.rs +++ b/crates/webui-press/src/bundler.rs @@ -28,10 +28,25 @@ const require = createRequire(pathToFileURL(path.join(config.resolveDir, "__webu const esbuild = require(require.resolve("esbuild")); const projectionUrl = pathToFileURL(config.projectionEntry).href; const { esbuildProjection } = await import(projectionUrl); +// A stable virtual identity keeps esbuild's chunk hashes independent of the +// per-build scratch directory, without introducing shared filesystem writes. +const appearance = { + name: "webui-press-appearance", + setup(build) { + build.onResolve({ filter: /^(@microsoft\/webui-press\/appearance\.js|webui-press:appearance)$/ }, () => ({ + path: "appearance.ts", + namespace: "webui-press", + })); + build.onLoad({ filter: /^appearance\.ts$/, namespace: "webui-press" }, () => ({ + contents: config.appearanceSource, + loader: "ts", + })); + }, +}; try { await esbuild.build({ ...config.build, - plugins: [esbuildProjection({ manifest: config.manifest })], + plugins: [appearance, esbuildProjection({ manifest: config.manifest })], }); } catch (error) { // esbuild's own summary message drops note text, which is where projection @@ -470,6 +485,7 @@ struct EsbuildRunnerConfig { resolve_dir: String, projection_entry: String, manifest: String, + appearance_source: &'static str, build: EsbuildJsOptions, } @@ -1223,9 +1239,20 @@ fn build_aliases(opts: &BundleOptions<'_>) -> Result> { if let Some(cfg) = opts.bundler_config { for (from, to) in &cfg.alias { + if from == "@microsoft/webui-press/appearance.js" { + return Err(Error::Build( + "The Press appearance API alias is reserved. Import \ + @microsoft/webui-press/appearance.js without overriding its alias." + .to_string(), + )); + } aliases.insert(from.clone(), normalized_alias_target(&config_dir, to)); } } + aliases.insert( + "@microsoft/webui-press/appearance.js".to_string(), + "webui-press:appearance".to_string(), + ); Ok(aliases) } @@ -1334,6 +1361,7 @@ fn esbuild_build_config( resolve_dir: path_for_js(&working_dir), projection_entry, manifest: path_for_js(&manifest_path), + appearance_source: include_str!("../template/appearance.ts"), build: EsbuildJsOptions { abs_working_dir: path_for_js(&working_dir), entry_points: entry_files @@ -1997,7 +2025,7 @@ mod tests { } #[test] - fn esbuild_command_resolves_from_node_modules() { + fn esbuild_command_resolves_from_node_modules() -> TestResult { let tmp = std::env::temp_dir().join("webui-press-esbuild-test"); let bin_path = if cfg!(windows) { tmp.join("node_modules/esbuild/bin/esbuild") @@ -2005,8 +2033,12 @@ mod tests { tmp.join("node_modules/.bin/esbuild") }; fs::remove_dir_all(&tmp).ok(); - fs::create_dir_all(bin_path.parent().unwrap()).unwrap(); - fs::write(&bin_path, "").unwrap(); + fs::create_dir_all( + bin_path + .parent() + .ok_or("Missing esbuild parent directory")?, + )?; + fs::write(&bin_path, "")?; let resolved = esbuild_command(&tmp.join("node_modules")); if cfg!(windows) { assert_eq!(resolved.program, PathBuf::from("node")); @@ -2016,6 +2048,7 @@ mod tests { assert!(resolved.prefix_args.is_empty()); } fs::remove_dir_all(&tmp).ok(); + Ok(()) } #[test] diff --git a/crates/webui-press/src/lib.rs b/crates/webui-press/src/lib.rs index 80426c28a..792994075 100644 --- a/crates/webui-press/src/lib.rs +++ b/crates/webui-press/src/lib.rs @@ -6,6 +6,8 @@ //! Builds static HTML documentation sites from markdown content using the //! WebUI template engine and parallel rendering. +mod appearance; +mod appearance_css; pub mod build; mod bundler; pub mod content; diff --git a/crates/webui-press/src/main.rs b/crates/webui-press/src/main.rs index 80abd476d..9d0f68cd0 100644 --- a/crates/webui-press/src/main.rs +++ b/crates/webui-press/src/main.rs @@ -11,6 +11,8 @@ //! webui-press serve --port 4000 # custom port //! ``` +mod appearance; +mod appearance_css; mod build; mod bundler; mod content; diff --git a/crates/webui-press/src/regions.rs b/crates/webui-press/src/regions.rs index a3e535532..f1c69c7e3 100644 --- a/crates/webui-press/src/regions.rs +++ b/crates/webui-press/src/regions.rs @@ -24,11 +24,15 @@ const REGION_STATE_ROOT: &str = "regions"; struct Region { start: usize, end: usize, + content_start: usize, + content_end: usize, + parent: Option, name: String, layout: Option, html: Option, state: Option, script_file: Option, + inactive: bool, } /// Parsed template declarations plus site-owned region content. @@ -43,6 +47,14 @@ impl RegionSet { self.regions.iter().any(|region| region.name == name) } + pub(crate) fn deactivate(&mut self, name: &str) { + for region in &mut self.regions { + if region.name == name { + region.inactive = true; + } + } + } + pub(crate) fn load( configs: &BTreeMap, config_dir: &Path, @@ -77,34 +89,18 @@ impl RegionSet { } pub(crate) fn render(&self, layout: &str, omitted: Option<&str>) -> String { - if self.regions.is_empty() { - return self.template.clone(); - } - - let extra = self - .active_regions(layout, omitted) - .filter_map(|region| region.html.as_ref()) - .map(String::len) - .sum(); - let mut output = String::with_capacity(self.template.len().saturating_add(extra)); - let mut cursor = 0; - for region in &self.regions { - output.push_str(&self.template[cursor..region.start]); - if region_applies(region, layout, omitted) { - if let Some(html) = ®ion.html { - output.push_str(html); - } - } - cursor = region.end; + let fragments = self.html_fragments(layout, omitted); + let mut output = String::with_capacity(fragments.iter().map(|html| html.len()).sum()); + for fragment in fragments { + output.push_str(fragment); } - output.push_str(&self.template[cursor..]); output } pub(crate) fn template_shell(&self) -> String { let mut output = String::with_capacity(self.template.len()); let mut cursor = 0; - for region in &self.regions { + for region in self.regions.iter().filter(|region| region.parent.is_none()) { output.push_str(&self.template[cursor..region.start]); cursor = region.end; } @@ -117,9 +113,42 @@ impl RegionSet { layout: &'a str, omitted: Option<&'a str>, ) -> Vec<&'a str> { - self.active_regions(layout, omitted) - .filter_map(|region| region.html.as_deref()) - .collect() + let mut fragments = Vec::with_capacity(self.regions.len() * 2 + 1); + let mut parents: Vec<&Region> = Vec::new(); + let mut cursor = 0; + for region in &self.regions { + if region.start < cursor { + continue; + } + while let Some(parent) = parents.last() { + if parent.content_end > region.start { + break; + } + push_fragment(&mut fragments, &self.template[cursor..parent.content_end]); + cursor = parent.end; + parents.pop(); + } + push_fragment(&mut fragments, &self.template[cursor..region.start]); + if !region_applies(region, layout, omitted) { + cursor = region.end; + } else if region.html.is_some() { + if let Some(html) = ®ion.html { + push_fragment(&mut fragments, html); + } + cursor = region.end; + } else { + cursor = region.content_start; + if region.content_end < region.end { + parents.push(region); + } + } + } + while let Some(parent) = parents.pop() { + push_fragment(&mut fragments, &self.template[cursor..parent.content_end]); + cursor = parent.end; + } + push_fragment(&mut fragments, &self.template[cursor..]); + fragments } pub(crate) fn script_files<'a>( @@ -137,10 +166,7 @@ impl RegionSet { state: &mut Value, omitted: Option<&str>, ) -> Result<()> { - for region in &self.regions { - if !region_applies(region, layout, omitted) { - continue; - } + for region in self.active_regions(layout, omitted) { if let Some(region_state) = ®ion.state { insert_region_state(state, ®ion.name, region_state.clone())?; } @@ -153,14 +179,29 @@ impl RegionSet { layout: &'a str, omitted: Option<&'a str>, ) -> impl Iterator + 'a { - self.regions - .iter() - .filter(move |region| region_applies(region, layout, omitted)) + let mut skip_until = 0; + self.regions.iter().filter(move |region| { + if region.start < skip_until { + return false; + } + let active = region_applies(region, layout, omitted); + if !active || region.html.is_some() { + skip_until = region.end; + } + active + }) + } +} + +fn push_fragment<'a>(fragments: &mut Vec<&'a str>, html: &'a str) { + if !html.is_empty() { + fragments.push(html); } } fn region_applies(region: &Region, layout: &str, omitted: Option<&str>) -> bool { - omitted != Some(region.name.as_str()) + !region.inactive + && omitted != Some(region.name.as_str()) && region.layout.as_deref().is_none_or(|value| { value == layout || value.split_ascii_whitespace().any(|name| name == layout) }) diff --git a/crates/webui-press/src/regions/parser.rs b/crates/webui-press/src/regions/parser.rs index 9b86cc190..0a8ef5b5f 100644 --- a/crates/webui-press/src/regions/parser.rs +++ b/crates/webui-press/src/regions/parser.rs @@ -13,12 +13,33 @@ const REGION_OPEN: &str = " Result> { - let mut declarations = Vec::new(); + let mut declarations: Vec = Vec::new(); let mut names = HashSet::new(); + let mut parents: Vec = Vec::new(); let mut cursor = 0; - while let Some(offset) = template[cursor..].find(REGION_OPEN) { + while let Some(offset) = template[cursor..].find('<') { let start = cursor + offset; + if template[start..].starts_with("") + .map_or(template.len(), |end| start + 4 + end + 3); + continue; + } + if template[start..].starts_with(REGION_CLOSE) { + let index = parents.pop().ok_or_else(|| { + invalid_declaration("region closing tag has no matching start tag") + })?; + let region = &mut declarations[index]; + region.content_end = start; + region.end = start + REGION_CLOSE.len(); + cursor = region.end; + continue; + } + if !template[start..].starts_with(REGION_OPEN) { + cursor = start + 1; + continue; + } let name_end = start + REGION_OPEN.len(); let Some(next) = template.as_bytes().get(name_end).copied() else { return Err(invalid_declaration("region start tag is incomplete")); @@ -27,10 +48,6 @@ pub(super) fn parse_declarations(template: &str) -> Result> { cursor = name_end; continue; } - if inside_comment(template, start) { - cursor = name_end; - continue; - } let open_end = name_end + template[name_end..] .find('>') @@ -44,48 +61,36 @@ pub(super) fn parse_declarations(template: &str) -> Result> { ))); } - let (end, html) = if self_closing { - (open_end + 1, None) - } else { - let content_start = open_end + 1; - let close_offset = template[content_start..] - .find(REGION_CLOSE) - .ok_or_else(|| { - Error::Build(format!("Template region '{name}' has no closing tag.")) - })?; - let close_start = content_start + close_offset; - if template[content_start..close_start].contains(REGION_OPEN) { - return Err(Error::Build(format!( - "Template region '{name}' contains a nested region; region declarations cannot be nested." - ))); - } - ( - close_start + REGION_CLOSE.len(), - Some(template[content_start..close_start].to_string()), - ) - }; + let end = open_end + 1; + let index = declarations.len(); declarations.push(Region { start, end, + content_start: end, + content_end: end, + parent: parents.last().copied(), name, layout, - html, + html: None, state: None, script_file: None, + inactive: false, }); + if !self_closing { + parents.push(index); + } cursor = end; } + if let Some(index) = parents.last() { + return Err(Error::Build(format!( + "Template region '{}' has no closing tag.", + declarations[*index].name + ))); + } Ok(declarations) } -fn inside_comment(template: &str, offset: usize) -> bool { - let prefix = &template[..offset]; - prefix - .rfind("").is_none_or(|close| open > close)) -} - fn parse_attributes(raw: &str, self_closing: bool) -> Result<(String, Option)> { let mut remaining = raw.trim(); if self_closing { diff --git a/crates/webui-press/src/regions/tests.rs b/crates/webui-press/src/regions/tests.rs index 6f1ad11e5..52a4af0b1 100644 --- a/crates/webui-press/src/regions/tests.rs +++ b/crates/webui-press/src/regions/tests.rs @@ -112,6 +112,12 @@ fn bundled_template_exposes_stable_regions() -> TestResult { names, [ "site.navigation", + "header.beforeNavigation", + "header.beforeActions", + "header.appearance", + "header.themePicker", + "header.modePicker", + "header.afterActions", "site.announcement", "home.hero", "home.afterHero", @@ -137,8 +143,9 @@ fn bundled_template_exposes_stable_regions() -> TestResult { .regions .iter() .find(|region| region.name == "home.hero") - .and_then(|region| region.html.as_deref()) - .is_some_and(|html| html.contains("home-hero"))); + .is_some_and( + |region| template[region.content_start..region.content_end].contains("home-hero") + )); Ok(()) } @@ -228,12 +235,8 @@ fn rejects_invalid_declarations() { "unsupported attribute", ), ( - concat!( - "", - "", - "" - ), - "cannot be nested", + "", + "has no closing tag", ), ]; for (template, expected) in cases { @@ -245,6 +248,99 @@ fn rejects_invalid_declarations() { } } +#[test] +fn nested_fallbacks_share_render_state_and_script_ownership() -> TestResult { + let template = concat!( + "
", + "
" + ); + let configs = BTreeMap::from([( + "picker".to_string(), + RegionConfig { + state: Some(serde_json::from_str(r#"{"label":"Theme"}"#)?), + script_file: Some("picker.ts".to_string()), + ..region("") + }, + )]); + let mut regions = RegionSet::load(&configs, Path::new("."), template.to_string())?; + assert_eq!( + regions.render("doc", None), + "
" + ); + assert_eq!(regions.template_shell(), "
"); + assert_eq!(regions.render("home", None), "
"); + assert_eq!(regions.script_files("home", None).count(), 0); + assert_eq!(regions.script_files("doc", Some("header")).count(), 0); + let mut state = serde_json::json!({}); + regions.apply_state("doc", &mut state, None)?; + assert_eq!(state["regions"]["picker"]["label"], "Theme"); + regions.deactivate("controls"); + assert_eq!(regions.render("doc", None), "
"); + assert_eq!(regions.script_files("doc", None).count(), 0); + let mut state = serde_json::json!({}); + regions.apply_state("doc", &mut state, None)?; + assert!(state.get("regions").is_none()); + Ok(()) +} + +#[test] +fn replaced_parent_suppresses_fallback_descendant_assets() -> TestResult { + let template = concat!( + "before", + "old", + "after", + "Footer" + ); + for replacement in ["", ""] { + let configs = BTreeMap::from([ + ("header".to_string(), region(replacement)), + ( + "picker".to_string(), + RegionConfig { + script_file: Some("inactive.ts".to_string()), + state: Some(serde_json::from_str(r#"{"value":1}"#)?), + ..region("") + }, + ), + ]); + let regions = RegionSet::load(&configs, Path::new("."), template.to_string())?; + assert_eq!(regions.render("doc", None), format!("{replacement}Footer")); + assert_eq!(regions.script_files("doc", None).count(), 0); + assert!(!regions + .html_fragments("doc", None) + .join("") + .contains("inactive")); + let mut state = serde_json::json!({}); + regions.apply_state("doc", &mut state, None)?; + assert!(state.get("regions").is_none()); + } + Ok(()) +} + +#[test] +fn parent_state_only_preserves_nested_defaults_and_adjacent_regions() -> TestResult { + let template = concat!( + "A", + "BC", + "", + "D" + ); + let configs = BTreeMap::from([( + "parent".to_string(), + RegionConfig { + html: None, + state: Some(serde_json::json!({"enabled": true})), + ..region("") + }, + )]); + let regions = RegionSet::load(&configs, Path::new("."), template.to_string())?; + assert_eq!(regions.render("doc", None), "ABCD"); + assert_eq!(regions.render("doc", Some("child")), "ACD"); + Ok(()) +} + #[test] fn ignores_commented_markers() -> TestResult { let template = concat!( diff --git a/crates/webui-press/src/serve.rs b/crates/webui-press/src/serve.rs index 91341b8e4..2d8135ee0 100644 --- a/crates/webui-press/src/serve.rs +++ b/crates/webui-press/src/serve.rs @@ -362,6 +362,44 @@ fn clone_config_via_reparse( mod tests { use super::*; + #[test] + fn watcher_tracks_external_appearance_sources() -> Result<()> { + let project = tempfile::tempdir()?; + let external = tempfile::tempdir()?; + let file = external.path().join("node_modules/theme/tokens.json"); + std::fs::create_dir_all(file.parent().context("theme parent")?)?; + std::fs::write( + &file, + r#"{"themes":{"light":{"size":"1px"},"dark":{"size":"2px"}}}"#, + )?; + let mut config: DocsConfig = serde_json::from_str( + r#"{"site":{"title":"Test"},"basePath":"/","contentDir":"missing","nav":[],"sidebar":[],"appearance":{"defaultTheme":"official","themes":[{"id":"official","label":"Official"}]}}"#, + )?; + config + .appearance + .as_mut() + .context("appearance config")? + .themes[0] + .source = Some(file.to_string_lossy().into_owned()); + let mut dependencies = StateDependencies::default(); + crate::appearance::PreparedAppearance::prepare(&config, project.path(), &mut dependencies)?; + let (tx, rx) = std::sync::mpsc::sync_channel(8); + let _watcher = source_watcher( + &config, + project.path(), + &project.path().join("config.json"), + &dependencies, + tx, + )?; + std::fs::write( + &file, + r#"{"themes":{"light":{"size":"3px"},"dark":{"size":"4px"}}}"#, + )?; + let changed = rx.recv_timeout(Duration::from_secs(3))?; + assert!(changed.contains(&file.canonicalize()?)); + Ok(()) + } + #[test] fn watcher_ignores_project_scratch_but_rebuilds_authored_sources() -> Result<()> { let root = tempfile::tempdir()?; diff --git a/crates/webui-press/src/state.rs b/crates/webui-press/src/state.rs index 6bbe8c6be..e1253fded 100644 --- a/crates/webui-press/src/state.rs +++ b/crates/webui-press/src/state.rs @@ -64,6 +64,7 @@ const RESERVED_STATE_KEYS: &[&str] = &[ "regions", "headTags", "tokens", + "webuiPress", "label", "icon", "__proto__", @@ -148,6 +149,35 @@ pub(crate) struct StateDependencies { } impl StateDependencies { + /// Retain explicit inputs and the exact bytes observed during preparation. + pub(crate) fn record_input(&mut self, path: PathBuf, content: Option<&[u8]>) { + // Match the bounded version probe below: larger explicit inputs still + // receive filesystem events, but cannot trigger an endless race retry + // by comparing a stored hash with the probe's unavailable sentinel. + let version = content + .filter(|bytes| bytes.len() as u64 <= MAX_STATE_BYTES) + .map(|bytes| { + let mut hasher = DefaultHasher::new(); + hasher.write(bytes); + hasher.finish() + }); + if !self.paths.contains(&path) { + self.paths.push(path.clone()); + } + self.versions.insert(path, version); + } + + pub(crate) fn extend(&mut self, other: Self) { + self.paths.extend(other.paths); + self.paths.sort(); + self.paths.dedup(); + self.versions.extend(other.versions); + } + + pub(crate) fn retain_current_paths(&mut self) { + self.paths.retain(|path| self.versions.contains_key(path)); + } + pub(crate) fn begin_build(&mut self) { self.versions.clear(); } @@ -318,6 +348,7 @@ impl StateLoader { } if let Some(value) = inline { + validate_press_namespace(value, label)?; return Ok(Some(value.clone())); } @@ -351,6 +382,7 @@ impl StateLoader { abs.display() )) })?; + validate_press_namespace(&parsed, label)?; self.cache.insert(key, parsed.clone()); Ok(Some(parsed)) } @@ -401,6 +433,15 @@ impl StateLoader { } } +fn validate_press_namespace(value: &Value, label: &str) -> Result<()> { + if value.get("webuiPress").is_some() { + return Err(Error::Build(format!( + "{label}: top-level key 'webuiPress' is reserved by Press.\nhelp: Move application state under an application-specific key." + ))); + } + Ok(()) +} + pub(crate) fn load_render_states(config: &DocsConfig, config_dir: &Path) -> Result { let mut loader = StateLoader::new(); let global = loader.load_global_state(config, config_dir)?; @@ -468,6 +509,7 @@ mod tests { public_dir: "./public".to_string(), css: None, theme: None, + appearance: None, components: None, head: Vec::new(), nav: Vec::new(), diff --git a/crates/webui-press/src/types.rs b/crates/webui-press/src/types.rs index 4611f5deb..dc99136db 100644 --- a/crates/webui-press/src/types.rs +++ b/crates/webui-press/src/types.rs @@ -34,6 +34,8 @@ pub struct DocsConfig { pub public_dir: String, pub css: Option, pub theme: Option, + /// Named theme families and independent color-scheme preferences. + pub appearance: Option, pub components: Option>, #[serde(default)] pub head: Vec, @@ -59,6 +61,56 @@ pub struct DocsConfig { pub bundler: Option, } +/// Build-time appearance configuration. Token sources are never sent to browsers. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AppearanceConfig { + /// Initial family; must identify one of `themes`. + pub default_theme: String, + /// Requested color scheme, independent of the family. + #[serde(default)] + pub default_mode: AppearanceMode, + /// Ordered family choices. + pub themes: Vec, + /// Optional established preference keys. + pub storage: Option, +} + +/// Supported requested color schemes. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AppearanceMode { + /// Follow the operating system. + #[default] + System, + /// Always use light colors. + Light, + /// Always use dark colors. + Dark, +} + +/// A theme family backed by token JSON or consumer-authored CSS. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ThemeFamilyConfig { + /// Stable ASCII identifier used in CSS and stored preferences. + pub id: String, + /// Human-readable picker label. + pub label: String, + /// Config-relative file or npm theme package supplying light/dark maps. + pub source: Option, +} + +/// Storage keys used by the document-owned preference controller. +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct AppearanceStorage { + /// Family preference key. + pub theme: String, + /// Requested mode preference key. + pub mode: String, +} + /// Site-owned content for one compile-time template region. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/webui-press/template/appearance-bootstrap.js b/crates/webui-press/template/appearance-bootstrap.js new file mode 100644 index 000000000..927af4941 --- /dev/null +++ b/crates/webui-press/template/appearance-bootstrap.js @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +(() => { + const config = JSON.parse(document.getElementById("webui-press-appearance").textContent); + const restore = () => { + const savedTheme = localStorage.getItem(config.storage.theme); + const savedMode = localStorage.getItem(config.storage.mode) ?? + localStorage.getItem("theme"); + return { + theme: config.themes.some(family => family.id === savedTheme) ? savedTheme : config.defaultTheme, + mode: ["system", "light", "dark"].includes(savedMode) ? savedMode : config.defaultMode, + }; + }; + const warn = error => { + console.warn("[WebUI Press] Appearance storage is unavailable; changes last for this page session.", error); + }; + let initial = { theme: config.defaultTheme, mode: config.defaultMode }; + try { + initial = restore(); + } catch (error) { + warn(error); + } + const root = document.documentElement; + const media = matchMedia("(prefers-color-scheme: dark)"); + const apply = ({ theme, mode }) => { + root.setAttribute("data-press-theme", theme); + root.setAttribute("data-theme-mode", mode); + root.setAttribute("data-theme", mode === "system" + ? (media.matches ? "dark" : "light") + : mode); + }; + apply(initial); + const followSystem = () => { + if (root.getAttribute("data-theme-mode") === "system") { + root.setAttribute("data-theme", media.matches ? "dark" : "light"); + } + }; + const followStorage = event => { + if (event.key !== null && event.key !== config.storage.theme && + event.key !== config.storage.mode && event.key !== "theme") return; + try { + if (event.storageArea !== null && event.storageArea !== localStorage) return; + apply(restore()); + } catch (error) { + warn(error); + } + }; + media.addEventListener("change", followSystem); + window.addEventListener("storage", followStorage); + // Custom headers may omit all controls. Keep preferences live until the API adopts them. + document[Symbol.for("@microsoft/webui-press/appearance-bootstrap")] = { + media, + stop: () => { + media.removeEventListener("change", followSystem); + window.removeEventListener("storage", followStorage); + }, + }; +})(); diff --git a/crates/webui-press/template/appearance.ts b/crates/webui-press/template/appearance.ts new file mode 100644 index 000000000..92fb53980 --- /dev/null +++ b/crates/webui-press/template/appearance.ts @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Requested appearance, independent of the selected token family. */ +export type AppearanceMode = "system" | "light" | "dark"; +/** A configured theme family; token values remain in build-generated CSS. */ +export interface ThemeFamily { + readonly id: string; + readonly label: string; +} +/** The last applied document appearance. */ +export interface Appearance { + readonly theme: string; + readonly mode: AppearanceMode; + readonly resolvedMode: "light" | "dark"; +} +/** A partial user selection. Invalid choices reject without changing state. */ +export interface AppearanceUpdate { + theme?: string; + mode?: AppearanceMode; +} +export type AppearanceListener = (appearance: Appearance) => void; + +interface Configuration { + themes: ThemeFamily[]; + defaultTheme: string; + defaultMode: AppearanceMode; + storage: { theme: string; mode: string }; +} +interface Owner { + get(): Appearance; + families: readonly ThemeFamily[]; + set(update: AppearanceUpdate): Promise; + subscribe(listener: AppearanceListener): () => void; +} + +// Separate bundles (including a consumer's independently bundled npm import) +// share the document owner, not a second set of preferences or event listeners. +const ownerKey = Symbol.for("@microsoft/webui-press/appearance"); +const bootstrapKey = Symbol.for("@microsoft/webui-press/appearance-bootstrap"); +function owner(): Owner { + const host = document as Document & { [ownerKey]?: Owner }; + return host[ownerKey] ??= createOwner(); +} + +function isMode(value: unknown): value is AppearanceMode { + return value === "system" || value === "light" || value === "dark"; +} + +function createOwner(): Owner { + const node = document.getElementById("webui-press-appearance"); + if (!node) throw new Error("WebUI Press appearance requires a full-layout appearance configuration."); + const config: Configuration = JSON.parse(node.textContent ?? ""); + const families = Object.freeze(config.themes.map(family => Object.freeze({ ...family }))); + const validTheme = (value: unknown): value is string => + families.some(family => family.id === value); + const root = document.documentElement; + const theme = root.getAttribute("data-press-theme"); + const mode = root.getAttribute("data-theme-mode"); + const resolvedMode = root.getAttribute("data-theme"); + if (!validTheme(theme) || !isMode(mode) || + (resolvedMode !== "light" && resolvedMode !== "dark")) { + throw new Error("WebUI Press appearance initialization did not run. Deploy the generated appearance-init script, allow it in your CSP, and load it before the browser API."); + } + let state: Appearance = Object.freeze({ theme, mode, resolvedMode }); + let desired = { theme, mode }; + const host = document as Document & { + [bootstrapKey]?: { media: MediaQueryList; stop(): void }; + }; + const bootstrap = host[bootstrapKey]; + const media = bootstrap?.media ?? matchMedia("(prefers-color-scheme: dark)"); + bootstrap?.stop(); + delete host[bootstrapKey]; + const listeners = new Set(); + let active: ViewTransition | undefined; + let pending: Promise | undefined; + let storageWarning = false; + + function warnStorage(error: unknown): void { + if (storageWarning) return; + storageWarning = true; + console.warn("[WebUI Press] Appearance storage is unavailable; changes last for this page session.", error); + } + + function apply(persist: boolean): void { + const next: Appearance = Object.freeze({ + ...desired, + resolvedMode: desired.mode === "system" ? (media.matches ? "dark" : "light") : desired.mode, + }); + if (persist) { + try { + localStorage.setItem(config.storage.theme, next.theme); + localStorage.setItem(config.storage.mode, next.mode); + } catch (error) { + warnStorage(error); + } + } + if (next.theme === state.theme && next.mode === state.mode && + next.resolvedMode === state.resolvedMode) return; + root.setAttribute("data-press-theme", next.theme); + root.setAttribute("data-theme-mode", next.mode); + root.setAttribute("data-theme", next.resolvedMode); + state = next; + for (const listener of listeners) { + // One faulty consumer must not prevent other controls from synchronizing. + try { listener(state); } catch (error) { reportError(error); } + } + } + + function changed(): boolean { + return desired.theme !== state.theme || desired.mode !== state.mode; + } + + async function animate(): Promise { + try { + while (changed()) { + const previous = root.getAttribute("data-press-transition"); + root.setAttribute("data-press-transition", "appearance"); + try { + active = document.startViewTransition(() => apply(true)); + // Attach to all promises immediately. Only ready may reject normally + // when skipped; update/finished failures must reach the caller. + const outcomes = await Promise.allSettled([ + active.ready, active.updateCallbackDone, active.finished, + ]); + for (const [index, outcome] of outcomes.entries()) { + if (outcome.status !== "rejected") continue; + const error: unknown = outcome.reason; + if (index === 0 && error instanceof DOMException && + (error.name === "AbortError" || error.name === "InvalidStateError")) continue; + throw error; + } + } finally { + active = undefined; + if (root.getAttribute("data-press-transition") === "appearance") { + if (previous === null) root.removeAttribute("data-press-transition"); + else root.setAttribute("data-press-transition", previous); + } + } + } + } catch (error) { + // Rendering/transition failures must not discard the latest selection. + apply(true); + throw error; + } finally { + pending = undefined; + } + } + + media.addEventListener("change", () => { + if (desired.mode === "system") { + active?.skipTransition(); + apply(false); + } + }); + window.addEventListener("storage", event => { + if (event.key !== null && event.key !== config.storage.theme && + event.key !== config.storage.mode && event.key !== "theme") return; + try { + if (event.storageArea !== null && event.storageArea !== localStorage) return; + const savedTheme = localStorage.getItem(config.storage.theme); + const savedMode = localStorage.getItem(config.storage.mode) ?? localStorage.getItem("theme"); + desired = { + theme: validTheme(savedTheme) ? savedTheme : config.defaultTheme, + mode: isMode(savedMode) ? savedMode : config.defaultMode, + }; + } catch (error) { + warnStorage(error); + return; + } + active?.skipTransition(); + apply(false); + }); + apply(false); + + return { + get: () => state, + families, + set(update) { + if (!update || typeof update !== "object" || + (update.theme !== undefined && !validTheme(update.theme)) || + (update.mode !== undefined && !isMode(update.mode))) { + return Promise.reject(new TypeError("Choose a configured theme and system, light, or dark appearance.")); + } + desired = { theme: update.theme ?? desired.theme, mode: update.mode ?? desired.mode }; + if (pending) { + active?.skipTransition(); + return pending; + } + if (!changed()) return Promise.resolve(); + if (!document.startViewTransition || + matchMedia("(prefers-reduced-motion: reduce)").matches || + matchMedia("(forced-colors: active)").matches) { + apply(true); + return Promise.resolve(); + } + // Deferral also coalesces synchronous inputs before allocating a transition. + pending = Promise.resolve().then(animate); + return pending; + }, + subscribe(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + }; +} + +/** Read the synchronous, applied state (not an animation's pending selection). */ +export function getAppearance(): Appearance { return owner().get(); } +/** Read ordered, immutable family metadata. */ +export function getThemeFamilies(): readonly ThemeFamily[] { return owner().families; } +/** Apply a validated selection, progressively enhanced with a native transition. */ +export async function setAppearance(update: AppearanceUpdate): Promise { return owner().set(update); } +/** Observe applied changes. This does not immediately invoke the listener. */ +export function subscribeAppearance(listener: AppearanceListener): () => void { + return owner().subscribe(listener); +} diff --git a/crates/webui-press/template/content.html b/crates/webui-press/template/content.html index fe64930b0..7bf6c1ab9 100644 --- a/crates/webui-press/template/content.html +++ b/crates/webui-press/template/content.html @@ -1,5 +1,5 @@ - + diff --git a/crates/webui-press/template/docs-appearance/docs-appearance.css b/crates/webui-press/template/docs-appearance/docs-appearance.css new file mode 100644 index 000000000..487cd4bec --- /dev/null +++ b/crates/webui-press/template/docs-appearance/docs-appearance.css @@ -0,0 +1,54 @@ +:host { display: inline-flex; min-width: 0; } +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--docs-appearance-control-height, var(--docs-header-control-height, 36px)); + height: var(--docs-appearance-control-height, var(--docs-header-control-height, 36px)); + padding: 7px; + color: var(--docs-appearance-color, var(--docs-color-text, CanvasText)); + background: var(--docs-appearance-background, var(--docs-color-bg, Canvas)); + border: 1px solid var(--docs-appearance-border-color, var(--docs-color-border, ButtonBorder)); + border-radius: var(--docs-appearance-radius, var(--docs-radius-m, 6px)); + cursor: pointer; + anchor-name: --press-appearance-trigger; +} +.trigger[hidden] { display: none; } +.trigger:hover:enabled { background: var(--docs-color-bg-alt); } +.trigger:focus-visible { outline: 2px solid var(--docs-color-brand); outline-offset: 2px; } +.trigger:disabled { cursor: default; opacity: 0.65; } +.trigger svg { width: 20px; height: 20px; } +.panel { + box-sizing: border-box; + width: 296px; + max-width: calc(100vw - 32px); + padding: var(--docs-space-m); + font: inherit; + color: var(--docs-appearance-color, var(--docs-color-text, CanvasText)); + background: var(--docs-appearance-background, var(--docs-color-bg, Canvas)); + border: 1px solid var(--docs-appearance-border-color, var(--docs-color-border, ButtonBorder)); + border-radius: var(--docs-radius-l); +} +.panel[popover] { + position: fixed; + inset: calc(var(--docs-nav-height) + 8px) 24px auto auto; + margin: 0; + box-shadow: var(--docs-shadow-l); +} +.panel:popover-open { display: grid; gap: var(--docs-space-m); } +.panel:not([popover]) { display: grid; gap: var(--docs-space-m); } +h2 { margin: 0; font-size: var(--docs-font-size-m); font-weight: var(--docs-font-weight-semibold); } +::slotted(*) { width: 100%; } +@supports (position-area: bottom span-left) { + .panel[popover] { + position-anchor: --press-appearance-trigger; + position-area: bottom span-left; + position-try-fallbacks: flip-inline, flip-block; + inset: auto; + margin-block-start: 8px; + } +} +@media (max-width: 1024px) { + :host { width: 100%; } + .panel:not([popover]) { width: 100%; max-width: none; padding: 0; border: 0; box-shadow: none; } +} diff --git a/crates/webui-press/template/docs-appearance/docs-appearance.html b/crates/webui-press/template/docs-appearance/docs-appearance.html new file mode 100644 index 000000000..9f0be951c --- /dev/null +++ b/crates/webui-press/template/docs-appearance/docs-appearance.html @@ -0,0 +1,22 @@ + +
+

Appearance

+ +
diff --git a/crates/webui-press/template/docs-appearance/docs-appearance.ts b/crates/webui-press/template/docs-appearance/docs-appearance.ts new file mode 100644 index 000000000..14e6a855b --- /dev/null +++ b/crates/webui-press/template/docs-appearance/docs-appearance.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebUIElement, observable } from "@microsoft/webui-framework"; + +export class DocsAppearance extends WebUIElement { + @observable ready = false; + @observable mobile = false; + panel!: HTMLDivElement; + trigger!: HTMLButtonElement; + private media?: MediaQueryList; + private resize = (): void => { + const supported = typeof this.panel.showPopover === "function"; + if (supported && this.panel.matches(":popover-open")) this.panel.hidePopover(); + this.mobile = !supported || this.media?.matches === true; + }; + + override connectedCallback(): void { + super.connectedCallback(); + if (this.ready) this.follow(); + } + + protected override hydratedCallback(): void { + this.follow(); + this.ready = true; + } + + private follow(): void { + this.media?.removeEventListener("change", this.resize); + this.media = matchMedia("(max-width: 1024px)"); + this.resize(); + this.media.addEventListener("change", this.resize); + } + + override disconnectedCallback(): void { + this.media?.removeEventListener("change", this.resize); + super.disconnectedCallback(); + } + + onToggle(event: ToggleEvent): void { + if (event.newState !== "closed" || this.mobile) return; + let active = this.ownerDocument.activeElement; + while (active) { + // Native restoration does not cover focused controls in slotted shadow roots. + if (this.contains(active) || this.shadowRoot?.contains(active)) { + this.trigger.focus({ preventScroll: true }); + return; + } + active = active.shadowRoot?.activeElement ?? null; + } + } +} + +DocsAppearance.define("docs-appearance"); diff --git a/crates/webui-press/template/docs-site-navigation/docs-site-navigation.css b/crates/webui-press/template/docs-site-navigation/docs-site-navigation.css index b39b83ed6..e6f4b2ed6 100644 --- a/crates/webui-press/template/docs-site-navigation/docs-site-navigation.css +++ b/crates/webui-press/template/docs-site-navigation/docs-site-navigation.css @@ -6,7 +6,23 @@ .nav-links { display: flex; align-items: center; - gap: var(--docs-space-l); + gap: var(--docs-space-m); +} + +::slotted([slot="appearance"]) { + display: flex; + align-items: center; + gap: var(--docs-space-s); +} + +::slotted([slot="before-actions"]), +::slotted([slot="after-actions"]) { + display: contents; +} + +.mobile-appearance { + padding: var(--docs-space-m); + border-bottom: 1px solid var(--docs-color-border); } .nav-link { @@ -56,10 +72,10 @@ .mobile-navigation { position: fixed; - inset: var(--docs-nav-height) 0 0 auto; + inset: 0 0 0 auto; width: min(88vw, 360px); max-width: none; - height: calc(100dvh - var(--docs-nav-height)); + height: 100dvh; max-height: none; margin: 0; padding: 0; @@ -78,6 +94,39 @@ background: rgba(14, 17, 27, 0.56); } +@media (prefers-reduced-motion: no-preference) { + .mobile-navigation { + transform: translateX(100%); + transition: + transform 180ms cubic-bezier(0.16, 1, 0.3, 1), + display 180ms allow-discrete, + overlay 180ms allow-discrete; + } + + .mobile-navigation[open] { + transform: translateX(0); + } + + .mobile-navigation::backdrop { + opacity: 0; + transition: opacity 180ms ease-out, display 180ms allow-discrete, overlay 180ms allow-discrete; + } + + .mobile-navigation[open]::backdrop { + opacity: 1; + } + + @starting-style { + .mobile-navigation[open] { + transform: translateX(100%); + } + + .mobile-navigation[open]::backdrop { + opacity: 0; + } + } +} + .mobile-navigation-panel { display: flex; min-height: 100%; @@ -138,7 +187,17 @@ padding: var(--docs-space-l); } -@media (max-width: 768px) { +@media (max-width: 1024px) { + .nav-links > slot[name="appearance"] { + display: none; + } + + ::slotted([slot="appearance"]) { + flex-direction: column; + align-items: stretch; + width: 100%; + } + .mobile-menu-btn { display: inline-flex; } diff --git a/crates/webui-press/template/docs-site-navigation/docs-site-navigation.html b/crates/webui-press/template/docs-site-navigation/docs-site-navigation.html index b26f07ee2..6e0cfb924 100644 --- a/crates/webui-press/template/docs-site-navigation/docs-site-navigation.html +++ b/crates/webui-press/template/docs-site-navigation/docs-site-navigation.html @@ -7,8 +7,12 @@ @click="{onNavigate(link.fullLayout, e)}" >{{link.text}} + - + + + + +
+ Color mode +
+ + + +
+
+{{error}} diff --git a/crates/webui-press/template/docs-theme-toggle/docs-theme-toggle.ts b/crates/webui-press/template/docs-theme-toggle/docs-theme-toggle.ts index 2ec3c260b..01c7ac9ec 100644 --- a/crates/webui-press/template/docs-theme-toggle/docs-theme-toggle.ts +++ b/crates/webui-press/template/docs-theme-toggle/docs-theme-toggle.ts @@ -2,31 +2,61 @@ // Licensed under the MIT license. import { WebUIElement, observable } from "@microsoft/webui-framework"; +import { getAppearance, setAppearance, subscribeAppearance } from "@microsoft/webui-press/appearance.js"; +import type { Appearance, AppearanceMode } from "@microsoft/webui-press/appearance.js"; + +interface ControlAppearance extends Appearance { + selected: Record; +} + +function controlAppearance(appearance: Appearance): ControlAppearance { + return { + ...appearance, + selected: { + system: appearance.mode === "system", + light: appearance.mode === "light", + dark: appearance.mode === "dark", + }, + }; +} export class DocsThemeToggle extends WebUIElement { - @observable icon = "🌙"; + @observable appearance!: ControlAppearance; + @observable ready = false; + @observable error = ""; + private unsubscribe?: () => void; - connectedCallback(): void { + override connectedCallback(): void { super.connectedCallback(); - const stored = localStorage.getItem("theme"); - if (stored) { - document.documentElement.setAttribute("data-theme", stored); - this.icon = stored === "dark" ? "☀️" : "🌙"; - } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - this.icon = "☀️"; - } + if (this.ready) this.follow(); + } + + protected override hydratedCallback(): void { + this.follow(); + this.ready = true; + } + + private follow(): void { + this.unsubscribe?.(); + this.appearance = controlAppearance(getAppearance()); + this.unsubscribe = subscribeAppearance(appearance => { this.appearance = controlAppearance(appearance); }); } - toggle(): void { - const html = document.documentElement; - const current = html.getAttribute("data-theme"); - const isDark = - current === "dark" || - (!current && window.matchMedia("(prefers-color-scheme: dark)").matches); - const next = isDark ? "light" : "dark"; - html.setAttribute("data-theme", next); - localStorage.setItem("theme", next); - this.icon = next === "dark" ? "☀️" : "🌙"; + override disconnectedCallback(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + super.disconnectedCallback(); + } + + async change(mode: AppearanceMode): Promise { + this.error = ""; + try { + await setAppearance({ mode }); + } catch (error) { + this.error = "Appearance could not be updated. Please try again."; + console.error("[WebUI Press] Appearance update failed.", error); + } + this.appearance = controlAppearance(getAppearance()); } } diff --git a/crates/webui-press/template/docs.css b/crates/webui-press/template/docs.css index 2cd66d9b8..ed578bdd2 100644 --- a/crates/webui-press/template/docs.css +++ b/crates/webui-press/template/docs.css @@ -3,7 +3,7 @@ /* ── Cross-document view transitions ────────────────── Cross-fade main content while navigation stays interactive. - Chromium-only; gracefully ignored elsewhere. */ + Progressively enhanced in supporting browsers. */ @view-transition { navigation: auto; } @@ -27,6 +27,21 @@ animation-timing-function: ease; } +:root[data-press-transition="appearance"] { + view-transition-name: root; +} + +:root[data-press-transition="appearance"] .doc-content { + view-transition-name: none; +} + +::view-transition-group(root), +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 180ms; + animation-timing-function: ease; +} + @media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; @@ -160,7 +175,7 @@ } @media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { + :root:where(:not([data-theme="light"])) { --docs-color-bg: #1a1a2e; --docs-color-bg-alt: #0e0e1a; --docs-color-text: #e2e8f0; diff --git a/crates/webui-press/template/index.html b/crates/webui-press/template/index.html index f7d08729f..0e55dbb05 100644 --- a/crates/webui-press/template/index.html +++ b/crates/webui-press/template/index.html @@ -1,21 +1,10 @@ - + - {{page.documentTitle}} @@ -115,6 +104,7 @@ {{site.title}} {{site.title}} + + > +
+ +
+
+ + + + + + + + + + +
+
+ +
+
diff --git a/crates/webui-press/template/shell.css b/crates/webui-press/template/shell.css index 4e45f8f31..7a3ca7393 100644 --- a/crates/webui-press/template/shell.css +++ b/crates/webui-press/template/shell.css @@ -137,6 +137,10 @@ body { view-transition-name: none; } +:root[data-press-transition="appearance"] .main-content { + view-transition-name: none; +} + .mobile-page-context { display: none; } diff --git a/docs/.webui-press/components/benchmark-explorer/benchmark-controls.test.ts b/docs/.webui-press/components/benchmark-explorer/benchmark-controls.test.ts index 054a688b0..407628967 100644 --- a/docs/.webui-press/components/benchmark-explorer/benchmark-controls.test.ts +++ b/docs/.webui-press/components/benchmark-explorer/benchmark-controls.test.ts @@ -88,10 +88,12 @@ test('benchmark controls switch metrics with a compact mobile select and retain await expect(explorer.locator('.benchmark-row')).toHaveCount(15); await select.focus(); - await select.press('Home'); + await select.press('j'); await expect(select).toHaveValue('jsHeapUsedBytes'); - await select.press('ArrowDown'); - await select.press('Enter'); + await select.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(select).toBeFocused(); + await select.press('r'); await expect(select).toHaveValue('rendererPrivateBytes'); await expect(select).toBeFocused(); await expect(explorer.locator('.benchmark-figure-heading h3')).toContainText('Renderer private bytes'); diff --git a/docs/.webui-press/regions/site-navigation.html b/docs/.webui-press/regions/site-navigation.html index 5bbd55794..0ea7fc4a6 100644 --- a/docs/.webui-press/regions/site-navigation.html +++ b/docs/.webui-press/regions/site-navigation.html @@ -11,7 +11,9 @@ ?home="{{page.isHome}}" ?full-layout="{{page.layout == 'full'}}" :open="{{page.mobileNavigationOpen}}" - > + > +
+ + > +
+
diff --git a/docs/.webui-press/regions/site-navigation.test.ts b/docs/.webui-press/regions/site-navigation.test.ts index f46910c12..30ff3a437 100644 --- a/docs/.webui-press/regions/site-navigation.test.ts +++ b/docs/.webui-press/regions/site-navigation.test.ts @@ -91,3 +91,49 @@ test('mobile readers can move between a lesson and its reference without leaving await dialog.getByRole('link', { name: 'Your first page', exact: true }).click(); await expect(page).toHaveURL(`${server.origin}/webui/start/first-page`); }); + +test('mobile navigation covers the header, slides in, and respects reduced motion', async t => { + const server = await startWebsite(); + t.after(() => server.close()); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, reducedMotion: 'no-preference', + }); + await page.goto(`${server.origin}/webui/reference/webui-press/`); + const opener = page.getByRole('button', { name: 'Open navigation', exact: true }); + const dialog = page.locator('docs-site-navigation dialog.mobile-navigation'); + await dialog.evaluate(element => { + for (const type of ['transitionrun', 'transitionend']) { + element.addEventListener(type, event => { + if (!(event instanceof TransitionEvent) || event.propertyName !== 'transform' || event.pseudoElement) return; + const attribute = event.type === 'transitionrun' ? 'data-slide-start-x' : 'data-slide-end-x'; + element.setAttribute(attribute, String(element.getBoundingClientRect().x)); + }); + } + }); + await opener.click(); + await expect(dialog).toBeVisible(); + await expect.poll(async () => (await dialog.boundingBox())?.y).toBe(0); + await expect(dialog).toHaveCSS('height', '844px'); + await expect(dialog).toHaveAttribute('data-slide-end-x', /.+/); + const slide = await dialog.evaluate(element => ({ + start: Number(element.getAttribute('data-slide-start-x')), + end: Number(element.getAttribute('data-slide-end-x')), + })); + assert.ok(slide.start > slide.end, 'The drawer must move in from the right.'); + await dialog.getByRole('button', { name: 'Close navigation' }).click(); + await expect(dialog).toBeHidden(); + await expect(opener).toBeFocused(); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 320, height: 568 }); + await opener.click(); + await expect(dialog).toBeVisible(); + await expect.poll(async () => (await dialog.boundingBox())?.y).toBe(0); + await expect(dialog).toHaveCSS('height', '568px'); + assert.equal(await dialog.evaluate(element => element.getAnimations().length), 0); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(opener).toBeFocused(); +}); diff --git a/docs/ai.md b/docs/ai.md index 99d506539..f5584b384 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -1603,6 +1603,18 @@ resolve before component discovery and compilation; do not manually register their components elsewhere. See [WebUI Press named regions](/reference/webui-press) for the stable built-in region list and full configuration contract. +Press's `appearance.themes` config accepts named light/dark token sources. +Theme family and System/Light/Dark preferences are independent; single-family +sites omit the family picker. A palette button opens an Appearance popover with +a themeable native family select and System/Light/Dark icon buttons. On narrow +screens the same controls appear directly in the navigation menu. +Deploy and CSP-authorize the generated same-origin initialization asset; it +restores preferences before styles and hydration. +Use `header.themePicker` or `header.modePicker` regions for custom controls and +`@microsoft/webui-press/appearance.js` for shared selection/subscriptions. +Do not duplicate preference storage, media listeners, or theme transitions. +See [Press appearance](/reference/webui-press#theme-families-and-appearance). + ```json { "scripts": { diff --git a/docs/reference/webui-press.md b/docs/reference/webui-press.md index 6c32c54f3..96ae50952 100644 --- a/docs/reference/webui-press.md +++ b/docs/reference/webui-press.md @@ -134,8 +134,9 @@ API panels, custom-page HTML, and bundled page scripts remain. Home Markdown is rendered too. All layouts and the 404 page use ordinary document scrolling, without reserved sidebar columns, header offsets, or full-layout viewport fill. -Full mode remembers the theme control's light/dark selection and applies it to -native theme tokens and browser controls regardless of the OS preference. +Full mode remembers independent design-theme and System/Light/Dark preferences. +Explicit Light or Dark applies to theme tokens and browser controls regardless +of the OS preference; System follows OS changes. Content mode has no shell theme control: it ignores the saved full-site choice without changing it, follows OS light/dark changes through CSS, and works without JavaScript. Forced-colors styles retain precedence over manual theme overrides. @@ -147,17 +148,163 @@ validated, but region state and scripts are inactive. Even with `--template`, content mode uses the bundled shell-free scaffold and content typography, not the full template's CSS or entry script. Configured -`head`, `css`, `theme`, `components`, and page scripts still apply. Put shared -assets there rather than in shell regions. Component discovery continues to -accept npm packages alongside local roots; browser registration imports can use -the existing ``} +`); + write(f.configFile, JSON.stringify({ + ...f.config, regions: customHeader ? { + 'site.navigation': { html: '
Custom navigation without controls
' }, + } : {}, + appearance: { + themes: [{ id: 'neutral', label: 'Neutral' }, { id: 'colorful', label: 'Colorful' }], + defaultTheme: 'neutral', defaultMode: 'system', + storage: { theme: 'fixture-family', mode: 'fixture-mode' }, + }, + })); + build(f.site); + const root = path.join(f.site, 'dist'); + const headers: Record = {}; + if (csp) { + const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8'); + const bootstrap = fs.readFileSync( + path.resolve(import.meta.dirname, '../../../crates/webui-press/template/appearance-bootstrap.js'), 'utf8', + ).trim(); + const hashes = await inlineScriptHashes(csp, html, bootstrap); + headers['content-security-policy'] = `script-src 'self' ${hashes.join(' ')}`; + } + const server = await startWebsite(root, '/fixture/', headers); + t.after(() => server.close()); + return { ...f, dist: root, url: `${server.origin}/fixture/` }; +} + +async function ready(page: Page, url: string): Promise { + await page.goto(url); + await page.waitForFunction(() => !!window.press); + await openAppearance(page); + await expect(page.getByRole('group', { name: 'Color mode' }).getByRole('button', { name: 'Light', exact: true })).toBeEnabled(); +} + +async function blockHydrationScripts(page: Page, url: string): Promise { + const origin = new URL(url).origin; + await page.route('**/*', route => { + const request = route.request(); + const source = new URL(request.url()); + if (request.resourceType() !== 'script' || + (source.origin === origin && /\/appearance-init-[a-f0-9]+\.js$/.test(source.pathname))) { + return route.continue(); + } + return route.abort(); + }); +} + +test('full-shell sourced families validate bundled and authored CSS without changing snapshots', t => { + const f = fixture(); + t.after(() => removeFixtureRoot(f.root)); + const page = path.join(f.site, 'content/index.md'); + write(page, '# Sourced family'); + write(path.join(f.site, '.webui-press/tokens.json'), JSON.stringify({ + themes: { light: { surface: '#fff' }, dark: { surface: '#000' } }, + })); + write(path.join(f.site, 'theme.css'), ':root { --consumer-surface: var(--surface); }'); + write(f.configFile, JSON.stringify({ + ...f.config, regions: {}, + appearance: { + defaultTheme: 'official', + themes: [{ id: 'official', label: 'Official', source: './tokens.json' }], + }, + })); + build(f.site); + const output = path.join(f.site, 'dist'); + const css = fs.readFileSync(path.join(output, 'appearance.css'), 'utf8'); + assert.ok(!css.includes('--docs-color-bg-alt:')); + assert.ok(!css.includes('--consumer-surface:')); + assert.ok(css.includes('--surface: #fff;')); + assert.ok(fs.existsSync(path.join(output, '404.html'))); + + write(page, '# Consumer CSS\n\n'); + build(f.site); + write(page, '# Typo\n\n'); + assert.throws(() => build(f.site), error => { + assert.ok(error instanceof Error); + assert.match(error.message, /consumer-surfac/); + assert.match(error.message, /help:/); + return true; + }); +}); + +test('CSP fixture hashes browser-parsed inline scripts without executing them', async t => { + const browser = await chromium.launch(); + t.after(() => browser.close()); + const scripts = [ + 'document.querySelectorAll("script").forEach(script => script.remove());', + 'window.second = true;', + 'window.third = true;', + ]; + const bootstrap = 'window.bootstrap = true;'; + const html = ` + + + + +`; + assert.deepEqual(await inlineScriptHashes(browser, html, bootstrap), + scripts.map(script => `'sha256-${createHash('sha256').update(script).digest('base64')}'`)); +}); + +test('same-origin appearance initialization works under CSP without authorizing inline bootstrap', async t => { + const browser = await chromium.launch(); + t.after(() => browser.close()); + const f = await appearanceSite(t, { csp: browser }); + const context = await browser.newContext({ + colorScheme: 'light', reducedMotion: 'reduce', storageState: { + cookies: [], origins: [{ origin: new URL(f.url).origin, localStorage: [ + { name: 'fixture-family', value: 'colorful' }, { name: 'fixture-mode', value: 'dark' }, + ] }], + }, + }); + const page = await context.newPage(); + const errors: string[] = []; + const initializationRequests: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + page.on('request', request => { + if (/\/appearance-init-[a-f0-9]+\.js$/.test(new URL(request.url()).pathname)) { + initializationRequests.push(request.url()); + } + }); + await ready(page, f.url); + assert.equal(initializationRequests.length, 1); + assert.equal(await page.locator('script[src*="appearance-init-"]').evaluate(script => + script instanceof HTMLScriptElement && !script.async && !script.defer), true); + const family = page.getByRole('combobox', { name: 'Design theme', exact: true }); + await expect(family).toHaveValue('colorful'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await family.selectOption('neutral'); + await setColorMode(page, 'light'); + await page.reload(); + await openAppearance(page); + await expect(family).toHaveValue('neutral'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await setColorMode(page, 'system'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + assert.deepEqual(errors, []); + + const prepaint = await browser.newPage({ + colorScheme: 'light', storageState: { + cookies: [], origins: [{ origin: new URL(f.url).origin, localStorage: [ + { name: 'fixture-family', value: 'colorful' }, { name: 'fixture-mode', value: 'dark' }, + ] }], + }, + }); + await prepaint.addInitScript(() => { + window.appearancePaints = []; + new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + window.appearancePaints.push({ + name: entry.name, + theme: document.documentElement.getAttribute('data-press-theme'), + mode: document.documentElement.getAttribute('data-theme'), + }); + } + }).observe({ type: 'paint', buffered: true }); + }); + await blockHydrationScripts(prepaint, f.url); + await prepaint.route('**/appearance-init-*.js', async route => { + await new Promise(resolve => setTimeout(resolve, 150)); + await route.continue(); + }); + await prepaint.goto(f.url); + await expect(prepaint.locator('html')).toHaveAttribute('data-press-theme', 'colorful'); + await expect(prepaint.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(prepaint.getByRole('button', { name: 'Appearance', exact: true })).toBeDisabled(); + await expect.poll(() => prepaint.evaluate(() => + window.appearancePaints.some(entry => entry.name === 'first-contentful-paint'))).toBe(true); + for (const paint of await prepaint.evaluate(() => window.appearancePaints)) { + assert.equal(paint.theme, 'colorful', `${paint.name}: family must be restored before paint`); + assert.equal(paint.mode, 'dark', `${paint.name}: mode must be restored before paint`); + } + + const unavailable = await browser.newPage(); + const failures: string[] = []; + unavailable.on('pageerror', error => failures.push(error.message)); + await unavailable.route('**/appearance-init-*.js', route => route.abort()); + await unavailable.goto(f.url); + await expect.poll(() => failures.some(error => error.includes('Deploy the generated appearance-init script, allow it in your CSP'))).toBe(true); +}); + +test('shared appearance controls, prepaint restore, cross-tab state, OS changes and packed API', async t => { + const f = await appearanceSite(t); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const context = await browser.newContext({ colorScheme: 'dark', reducedMotion: 'reduce' }); + const page = await context.newPage(); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await ready(page, f.url); + await page.evaluate(() => customElements.whenDefined('appearance-probe')); + assert.equal(await page.locator('appearance-probe').evaluate(element => + Reflect.get(element, 'appearance')), ''); + const family = page.getByRole('combobox', { name: 'Design theme', exact: true }); + const mode = page.getByRole('group', { name: 'Color mode' }); + const palette = page.getByRole('button', { name: 'Appearance', exact: true }); + await page.keyboard.press('Escape'); + await expect(palette).toBeFocused(); + await expect(family).not.toBeVisible(); + await palette.press('Enter'); + await expect(family).toBeVisible(); + const outside = page.getByRole('button', { name: 'Outside action' }); + await outside.click(); + await expect(family).not.toBeVisible(); + await expect(outside).toBeFocused(); + await palette.click(); + await expect(family).toHaveValue('neutral'); + await expect(mode.getByRole('button', { name: 'System' })).toHaveAttribute('aria-pressed', 'true'); + await expect(mode.getByRole('button', { name: 'Light', exact: true })).toHaveAttribute('aria-pressed', 'false'); + await expect(mode.getByRole('button', { name: 'Dark', exact: true })).toHaveAttribute('aria-pressed', 'false'); + await expect(page.getByRole('combobox', { name: 'Color mode' })).toHaveCount(0); + assert.deepEqual(await page.evaluate(() => window.press.getAppearance()), { + theme: 'neutral', mode: 'system', resolvedMode: 'dark', + }); + assert.equal(await page.evaluate(() => Object.isFrozen(window.press.getThemeFamilies())), true); + assert.equal(await page.evaluate(() => window.updates.length), 0); + await family.selectOption('colorful'); + await page.evaluate(() => window.press.setAppearance({ theme: 'colorful' })); + assert.equal(await page.evaluate(() => window.updates.length), 1); + await setColorMode(page, 'light'); + await expect(page.locator('html')).toHaveAttribute('data-press-theme', 'colorful'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.reload(); + await openAppearance(page); + await expect(mode.getByRole('button', { name: 'Light', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(family).toHaveValue('colorful'); + await setColorMode(page, 'system'); + await page.emulateMedia({ colorScheme: 'light' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + const second = await context.newPage(); + await ready(second, f.url); + await second.evaluate(() => window.press.setAppearance({ theme: 'neutral', mode: 'light' })); + await expect(family).toHaveValue('neutral'); + await expect(mode.getByRole('button', { name: 'Light', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.evaluate(() => { + window.stopAppearance(); + window.updates = []; + }); + await setColorMode(page, 'dark'); + assert.equal(await page.evaluate(() => window.updates.length), 0); + const before = await page.evaluate(() => window.press.getAppearance()); + const invalid = await page.evaluate(async () => { + try { await window.press.setAppearance({ theme: 'missing' }); return ''; } + catch (error) { return String(error); } + }); + assert.match(invalid, /TypeError/); + assert.deepEqual(await page.evaluate(() => window.press.getAppearance()), before); + await setColorMode(page, 'light'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.keyboard.press('Escape'); + await expect(family).not.toBeVisible(); + await expect(palette).toBeFocused(); + await palette.click(); + await setColorMode(page, 'dark'); + + // Exercise the real published export, not a workspace-relative source import. + const packageDir = path.resolve(import.meta.dirname, '..'); + const packed = JSON.parse(execFileSync('npm', ['pack', '--json', '--pack-destination', f.root], { + cwd: packageDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + })) as { filename: string }[]; + execFileSync('tar', ['-xzf', path.join(f.root, packed[0].filename), '-C', f.root]); + const pkg = JSON.parse(fs.readFileSync(path.join(f.root, 'package/package.json'), 'utf8')); + assert.equal(pkg.exports['./appearance.js'].types, './dist/appearance.d.ts'); + assert.match(fs.readFileSync(path.join(f.root, 'package/dist/appearance.d.ts'), 'utf8'), /subscribeAppearance/); + const packageLink = path.join(f.root, 'node_modules/@microsoft/webui-press'); + fs.mkdirSync(path.dirname(packageLink), { recursive: true }); + fs.symlinkSync(path.join(f.root, 'package'), packageLink, 'junction'); + execFileSync(process.execPath, ['--input-type=module', '-e', + `import {getAppearance,getThemeFamilies,setAppearance,subscribeAppearance} from '@microsoft/webui-press/appearance.js'; + if (![getAppearance,getThemeFamilies,setAppearance,subscribeAppearance].every(fn => typeof fn === 'function')) process.exit(1);`, + ], { cwd: f.root }); + write(path.join(f.root, 'consumer.mts'), ` + import {getAppearance, getThemeFamilies, setAppearance, subscribeAppearance} from '@microsoft/webui-press/appearance.js'; + const mode: 'system' | 'light' | 'dark' = getAppearance().mode; + const families: readonly {id: string; label: string}[] = getThemeFamilies(); + const update: Promise = setAppearance({theme: families[0].id, mode}); + const stop: () => void = subscribeAppearance(value => console.log(value.resolvedMode)); + // @ts-expect-error invalid appearance is rejected by the public types + setAppearance({mode: 'automatic'}); + `); + write(path.join(f.root, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { noEmit: true, strict: true, skipLibCheck: true, module: 'NodeNext', target: 'ES2022' }, + files: ['consumer.mts'], + })); + execFileSync(process.execPath, [ + path.join(packageDir, 'node_modules/typescript/bin/tsc'), '--project', path.join(f.root, 'tsconfig.json'), + ], { cwd: f.root, stdio: 'pipe' }); + fs.copyFileSync(path.join(f.root, 'package/dist/appearance.js'), path.join(f.dist, 'packed-appearance.js')); + assert.equal(await page.evaluate(async () => { + const url = new URL('packed-appearance.js', location.href).href; + const packed = await import(url); + return packed.getAppearance() === window.press.getAppearance(); + }), true); + const screenshotDir = process.env.WEBUI_PRESS_SCREENSHOTS; + if (screenshotDir) { + fs.mkdirSync(screenshotDir, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.screenshot({ path: path.join(screenshotDir, 'appearance-1440.png'), fullPage: true }); + } + await page.setViewportSize({ width: 390, height: 900 }); + await expect(family).not.toBeVisible(); + if (screenshotDir) { + await page.screenshot({ path: path.join(screenshotDir, 'appearance-390-closed.png'), fullPage: true }); + } + const navigation = page.getByRole('button', { name: 'Open navigation', exact: true }); + await navigation.click(); + await expect(family).toBeVisible(); + await expect(mode).toBeVisible(); + // Keyboard selection stays native; Escape closes the picker, then the menu. + await page.keyboard.press('Tab'); + await expect(family).toBeFocused(); + await page.keyboard.press('Space'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(family).toHaveValue('colorful'); + await page.keyboard.press('Tab'); + const system = mode.getByRole('button', { name: 'System' }); + await expect(system).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(system).toHaveAttribute('aria-pressed', 'true'); + await page.keyboard.press('Tab'); + const light = mode.getByRole('button', { name: 'Light', exact: true }); + await expect(light).toBeFocused(); + await page.keyboard.press('Space'); + await expect(light).toHaveAttribute('aria-pressed', 'true'); + await page.keyboard.press('Tab'); + const dark = mode.getByRole('button', { name: 'Dark', exact: true }); + await expect(dark).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(dark).toHaveAttribute('aria-pressed', 'true'); + if (screenshotDir) { + await page.screenshot({ path: path.join(screenshotDir, 'appearance-390.png'), fullPage: true }); + } + await page.keyboard.press('Escape'); + await expect(navigation).toBeFocused(); + await expect(family).not.toBeVisible(); + assert.deepEqual(errors, []); +}); + +test('bootstrap validates stored choices, preserves legacy keys and works without storage or hydration', async t => { + const f = await appearanceSite(t); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ colorScheme: 'light', reducedMotion: 'reduce' }); + await page.addInitScript(() => { + localStorage.setItem('theme', 'dark'); + localStorage.setItem('fixture-family', 'missing'); + localStorage.removeItem('fixture-mode'); + }); + await ready(page, f.url); + assert.deepEqual(await page.evaluate(() => window.press.getAppearance()), { + theme: 'neutral', mode: 'dark', resolvedMode: 'dark', + }); + await page.evaluate(() => window.press.setAppearance({ mode: 'system' })); + assert.equal(await page.evaluate(() => localStorage.getItem('theme')), 'dark'); + const prepaint = await browser.newPage({ colorScheme: 'light', storageState: { + cookies: [], origins: [{ origin: new URL(f.url).origin, localStorage: [ + { name: 'fixture-family', value: 'colorful' }, { name: 'fixture-mode', value: 'dark' }, + ] }], + } }); + await blockHydrationScripts(prepaint, f.url); + await prepaint.goto(f.url); + await expect(prepaint.locator('html')).toHaveAttribute('data-press-theme', 'colorful'); + await expect(prepaint.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(prepaint.getByRole('button', { name: 'Appearance', exact: true })).toBeDisabled(); + await expect(prepaint.getByRole('button', { name: 'Light', exact: true, includeHidden: true })).toBeDisabled(); + const invalidSaved = await browser.newPage({ colorScheme: 'light', storageState: { + cookies: [], origins: [{ origin: new URL(f.url).origin, localStorage: [ + { name: 'theme', value: 'dark' }, { name: 'fixture-mode', value: 'invalid' }, + ] }], + } }); + await ready(invalidSaved, f.url); + assert.deepEqual(await invalidSaved.evaluate(() => window.press.getAppearance()), { + theme: 'neutral', mode: 'system', resolvedMode: 'light', + }); + + const unavailable = await browser.newPage({ reducedMotion: 'reduce' }); + const warnings: string[] = []; + unavailable.on('console', message => { if (message.type() === 'warning') warnings.push(message.text()); }); + await unavailable.addInitScript(() => { + Object.defineProperty(window, 'localStorage', { get() { throw new DOMException('Denied', 'SecurityError'); } }); + }); + await ready(unavailable, f.url); + await setColorMode(unavailable, 'dark'); + await expect(unavailable.locator('html')).toHaveAttribute('data-theme', 'dark'); + assert.ok(warnings.some(warning => warning.includes('storage is unavailable'))); + + const fallback = await browser.newPage({ reducedMotion: 'reduce' }); + await fallback.addInitScript(() => { + Object.defineProperty(HTMLElement.prototype, 'showPopover', { value: undefined }); + }); + await ready(fallback, f.url); + await expect(fallback.getByRole('button', { name: 'Appearance', exact: true })).not.toBeVisible(); + await expect(fallback.getByRole('combobox', { name: 'Design theme' })).toBeVisible(); + await setColorMode(fallback, 'dark'); + await expect(fallback.locator('html')).toHaveAttribute('data-theme', 'dark'); + + const noJs = await browser.newPage({ javaScriptEnabled: false }); + await noJs.goto(f.url); + await expect(noJs.getByRole('button', { name: 'Appearance', exact: true })).toBeDisabled(); + await expect(noJs.getByRole('button', { name: 'Light', exact: true, includeHidden: true })).toBeDisabled(); + await expect(noJs.getByRole('combobox', { name: 'Design theme', exact: true, includeHidden: true })).toBeDisabled(); + await expect(noJs.getByRole('combobox', { name: 'Design theme', exact: true, includeHidden: true })).toHaveValue('neutral'); + await noJs.setViewportSize({ width: 390, height: 844 }); + await expect(noJs.getByRole('combobox', { name: 'Design theme', exact: true })).not.toBeVisible(); + assert.equal(await noJs.evaluate(() => + document.documentElement.scrollWidth <= window.innerWidth), true); +}); + +test('native transitions are latest-wins, scoped, skippable and surface unexpected failures', async t => { + const f = await appearanceSite(t); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ reducedMotion: 'no-preference' }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await ready(page, f.url); + await page.evaluate(() => { + const start = document.startViewTransition.bind(document); + window.transitionCount = 0; + document.startViewTransition = ((update: () => void) => { + window.transitionCount++; + return start(update); + }) as typeof document.startViewTransition; + }); + await page.evaluate(async () => { + const first = window.press.setAppearance({ theme: 'colorful' }); + await new Promise(resolve => setTimeout(resolve, 10)); + await Promise.all([first, window.press.setAppearance({ mode: 'dark' }), + window.press.setAppearance({ theme: 'neutral', mode: 'light' })]); + }); + assert.deepEqual(await page.evaluate(() => window.press.getAppearance()), { + theme: 'neutral', mode: 'light', resolvedMode: 'light', + }); + await expect(page.locator('html')).not.toHaveAttribute('data-press-transition'); + assert.ok(await page.evaluate(() => window.transitionCount <= 2)); + await page.emulateMedia({ reducedMotion: 'reduce' }); + const count = await page.evaluate(() => window.transitionCount); + await page.evaluate(() => window.press.setAppearance({ mode: 'dark' })); + assert.equal(await page.evaluate(() => window.transitionCount), count); + await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'active' }); + await page.evaluate(() => window.press.setAppearance({ mode: 'light' })); + assert.equal(await page.evaluate(() => window.transitionCount), count); + await page.emulateMedia({ forcedColors: 'none' }); + const failure = await page.evaluate(async () => { + document.startViewTransition = () => { throw new Error('Transition failure'); }; + try { await window.press.setAppearance({ mode: 'dark' }); return ''; } + catch (error) { return String(error); } + }); + assert.match(failure, /Transition failure/); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(page.locator('html')).not.toHaveAttribute('data-press-transition'); + const rejectedReady = await page.evaluate(async () => { + document.startViewTransition = ((update: () => void) => { + update(); + return { + ready: Promise.reject(new Error('Unexpected ready failure')), + updateCallbackDone: Promise.resolve(), finished: Promise.resolve(), skipTransition() {}, + }; + }) as typeof document.startViewTransition; + try { await window.press.setAppearance({ mode: 'light' }); return ''; } + catch (error) { return String(error); } + }); + assert.match(rejectedReady, /Unexpected ready failure/); + await page.evaluate(async () => { + Object.defineProperty(document, 'startViewTransition', { value: undefined, configurable: true }); + await window.press.setAppearance({ mode: 'dark' }); + }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + assert.deepEqual(errors, []); +}); + +test('custom headers follow System before the API adopts the bootstrap listener', async t => { + const f = await appearanceSite(t, { customHeader: true }); + buildSync({ + entryPoints: [path.resolve(import.meta.dirname, '../../../crates/webui-press/template/appearance.ts')], + outfile: path.join(f.dist, 'late-appearance.js'), format: 'esm', bundle: true, + }); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const context = await browser.newContext({ colorScheme: 'light', reducedMotion: 'reduce' }); + const page = await context.newPage(); + await page.goto(f.url); + assert.equal(await page.evaluate(() => + Reflect.has(document, Symbol.for('@microsoft/webui-press/appearance'))), false); + await expect(page.getByRole('combobox')).toHaveCount(0); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(26, 26, 46)'); + await page.emulateMedia({ colorScheme: 'light' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + const second = await page.context().newPage(); + await second.goto(f.url); + await second.evaluate(() => { + localStorage.setItem('fixture-family', 'colorful'); + localStorage.setItem('fixture-mode', 'dark'); + }); + await expect(page.locator('html')).toHaveAttribute('data-press-theme', 'colorful'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await second.evaluate(() => localStorage.setItem('fixture-mode', 'system')); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.evaluate(async () => { + window.press = await import(new URL('late-appearance.js', location.href).href); + window.updates = []; + window.stopAppearance = window.press.subscribeAppearance(value => window.updates.push(value)); + window.themeMutations = 0; + new MutationObserver(records => { window.themeMutations += records.length; }) + .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + }); + assert.equal(await page.evaluate(() => + Reflect.has(document, Symbol.for('@microsoft/webui-press/appearance-bootstrap'))), false); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + assert.equal(await page.evaluate(() => window.updates.length), 1); + assert.equal(await page.evaluate(() => window.themeMutations), 1); + await page.evaluate(() => { window.themeMutations = 0; }); + await second.evaluate(() => localStorage.setItem('fixture-family', 'neutral')); + await expect(page.locator('html')).toHaveAttribute('data-press-theme', 'neutral'); + assert.equal(await page.evaluate(() => window.updates.length), 2); + assert.equal(await page.evaluate(() => window.themeMutations), 1); + await page.evaluate(() => window.press.setAppearance({ mode: 'light' })); + await page.emulateMedia({ colorScheme: 'light' }); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); +}); diff --git a/packages/webui-press/tests/theme-mode.test.ts b/packages/webui-press/tests/theme-mode.test.ts index bf3be426e..c3b548a71 100644 --- a/packages/webui-press/tests/theme-mode.test.ts +++ b/packages/webui-press/tests/theme-mode.test.ts @@ -11,9 +11,9 @@ import path from 'node:path'; import test from 'node:test'; import type { TestContext } from 'node:test'; import { build, fixture, removeFixtureRoot, write } from './native-fixture.js'; -import { contrastRatio } from './website-fixture.js'; +import { contrastRatio, openAppearance, setColorMode } from './website-fixture.js'; -async function themedSite(t: TestContext, mode: 'all' | 'content') { +async function themedSite(t: TestContext, mode: 'all' | 'content', families = false) { const f = fixture(); const root = path.join(f.site, 'dist'); let server: http.Server | undefined; @@ -45,18 +45,38 @@ async function themedSite(t: TestContext, mode: 'all' | 'content') { // Reuse Press's own palette; the fixture authors only its component tokens. const light = declarations(css.slice(0, darkStart)); const dark = { ...light, ...declarations(css.slice(darkStart)) }; - write(path.join(f.site, '.webui-press/tokens.json'), JSON.stringify({ + const tokens = { themes: { light: { ...light, 'fixture-text': '#111111', 'fixture-surface': '#ffffff' }, dark: { ...dark, 'fixture-text': '#eeeeee', 'fixture-surface': '#171717' }, }, - })); + }; + if (families) { + Object.assign(tokens.themes.light, { 'docs-color-bg': '#fafafa' }); + Object.assign(tokens.themes.dark, { 'docs-color-bg': '#181818' }); + write(path.join(f.site, '.webui-press/ocean.json'), JSON.stringify({ + themes: { + light: { ...tokens.themes.light, 'docs-color-bg': '#f0f8ff' }, + dark: { ...tokens.themes.dark, 'docs-color-bg': '#001a33' }, + }, + })); + } + write(path.join(f.site, '.webui-press/tokens.json'), JSON.stringify(tokens)); write(path.join(f.site, 'theme.css'), ` + :root { --docs-nav-height: 4.5rem; } @media (forced-colors: active) { :root { --fixture-text: CanvasText; --fixture-surface: Canvas; } } `); - write(f.configFile, JSON.stringify({ ...f.config, regions: {}, theme: './tokens.json' })); + write(f.configFile, JSON.stringify({ ...f.config, regions: {}, ...(families ? { + appearance: { + defaultTheme: 'neutral', + themes: [ + { id: 'neutral', label: 'Neutral', source: './tokens.json' }, + { id: 'ocean', label: 'Ocean', source: './ocean.json' }, + ], + }, + } : { theme: './tokens.json' }) })); build(f.site, `--show=${mode}`); const mime: Record = { '.css': 'text/css', '.js': 'text/javascript', '.json': 'application/json', '.svg': 'image/svg+xml', @@ -125,20 +145,20 @@ test('full manual themes override OS token colors without overriding forced colo image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0, )).toBe(true); await expectTheme(page, 'dark'); - await expect(page.locator('docs-theme-toggle .sun-icon')).toBeVisible(); - await expect(page.locator('docs-theme-toggle button')).not.toContainText('☀️'); - await page.locator('docs-theme-toggle button').click(); + await openAppearance(page); + await expect(page.getByRole('group', { name: 'Color mode' }).getByRole('button', { name: 'System' })).toHaveAttribute('aria-pressed', 'true'); + await setColorMode(page, 'light'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); await expectTheme(page, 'light'); - await expect(page.locator('docs-theme-toggle .moon-icon')).toBeVisible(); - await expect(page.locator('docs-theme-toggle button')).not.toContainText('🌙'); + await expect(page.getByRole('group', { name: 'Color mode' }).getByRole('button', { name: 'Light', exact: true })).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('theme-probe input')).toHaveCSS('color-scheme', 'light'); await page.reload(); await expectTheme(page, 'light'); await capture(page, 'theme-full-light-os-dark'); await expectForcedColors(page); await page.emulateMedia({ forcedColors: 'none', colorScheme: 'light' }); - await page.locator('docs-theme-toggle button').click(); + await page.setViewportSize({ width: 1440, height: 1000 }); + await setColorMode(page, 'dark'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expectTheme(page, 'dark'); await expectForcedColors(page); @@ -173,3 +193,36 @@ test('content themes ignore stored shell preferences and follow OS changes', asy await noJs.emulateMedia({ colorScheme: 'light' }); await expectTheme(noJs, 'light'); }); + +test('sourced family colors override shell defaults while authored aliases retain precedence', async t => { + const { url } = await themedSite(t, 'all', true); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ colorScheme: 'dark', reducedMotion: 'reduce' }); + await page.goto(url); + await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(24, 24, 24)'); + const family = page.getByRole('combobox', { name: 'Design theme' }); + await openAppearance(page); + for (const [id, light, dark] of [ + ['neutral', 'rgb(250, 250, 250)', 'rgb(24, 24, 24)'], + ['ocean', 'rgb(240, 248, 255)', 'rgb(0, 26, 51)'], + ]) { + await family.selectOption(id); + for (const preference of ['system', 'light', 'dark'] as const) { + await setColorMode(page, preference); + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }); + const resolved = preference === 'system' ? colorScheme : preference; + await expect(page.locator('body')).toHaveCSS('background-color', resolved === 'light' ? light : dark); + assert.equal(await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--docs-nav-height').trim()), '4.5rem'); + } + } + } + await expectForcedColors(page); + const noJs = await browser.newPage({ javaScriptEnabled: false, colorScheme: 'dark' }); + await noJs.goto(url); + await expect(noJs.locator('body')).toHaveCSS('background-color', 'rgb(24, 24, 24)'); + await noJs.emulateMedia({ colorScheme: 'light' }); + await expect(noJs.locator('body')).toHaveCSS('background-color', 'rgb(250, 250, 250)'); +}); diff --git a/packages/webui-press/tests/website-fixture.ts b/packages/webui-press/tests/website-fixture.ts index 625c502c3..d9b22e543 100644 --- a/packages/webui-press/tests/website-fixture.ts +++ b/packages/webui-press/tests/website-fixture.ts @@ -2,6 +2,8 @@ // Licensed under the MIT license. import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { once } from 'node:events'; import fs from 'node:fs'; import http from 'node:http'; @@ -9,6 +11,24 @@ import path from 'node:path'; export const docsDist = path.resolve(import.meta.dirname, '../../../docs/dist'); +export async function openAppearance(page: Page): Promise { + const trigger = page.getByRole('button', { name: 'Appearance', exact: true }); + const group = page.getByRole('group', { name: 'Color mode' }); + await expect.poll(async () => await group.isVisible() || + (await trigger.isVisible() && await trigger.isEnabled())).toBe(true); + if (!await group.isVisible()) { + await trigger.click(); + } +} + +export async function setColorMode(page: Page, mode: 'system' | 'light' | 'dark'): Promise { + await openAppearance(page); + const name = mode[0].toUpperCase() + mode.slice(1); + const control = page.getByRole('group', { name: 'Color mode' }).getByRole('button', { name, exact: true }); + await control.click(); + await expect(control).toHaveAttribute('aria-pressed', 'true'); +} + const contentTypes: Record = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', @@ -34,7 +54,7 @@ export function contrastRatio(color: string, background: string): number { return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } -export async function startWebsite(root = docsDist, basePath = '/webui/') { +export async function startWebsite(root = docsDist, basePath = '/webui/', headers: Record = {}) { const directory = path.resolve(root); assert.ok(basePath.startsWith('/') && basePath.endsWith('/')); assert.ok(fs.existsSync(path.join(directory, 'index.html')), 'Build the documentation before testing the website.'); @@ -64,6 +84,7 @@ export async function startWebsite(root = docsDist, basePath = '/webui/') { response.writeHead(200, { 'content-type': contentTypes[path.extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store', + ...headers, }); fs.createReadStream(file).on('error', error => response.destroy(error)).pipe(response); }); diff --git a/packages/webui-press/tsconfig.json b/packages/webui-press/tsconfig.json new file mode 100644 index 000000000..b7c05a600 --- /dev/null +++ b/packages/webui-press/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "rootDir": "../../crates/webui-press/template", + "outDir": "dist", + "types": [], + "lib": ["ES2022", "DOM"] + }, + "include": ["../../crates/webui-press/template/appearance.ts"] +} diff --git a/packages/webui-press/tsconfig.test.json b/packages/webui-press/tsconfig.test.json index 750c1c2dd..9b0b7e27c 100644 --- a/packages/webui-press/tsconfig.test.json +++ b/packages/webui-press/tsconfig.test.json @@ -4,16 +4,22 @@ "module": "ESNext", "moduleResolution": "bundler", "strict": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, "skipLibCheck": true, "verbatimModuleSyntax": true, "types": ["node"], "paths": { - "@microsoft/webui-framework": ["../webui-framework/dist/index.d.ts"] + "@microsoft/webui-framework": ["../webui-framework/dist/index.d.ts"], + "@microsoft/webui-press/appearance.js": ["../../crates/webui-press/template/appearance.ts"] } }, "include": [ "tests", "../../crates/webui-press/template/docs-outline", + "../../crates/webui-press/template/docs-appearance", + "../../crates/webui-press/template/docs-theme-picker", + "../../crates/webui-press/template/docs-theme-toggle", "../../docs/.webui-press/components/**/*.test.ts", "../../docs/.webui-press/regions/**/*.test.ts", "../../docs/.webui-press/scripts/**/*.test.ts",