From cf9c3138fcd928d2c9d1a600c9e7809ac2997f62 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 30 Oct 2026 11:58:05 -0700 Subject: [PATCH 1/5] feat: add Press theme families and appearance integrations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + DESIGN.md | 8 +- crates/webui-press/Cargo.toml | 1 + crates/webui-press/src/appearance.rs | 344 ++++++++++++++ crates/webui-press/src/appearance_css.rs | 211 +++++++++ crates/webui-press/src/appearance_tests.rs | 303 +++++++++++++ crates/webui-press/src/build.rs | 55 ++- crates/webui-press/src/bundler.rs | 41 +- crates/webui-press/src/lib.rs | 2 + crates/webui-press/src/main.rs | 2 + crates/webui-press/src/regions.rs | 105 +++-- crates/webui-press/src/regions/parser.rs | 73 +-- crates/webui-press/src/regions/tests.rs | 112 ++++- crates/webui-press/src/serve.rs | 38 ++ crates/webui-press/src/state.rs | 42 ++ crates/webui-press/src/types.rs | 52 +++ .../template/appearance-bootstrap.js | 59 +++ crates/webui-press/template/appearance.ts | 217 +++++++++ crates/webui-press/template/content.html | 2 +- .../docs-appearance/docs-appearance.css | 54 +++ .../docs-appearance/docs-appearance.html | 22 + .../docs-appearance/docs-appearance.ts | 54 +++ .../docs-site-navigation.css | 30 +- .../docs-site-navigation.html | 11 +- .../docs-site-navigation.ts | 10 +- .../docs-theme-picker/docs-theme-picker.css | 36 ++ .../docs-theme-picker/docs-theme-picker.html | 9 + .../docs-theme-picker/docs-theme-picker.ts | 49 ++ .../docs-theme-toggle/docs-theme-toggle.css | 59 +-- .../docs-theme-toggle/docs-theme-toggle.html | 34 +- .../docs-theme-toggle/docs-theme-toggle.ts | 68 ++- crates/webui-press/template/docs.css | 19 +- crates/webui-press/template/index.html | 35 +- crates/webui-press/template/shell.css | 4 + .../benchmark-controls.test.ts | 8 +- .../.webui-press/regions/site-navigation.html | 8 +- docs/ai.md | 10 + docs/reference/webui-press.md | 187 +++++++- packages/webui-press/README.md | 15 + packages/webui-press/package.json | 13 +- packages/webui-press/tests/appearance.test.ts | 422 ++++++++++++++++++ packages/webui-press/tests/theme-mode.test.ts | 75 +++- packages/webui-press/tests/website-fixture.ts | 20 + packages/webui-press/tsconfig.json | 15 + packages/webui-press/tsconfig.test.json | 8 +- 45 files changed, 2726 insertions(+), 217 deletions(-) create mode 100644 crates/webui-press/src/appearance.rs create mode 100644 crates/webui-press/src/appearance_css.rs create mode 100644 crates/webui-press/src/appearance_tests.rs create mode 100644 crates/webui-press/template/appearance-bootstrap.js create mode 100644 crates/webui-press/template/appearance.ts create mode 100644 crates/webui-press/template/docs-appearance/docs-appearance.css create mode 100644 crates/webui-press/template/docs-appearance/docs-appearance.html create mode 100644 crates/webui-press/template/docs-appearance/docs-appearance.ts create mode 100644 crates/webui-press/template/docs-theme-picker/docs-theme-picker.css create mode 100644 crates/webui-press/template/docs-theme-picker/docs-theme-picker.html create mode 100644 crates/webui-press/template/docs-theme-picker/docs-theme-picker.ts create mode 100644 packages/webui-press/tests/appearance.test.ts create mode 100644 packages/webui-press/tsconfig.json 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..ad572e8cf 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,12 @@ 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 +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..ecadecfd5 --- /dev/null +++ b/crates/webui-press/src/appearance.rs @@ -0,0 +1,344 @@ +// 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}; + +#[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) -> 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", + include_str!("../template/appearance-bootstrap.js") + )) + } +} + +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..dcd32d22c --- /dev/null +++ b/crates/webui-press/src/appearance_tests.rs @@ -0,0 +1,303 @@ +// 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)?; + 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(" {{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/ai.md b/docs/ai.md index 99d506539..205bcb50a 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -1603,6 +1603,16 @@ 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. +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..616ce43e6 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,156 @@ 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 server = await startWebsite(root, '/fixture/'); + 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(); +} + +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 prepaint.route('**/*.js*', route => route.abort()); + 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, 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..ff21f551e 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', 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", From a791748579a1ee29136a9fb76ffe1ec5e0520ef1 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 30 Oct 2026 12:22:41 -0700 Subject: [PATCH 2/5] fix: keep Press Rust tests independent of built JS packages Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/webui-press/src/appearance_tests.rs | 71 +++++-------------- packages/webui-press/tests/appearance.test.ts | 35 +++++++++ 2 files changed, 52 insertions(+), 54 deletions(-) diff --git a/crates/webui-press/src/appearance_tests.rs b/crates/webui-press/src/appearance_tests.rs index dcd32d22c..4f6b6f798 100644 --- a/crates/webui-press/src/appearance_tests.rs +++ b/crates/webui-press/src/appearance_tests.rs @@ -238,66 +238,29 @@ fn build_emits_shared_artifacts_and_metadata_for_pages_and_404() -> TestResult { } #[test] -fn full_shell_sourced_families_use_external_css_validation_context() -> TestResult { - let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages/webui-press/target"); - fs::create_dir_all(&fixtures)?; - let dir = tempfile::tempdir_in(fixtures)?; - let content = dir.path().join("content"); - fs::create_dir_all(&content)?; - let page = content.join("index.md"); - fs::write(&page, "# Sourced family")?; +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 custom_css = dir.path().join("custom.css"); - fs::write(&custom_css, ":root { --consumer-surface: var(--surface); }")?; - let mut config = config( + let config = config( r#"{"defaultTheme":"official","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(custom_css.to_string_lossy().into_owned()); - let mut bundler: crate::types::BundlerConfig = serde_json::from_str("{}")?; - bundler.alias.insert( - "@microsoft/webui-framework".into(), - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../packages/webui-framework/src/index.ts") - .canonicalize()? - .to_string_lossy() - .into_owned(), + 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() ); - config.bundler = Some(bundler); - // Match native CLI extraction: the real bundled templates live alongside - // the consumer's node_modules, not beneath the Rust source tree. - static TEMPLATE: include_dir::Dir<'_> = - include_dir::include_dir!("$CARGO_MANIFEST_DIR/template"); - let template = dir.path().join("template"); - fs::create_dir_all(&template)?; - TEMPLATE.extract(&template)?; - crate::build::build_docs(&config, dir.path(), &template)?; - let css = fs::read_to_string(Path::new(&config.out_dir).join("appearance.css"))?; - assert!(!css.contains("--docs-color-bg-alt:")); - assert!(!css.contains("--consumer-surface:")); - assert!(css.contains("--surface: #fff;")); - assert!(Path::new(&config.out_dir).join("404.html").is_file()); - - fs::write( - &page, - "# Consumer CSS\n\n", - )?; - crate::build::build_docs(&config, dir.path(), &template)?; - - fs::write( - &page, - "# Typo\n\n", - )?; - let error = crate::build::build_docs(&config, dir.path(), &template) - .err() - .ok_or("misspelled token was accepted")? - .to_string(); - assert!(error.contains("consumer-surfac"), "{error}"); - assert!(error.contains("help:"), "{error}"); + assert!(!appearance.css.contains("--docs-color-bg-alt:")); + assert!(!appearance.css.contains("--consumer-surface:")); + assert!(appearance.css.contains("--surface: #fff;")); Ok(()) } diff --git a/packages/webui-press/tests/appearance.test.ts b/packages/webui-press/tests/appearance.test.ts index bf8a31e9c..17b3bea64 100644 --- a/packages/webui-press/tests/appearance.test.ts +++ b/packages/webui-press/tests/appearance.test.ts @@ -72,6 +72,41 @@ async function ready(page: Page, url: string): Promise { await expect(page.getByRole('group', { name: 'Color mode' }).getByRole('button', { name: 'Light', exact: true })).toBeEnabled(); } +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('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(); From 8a75fa8983b4df3ff2d6879ef2ab96dd02df9124 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 30 Oct 2026 12:59:41 -0700 Subject: [PATCH 3/5] fix: make mobile navigation a full-height sliding drawer Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../docs-site-navigation.css | 37 ++++++++++++++- .../regions/site-navigation.test.ts | 46 +++++++++++++++++++ docs/reference/webui-press.md | 3 +- 3 files changed, 83 insertions(+), 3 deletions(-) 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 40378daf5..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 @@ -72,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; @@ -94,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%; 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/reference/webui-press.md b/docs/reference/webui-press.md index 616ce43e6..cdc0d9c49 100644 --- a/docs/reference/webui-press.md +++ b/docs/reference/webui-press.md @@ -207,7 +207,8 @@ native select behavior. Color mode uses labeled monitor, sun, and moon buttons, with the current preference exposed as pressed. The popover uses native light-dismiss and Escape behavior. Without Popover API support, the controls remain inline. On narrow screens the same controls appear directly inside the -navigation menu, without a second popover. +full-height navigation drawer, which covers the header and slides in from the +right without a second popover. Reduced-motion preferences disable the slide. The controls inherit the `--docs-*` color, font, spacing, and radius tokens, so a site can theme them without depending on a UI library. From b75fe99538f121b956f2a2eac51ba18c44862c45 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 30 Oct 2026 15:00:10 -0700 Subject: [PATCH 4/5] fix: serve Press appearance initialization as a static asset Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 9 +- crates/webui-press/src/appearance.rs | 8 +- crates/webui-press/src/appearance_tests.rs | 43 ++++++- crates/webui-press/src/build.rs | 12 +- crates/webui-press/template/appearance.ts | 2 +- docs/ai.md | 2 + docs/reference/webui-press.md | 16 ++- packages/webui-press/tests/appearance.test.ts | 119 +++++++++++++++++- packages/webui-press/tests/website-fixture.ts | 3 +- 9 files changed, 189 insertions(+), 25 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ad572e8cf..ffec97e4e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -387,10 +387,11 @@ 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 -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. +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/src/appearance.rs b/crates/webui-press/src/appearance.rs index ecadecfd5..137bffe6d 100644 --- a/crates/webui-press/src/appearance.rs +++ b/crates/webui-press/src/appearance.rs @@ -15,6 +15,8 @@ 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, @@ -183,7 +185,7 @@ impl PreparedAppearance { ) } - pub(crate) fn head_tags(&self, show: ShowMode) -> Result { + pub(crate) fn head_tags(&self, show: ShowMode, bootstrap_url: &str) -> Result { if show == ShowMode::Content { return Ok(String::new()); } @@ -195,8 +197,8 @@ impl PreparedAppearance { .replace('\u{2028}', "\\u2028") .replace('\u{2029}', "\\u2029"); Ok(format!( - "\n", - include_str!("../template/appearance-bootstrap.js") + "\n", + html_escape::encode_double_quoted_attribute(bootstrap_url) )) } } diff --git a/crates/webui-press/src/appearance_tests.rs b/crates/webui-press/src/appearance_tests.rs index 4f6b6f798..cde6aa820 100644 --- a/crates/webui-press/src/appearance_tests.rs +++ b/crates/webui-press/src/appearance_tests.rs @@ -41,12 +41,19 @@ fn metadata_is_normalized_safe_and_shared_with_ssr() -> TestResult { 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)?; + 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(()) } @@ -160,7 +167,9 @@ fn legacy_and_content_contracts_remain_separate() -> TestResult { 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)?.is_empty()); + assert!(content + .head_tags(config.show, "/docs/appearance-init.js")? + .is_empty()); Ok(()) } @@ -197,6 +206,27 @@ fn build_emits_shared_artifacts_and_metadata_for_pages_and_404() -> TestResult { 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\"")); @@ -206,17 +236,22 @@ fn build_emits_shared_artifacts_and_metadata_for_pages_and_404() -> TestResult { 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 < appearance && appearance < custom); + 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\"")); diff --git a/crates/webui-press/src/build.rs b/crates/webui-press/src/build.rs index 0ec5726e5..fede9979a 100644 --- a/crates/webui-press/src/build.rs +++ b/crates/webui-press/src/build.rs @@ -17,7 +17,7 @@ use webui::BuildOptions; use webui_handler::{Protocol, RenderOptions, WebUIHandler}; use webui_tokens::TokenFile; -use crate::appearance::PreparedAppearance; +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, @@ -322,6 +322,7 @@ pub fn build_docs_with_cache( .and_then(|p| fs::read_to_string(p).ok()) .unwrap_or_default(); 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!(); @@ -352,7 +353,8 @@ pub fn build_docs_with_cache( }; let head_injection = { let mut parts: Vec = Vec::with_capacity(4 + config.head.len()); - let appearance_head = appearance.head_tags(config.show)?; + let appearance_head = + appearance.head_tags(config.show, &format!("{base_path}{appearance_bootstrap}"))?; if !appearance_head.is_empty() { parts.push(appearance_head); } @@ -470,6 +472,10 @@ pub fn build_docs_with_cache( } 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()) @@ -1548,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/template/appearance.ts b/crates/webui-press/template/appearance.ts index 8dab03b70..92fb53980 100644 --- a/crates/webui-press/template/appearance.ts +++ b/crates/webui-press/template/appearance.ts @@ -60,7 +60,7 @@ function createOwner(): Owner { const resolvedMode = root.getAttribute("data-theme"); if (!validTheme(theme) || !isMode(mode) || (resolvedMode !== "light" && resolvedMode !== "dark")) { - throw new Error("WebUI Press appearance bootstrap must run before the browser API."); + 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 }; diff --git a/docs/ai.md b/docs/ai.md index 205bcb50a..f5584b384 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -1608,6 +1608,8 @@ 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. diff --git a/docs/reference/webui-press.md b/docs/reference/webui-press.md index cdc0d9c49..96ae50952 100644 --- a/docs/reference/webui-press.md +++ b/docs/reference/webui-press.md @@ -218,11 +218,17 @@ Override `--docs-appearance-color`, `--docs-appearance-background`, `--docs-appearance-control-height`, or `--docs-appearance-theme-width` in your site CSS for control-specific styling. -Press restores saved preferences before external scripts load. Choosing System -resumes OS tracking without changing the selected design theme. Already-open -tabs on the same origin using the same storage keys adopt preference changes. -Storage failures produce a warning while selection continues in the current -page. Unknown saved family IDs use the configured default. +Press restores saved preferences with a small, shared same-origin JavaScript +asset loaded before stylesheet links and hydration. Deploy this generated asset +with the rest of the site and allow it in your Content Security Policy. It does not need +an inline-script exception or a per-request nonce; other generated page scripts +still need their own CSP authorization. + +Choosing System resumes OS tracking without changing the selected design +theme. Already-open tabs on the same origin using the same storage keys adopt +preference changes. Storage failures produce a warning while selection +continues in the current page. Unknown saved family IDs use the configured +default. To preserve preferences from an existing integration, configure its keys: diff --git a/packages/webui-press/tests/appearance.test.ts b/packages/webui-press/tests/appearance.test.ts index 17b3bea64..b750bfec4 100644 --- a/packages/webui-press/tests/appearance.test.ts +++ b/packages/webui-press/tests/appearance.test.ts @@ -6,6 +6,7 @@ import type { Page } from '@playwright/test'; import { buildSync } from 'esbuild'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; @@ -21,10 +22,12 @@ declare global { stopAppearance: () => void; transitionCount: number; themeMutations: number; + appearancePaints: { name: string; theme: string | null; mode: string | null }[]; } } -async function appearanceSite(t: TestContext, customHeader = false) { +async function appearanceSite(t: TestContext, options: { customHeader?: boolean; csp?: boolean } = {}) { + const { customHeader = false, csp = false } = options; const f = fixture(); t.after(() => removeFixtureRoot(f.root)); const probe = path.join(f.site, 'content/local/appearance-probe/appearance-probe'); @@ -60,7 +63,20 @@ window.stopAppearance = press.subscribeAppearance(value => window.updates.push(v })); build(f.site); const root = path.join(f.site, 'dist'); - const server = await startWebsite(root, '/fixture/'); + 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 = []; + for (const match of html.matchAll(/]*)>([\s\S]*?)<\/script>/gi)) { + if (/\bsrc\s*=|type=["']application\/json/i.test(match[1]) || match[2].trim() === bootstrap) continue; + hashes.push(`'sha256-${createHash('sha256').update(match[2]).digest('base64')}'`); + } + 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/` }; } @@ -72,6 +88,19 @@ async function ready(page: Page, url: string): Promise { 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)); @@ -107,6 +136,88 @@ test('full-shell sourced families validate bundled and authored CSS without chan }); }); +test('same-origin appearance initialization works under CSP without authorizing inline bootstrap', async t => { + const f = await appearanceSite(t, { csp: true }); + const browser = await chromium.launch(); + t.after(() => browser.close()); + 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(); @@ -290,7 +401,7 @@ test('bootstrap validates stored choices, preserves legacy keys and works withou { name: 'fixture-family', value: 'colorful' }, { name: 'fixture-mode', value: 'dark' }, ] }], } }); - await prepaint.route('**/*.js*', route => route.abort()); + 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'); @@ -403,7 +514,7 @@ test('native transitions are latest-wins, scoped, skippable and surface unexpect }); test('custom headers follow System before the API adopts the bootstrap listener', async t => { - const f = await appearanceSite(t, true); + 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, diff --git a/packages/webui-press/tests/website-fixture.ts b/packages/webui-press/tests/website-fixture.ts index ff21f551e..d9b22e543 100644 --- a/packages/webui-press/tests/website-fixture.ts +++ b/packages/webui-press/tests/website-fixture.ts @@ -54,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.'); @@ -84,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); }); From e4c2134a8b860f0a42a07ee2490bb3e9a0c51ef6 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 30 Oct 2026 15:11:58 -0700 Subject: [PATCH 5/5] fix: parse CSP test scripts with Chromium Collect inline script hashes from a script-disabled, network-blocked browser document instead of matching HTML with a regular expression. Cover whitespace-terminated end tags, quoted attributes, external scripts, inert JSON, and bootstrap exclusion. --- packages/webui-press/tests/appearance.test.ts | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/webui-press/tests/appearance.test.ts b/packages/webui-press/tests/appearance.test.ts index b750bfec4..e6787364c 100644 --- a/packages/webui-press/tests/appearance.test.ts +++ b/packages/webui-press/tests/appearance.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { chromium, expect } from '@playwright/test'; -import type { Page } from '@playwright/test'; +import type { Browser, Page } from '@playwright/test'; import { buildSync } from 'esbuild'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; @@ -26,8 +26,23 @@ declare global { } } -async function appearanceSite(t: TestContext, options: { customHeader?: boolean; csp?: boolean } = {}) { - const { customHeader = false, csp = false } = options; +async function inlineScriptHashes(browser: Browser, html: string, bootstrap: string): Promise { + const page = await browser.newPage({ javaScriptEnabled: false }); + try { + await page.route('**/*', route => route.abort()); + await page.setContent(html, { waitUntil: 'domcontentloaded' }); + const scripts = await page.evaluate(() => Array.from(document.scripts) + .filter(script => !script.hasAttribute('src') && script.type.trim().toLowerCase() !== 'application/json') + .map(script => script.textContent ?? '')); + return scripts.filter(script => script.trim() !== bootstrap) + .map(script => `'sha256-${createHash('sha256').update(script).digest('base64')}'`); + } finally { + await page.close(); + } +} + +async function appearanceSite(t: TestContext, options: { customHeader?: boolean; csp?: Browser } = {}) { + const { customHeader = false, csp } = options; const f = fixture(); t.after(() => removeFixtureRoot(f.root)); const probe = path.join(f.site, 'content/local/appearance-probe/appearance-probe'); @@ -69,11 +84,7 @@ window.stopAppearance = press.subscribeAppearance(value => window.updates.push(v const bootstrap = fs.readFileSync( path.resolve(import.meta.dirname, '../../../crates/webui-press/template/appearance-bootstrap.js'), 'utf8', ).trim(); - const hashes = []; - for (const match of html.matchAll(/]*)>([\s\S]*?)<\/script>/gi)) { - if (/\bsrc\s*=|type=["']application\/json/i.test(match[1]) || match[2].trim() === bootstrap) continue; - hashes.push(`'sha256-${createHash('sha256').update(match[2]).digest('base64')}'`); - } + const hashes = await inlineScriptHashes(csp, html, bootstrap); headers['content-security-policy'] = `script-src 'self' ${hashes.join(' ')}`; } const server = await startWebsite(root, '/fixture/', headers); @@ -136,10 +147,31 @@ test('full-shell sourced families validate bundled and authored CSS without chan }); }); +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 f = await appearanceSite(t, { csp: true }); 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: [