diff --git a/CONTRACT.md b/CONTRACT.md index d7e6872..ece48f6 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -2,7 +2,7 @@ ## Status -This document defines the draft `1.0` core catalog document shape. Catalog and package version `0.7.0` remain pre-1.0 and may change incompatibly before a stable release. The Rust catalog is distributed on crates.io; generated npm artifacts are maintained in this repository. User-imported provider packs use the separate contract in [`PROVIDER_PACKS.md`](./PROVIDER_PACKS.md); their custom-term assets never enter this core catalog. +This document defines the draft `1.0` core catalog document shape. Catalog and package version `0.8.0` remain pre-1.0 and may change incompatibly before a stable release. The Rust catalog is distributed on crates.io; generated npm artifacts are maintained in this repository. User-imported provider packs use the separate contract in [`PROVIDER_PACKS.md`](./PROVIDER_PACKS.md); their custom-term assets never enter this core catalog. The JSON Schema at [`schemas/catalog.schema.json`](./schemas/catalog.schema.json) is the machine-readable source of truth. [`catalog/catalog.json`](./catalog/catalog.json) is the only source catalog. Generated Cargo and npm copies must not be edited directly. The schema is copied into both packages; `$schema` is an editor-facing canonical repository URL and runtime consumers do not fetch it. @@ -45,6 +45,18 @@ Integer units keep Rust and JavaScript consumers from introducing representation `connector` requires line, text, and label-background palette references plus integer line width and arrow size. `dashMilliPx` is the only optional connector field; absence means a solid line. +## Configured theme overrides + +[`schemas/theme-overrides.schema.json`](./schemas/theme-overrides.schema.json) defines a map of at most 32 user theme definitions. Each map key is any valid Stack theme identifier, including `default`, `light`, or `dark`. A definition requires one `extends` value from those three built-in identifiers and at least one `palette` value. Palette values use the same six- or eight-digit hexadecimal sRGB format as the core catalog. Other theme records cannot be configured through this contract. + +Resolution always reads `extends` from the original built-in catalog before applying any configured definition. Configured themes therefore do not extend one another. A definition named `default` with `extends: default` intentionally shadows the built-in `default` without forming a cycle. Theme lookup checks configured definitions before the installed catalog, and a missing source-level theme selection requests the effective `default`, so the configured override applies there too. + +The resolver inherits typography, node fallbacks, connectors, and icons from the selected built-in theme. A definition that shadows a built-in theme keeps that theme's display metadata. A new identifier uses its identifier as the display name and omits the inherited description. Existing catalog positions are preserved for shadowed themes; new themes are appended in identifier order. + +The Rust resolver validates identifier, count, base, palette presence, and color constraints even when a caller does not run JSON Schema validation. It normalizes configured colors to uppercase hexadecimal. Transparent colors and contrast below the core review floors produce structured warnings; the resolver never silently replaces a user color. + +With no configured definitions, the effective revision is the base `catalogRevision`. Otherwise it is `sha256:` followed by the lowercase SHA-256 digest of `stack-theme-effective-v1`, a null byte, the base revision, a null byte, and compact JSON for the normalized definition map. Map keys use lexicographic order, definition fields use `extends` then `palette`, palette fields use contract order, absent fields are omitted, and colors use uppercase hexadecimal. The revision changes with either the built-in catalog or the effective user definition while remaining stable across input key order and color casing. + ## Font metrics A font metric record requires `id`, `family`, `version`, `unitsPerEm`, `ascent`, `descent`, `lineGap`, `defaultAdvance`, `wideAdvance`, ordered non-overlapping `wideRanges`, `glyphAdvances`, and the same source, license, and distribution `provenance` required for icon assets. @@ -69,7 +81,7 @@ SVG validation uses an element and attribute allowlist. It rejects scripts, even ## Cargo and npm boundary -Cargo `stack-theme` exposes typed Rust records, `catalog()`, `catalog_json()`, `catalog_schema_json()`, `provider_pack_schema_json()`, `icon_svg()`, `CATALOG_VERSION`, and `CATALOG_REVISION`. npm `@stack-sh/theme` exposes the equivalent frozen `catalog`, `providerPackSchema`, `iconAssets`, `iconSvg()`, `catalogVersion`, and `catalogRevision`, plus TypeScript declarations and both schema JSON subpath exports. Referenced core SVG and license files are copied into both package roots; core SVG bytes are also embedded behind the Rust and JavaScript accessors so runtime consumers never resolve catalog paths through the host. Provider-pack asset bytes are supplied by the user and are not embedded. +Cargo `stack-theme` exposes typed Rust records, `catalog()`, `catalog_json()`, `catalog_schema_json()`, `provider_pack_schema_json()`, `theme_overrides_schema_json()`, `resolve_theme_overrides()`, `icon_svg()`, `CATALOG_VERSION`, and `CATALOG_REVISION`. npm `@stack-sh/theme` exposes the equivalent frozen `catalog`, `providerPackSchema`, `themeOverridesSchema`, `iconAssets`, `iconSvg()`, `catalogVersion`, and `catalogRevision`, plus TypeScript declarations and all three schema JSON subpath exports. Referenced core SVG and license files are copied into both package roots; core SVG bytes are also embedded behind the Rust and JavaScript accessors so runtime consumers never resolve catalog paths through the host. Provider-pack asset bytes are supplied by the user and are not embedded. `npm run generate` validates the source catalog, checks package version equality, computes one revision, and updates both package artifacts. `npm run generate:check` fails when a generated artifact is missing or stale. Generated package data is checked into Git so Cargo and npm builds do not need network, filesystem discovery, Git, a clock, locale, or host font measurement at runtime. diff --git a/Cargo.lock b/Cargo.lock index ee4b360..fb3a767 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,77 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "memchr" version = "2.8.3" @@ -75,12 +140,24 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "stack-theme" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde", "serde_json", + "sha2", ] [[package]] @@ -94,6 +171,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index b75563a..2eaf340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,9 @@ edition = "2024" license = "Apache-2.0" repository = "https://github.com/stack-sh/theme" rust-version = "1.85" -version = "0.7.0" +version = "0.8.0" [workspace.dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "=0.11.0" diff --git a/README.md b/README.md index 50c761e..0691fe7 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ `stack-sh/theme` is the canonical public contract for Stack diagram themes, icons, font metrics, and local provider icon packs. -The core catalog contract is defined by [`CONTRACT.md`](./CONTRACT.md) and [`schemas/catalog.schema.json`](./schemas/catalog.schema.json). The separate local-only provider-pack contract is defined by [`PROVIDER_PACKS.md`](./PROVIDER_PACKS.md) and [`schemas/provider-pack.schema.json`](./schemas/provider-pack.schema.json). The canonical core source is [`catalog/catalog.json`](./catalog/catalog.json); Rust and JavaScript package trees are generated from that source with one content revision. +The core catalog and configured palette contract are defined by [`CONTRACT.md`](./CONTRACT.md), [`schemas/catalog.schema.json`](./schemas/catalog.schema.json), and [`schemas/theme-overrides.schema.json`](./schemas/theme-overrides.schema.json). The separate local-only provider-pack contract is defined by [`PROVIDER_PACKS.md`](./PROVIDER_PACKS.md) and [`schemas/provider-pack.schema.json`](./schemas/provider-pack.schema.json). The canonical core source is [`catalog/catalog.json`](./catalog/catalog.json); Rust and JavaScript package trees are generated from that source with one content revision. -The current `0.7.0` catalog contains the core `default`, `light`, and `dark` themes, repository-authored fallbacks for every Stack 1.0 node kind, 30 provider-neutral explicit icons, and versioned host-independent font metrics. The explicit icon catalog covers clients and compute, networking and delivery, data and events, development workflows, security, general collaboration tools, and AI systems. Its identifiers are `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. +The current `0.8.0` catalog contains the core `default`, `light`, and `dark` themes, repository-authored fallbacks for every Stack 1.0 node kind, 30 provider-neutral explicit icons, and versioned host-independent font metrics. It also exposes a palette-only resolver that lets user configuration add any valid theme name or intentionally shadow a built-in name while inheriting the built-in typography, node styles, connectors, and icons. The built-in palettes themselves are unchanged in this release. Provider-specific assets are not bundled. The provider-pack contract lets a CLI or browser validate an archive that the user explicitly selected from the provider's official source, keep it local, preserve the artwork, and carry source and terms notices into diagram output. @@ -13,7 +13,7 @@ Provider-specific assets are not bundled. The provider-pack contract lets a CLI Add the Rust catalog from crates.io with: ```sh -cargo add stack-theme@0.7.0 +cargo add stack-theme@0.8.0 ``` The package supports Rust 1.85 or newer and includes only the generated catalog API, repository-authored SVG assets, public schemas, package documentation, and the Apache-2.0 license. @@ -75,4 +75,4 @@ Repository-authored source code, catalog data, and assets are licensed under the Third-party fonts, icons, and other assets keep their own licenses and are not relicensed under Apache-2.0. Their provenance and redistribution terms must be recorded in [THIRD_PARTY_LICENSES.md](./THIRD_PARTY_LICENSES.md) before they are committed. Current provider icons are user-imported and are never committed or copied into the Cargo or npm packages. -Maintainers use the [trusted publishing procedure](./docs/releasing.md#ongoing-trusted-publishing) for subsequent crates.io releases. See the [0.7.0 release notes](./docs/releases/v0.7.0.md) for the cache card update. +Maintainers use the [trusted publishing procedure](./docs/releasing.md#ongoing-trusted-publishing) for subsequent crates.io releases. See the [0.8.0 release notes](./docs/releases/v0.8.0.md) for configured palette overrides. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index e071872..56fb567 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -11,8 +11,9 @@ The repository uses the following third-party libraries: | TypeScript | 7.0.2 | Development-only npm declaration checking | https://github.com/microsoft/TypeScript/tree/v7.0.2 | Microsoft Corporation | Apache-2.0 | Installed by `npm ci`; not included in `@stack-sh/theme` | | serde | 1.0.229 | Rust catalog deserialization and public data types | https://github.com/serde-rs/serde/tree/v1.0.229 | David Tolnay | MIT OR Apache-2.0 | Cargo dependency; no separately bundled asset | | serde_json | 1.0.151 | Embedded JSON catalog deserialization | https://github.com/serde-rs/json/tree/v1.0.151 | David Tolnay | MIT OR Apache-2.0 | Cargo dependency; no separately bundled asset | +| sha2 | 0.11.0 | Effective theme catalog revision hashing | https://github.com/RustCrypto/hashes/tree/sha2-v0.11.0/sha2 | RustCrypto Developers | MIT OR Apache-2.0 | Cargo dependency; no separately bundled asset | -Ajv, saxes, and TypeScript retain their upstream licenses in installed package metadata and the npm lockfile. serde and serde_json retain their upstream Cargo package metadata and license terms. No dependency source or license text is copied into either generated distribution package by this repository. +Ajv, saxes, and TypeScript retain their upstream licenses in installed package metadata and the npm lockfile. serde, serde_json, and sha2 retain their upstream Cargo package metadata and license terms. No dependency source or license text is copied into either generated distribution package by this repository. Before adding a third-party asset, record all of the following in this file: diff --git a/catalog/catalog.json b/catalog/catalog.json index ac9a2c9..b832961 100644 --- a/catalog/catalog.json +++ b/catalog/catalog.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.7.0", + "catalogVersion": "0.8.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/crates/stack-theme/Cargo.toml b/crates/stack-theme/Cargo.toml index 6c12e4c..b329f76 100644 --- a/crates/stack-theme/Cargo.toml +++ b/crates/stack-theme/Cargo.toml @@ -17,3 +17,4 @@ include = ["src/**", "assets/**", "licenses/**", "schema/**", "README.md", "LICE [dependencies] serde.workspace = true serde_json.workspace = true +sha2.workspace = true diff --git a/crates/stack-theme/README.md b/crates/stack-theme/README.md index 561d2ad..170d380 100644 --- a/crates/stack-theme/README.md +++ b/crates/stack-theme/README.md @@ -4,4 +4,4 @@ The public contract and compatibility policy are documented in the repository's [`CONTRACT.md`](https://github.com/stack-sh/theme/blob/main/CONTRACT.md). -Add version 0.7.0 from crates.io with `cargo add stack-theme@0.7.0`. The package supports Rust 1.85 or newer. +Add version 0.8.0 from crates.io with `cargo add stack-theme@0.8.0`. The package supports Rust 1.85 or newer. `resolve_theme_overrides()` applies palette-only user definitions over the original built-in catalog, including intentional overrides of `default`, `light`, and `dark`. diff --git a/crates/stack-theme/schema/catalog.schema.json b/crates/stack-theme/schema/catalog.schema.json index 736b83f..3d8bccc 100644 --- a/crates/stack-theme/schema/catalog.schema.json +++ b/crates/stack-theme/schema/catalog.schema.json @@ -54,7 +54,7 @@ }, "identifier": { "type": "string", - "pattern": "^[a-z][a-z0-9_-]{0,63}$" + "pattern": "^(?!.*--)[a-z][a-z0-9_-]{0,63}$" }, "iconIdentifier": { "type": "string", diff --git a/crates/stack-theme/schema/theme-overrides.schema.json b/crates/stack-theme/schema/theme-overrides.schema.json new file mode 100644 index 0000000..99e844a --- /dev/null +++ b/crates/stack-theme/schema/theme-overrides.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/theme-overrides.schema.json", + "title": "Stack Theme Overrides", + "description": "Palette-only user theme definitions applied over built-in Stack themes.", + "type": "object", + "maxProperties": 32, + "propertyNames": { + "$ref": "#/$defs/identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/themeOverride" + }, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^(?!.*--)[a-z][a-z0-9_-]{0,63}$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + "themeOverride": { + "type": "object", + "additionalProperties": false, + "required": ["extends", "palette"], + "properties": { + "extends": { + "enum": ["default", "light", "dark"] + }, + "palette": { + "$ref": "#/$defs/paletteOverride" + } + } + }, + "paletteOverride": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "canvas": { "$ref": "#/$defs/color" }, + "surface": { "$ref": "#/$defs/color" }, + "surfaceMuted": { "$ref": "#/$defs/color" }, + "text": { "$ref": "#/$defs/color" }, + "textMuted": { "$ref": "#/$defs/color" }, + "border": { "$ref": "#/$defs/color" }, + "accent": { "$ref": "#/$defs/color" }, + "danger": { "$ref": "#/$defs/color" }, + "connector": { "$ref": "#/$defs/color" } + } + } + } +} diff --git a/crates/stack-theme/src/generated/catalog.json b/crates/stack-theme/src/generated/catalog.json index 87eebb1..72017fb 100644 --- a/crates/stack-theme/src/generated/catalog.json +++ b/crates/stack-theme/src/generated/catalog.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.7.0", + "catalogVersion": "0.8.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/crates/stack-theme/src/generated/metadata.rs b/crates/stack-theme/src/generated/metadata.rs index 0c7a6a2..2737522 100644 --- a/crates/stack-theme/src/generated/metadata.rs +++ b/crates/stack-theme/src/generated/metadata.rs @@ -1,6 +1,6 @@ // Generated by scripts/generate.mjs. Do not edit. -pub const CATALOG_VERSION: &str = "0.7.0"; -pub const CATALOG_REVISION: &str = "sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a"; +pub const CATALOG_VERSION: &str = "0.8.0"; +pub const CATALOG_REVISION: &str = "sha256:e0d119e8048f74ebbc85e8cc34c1f25ccc5b4fe6cb8274c80a92271b81fa30ea"; pub fn icon_svg(asset_path: &str) -> Option<&'static str> { match asset_path { diff --git a/crates/stack-theme/src/lib.rs b/crates/stack-theme/src/lib.rs index 1d73a0f..ee98c6c 100644 --- a/crates/stack-theme/src/lib.rs +++ b/crates/stack-theme/src/lib.rs @@ -4,9 +4,11 @@ //! filesystem, network, clock, locale, or host-font access at runtime. use std::collections::BTreeMap; +use std::fmt::{self, Write}; use std::sync::OnceLock; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; mod generated { include!("generated/metadata.rs"); @@ -17,6 +19,7 @@ pub use generated::{CATALOG_REVISION, CATALOG_VERSION}; const CATALOG_JSON: &str = include_str!("generated/catalog.json"); const CATALOG_SCHEMA_JSON: &str = include_str!("../schema/catalog.schema.json"); const PROVIDER_PACK_SCHEMA_JSON: &str = include_str!("../schema/provider-pack.schema.json"); +const THEME_OVERRIDES_SCHEMA_JSON: &str = include_str!("../schema/theme-overrides.schema.json"); static CATALOG: OnceLock = OnceLock::new(); /// Returns the embedded catalog parsed into the public Rust contract. @@ -45,6 +48,12 @@ pub const fn provider_pack_schema_json() -> &'static str { PROVIDER_PACK_SCHEMA_JSON } +/// Returns the JSON Schema for palette-only user theme definitions. +#[must_use] +pub const fn theme_overrides_schema_json() -> &'static str { + THEME_OVERRIDES_SCHEMA_JSON +} + /// Returns one validated SVG asset by its catalog path. /// /// The bytes are embedded at compile time; this function never reads the host @@ -161,6 +170,391 @@ pub struct Palette { pub connector: String, } +/// Palette-only theme definitions supplied by one user configuration. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct ThemeOverrides(pub BTreeMap); + +/// One user theme definition resolved from a built-in theme. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ThemeOverride { + pub extends: BuiltinThemeId, + pub palette: PaletteOverride, +} + +/// Built-in themes that may supply non-palette records to a user theme. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BuiltinThemeId { + Default, + Light, + Dark, +} + +impl BuiltinThemeId { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Light => "light", + Self::Dark => "dark", + } + } +} + +/// Semantic color slots changed by one user theme definition. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PaletteOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub surface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub surface_muted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_muted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub border: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub accent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub danger: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub connector: Option, +} + +impl PaletteOverride { + fn is_empty(&self) -> bool { + self.canvas.is_none() + && self.surface.is_none() + && self.surface_muted.is_none() + && self.text.is_none() + && self.text_muted.is_none() + && self.border.is_none() + && self.accent.is_none() + && self.danger.is_none() + && self.connector.is_none() + } + + fn normalized(&self) -> Result { + macro_rules! normalized_slot { + ($field:ident, $token:literal) => { + self.$field + .as_deref() + .map(|value| { + normalize_color(value).ok_or_else(|| { + ThemeOverrideError::new(format!( + "palette.{} must be a six- or eight-digit hexadecimal color", + $token + )) + }) + }) + .transpose()? + }; + } + + Ok(Self { + canvas: normalized_slot!(canvas, "canvas"), + surface: normalized_slot!(surface, "surface"), + surface_muted: normalized_slot!(surface_muted, "surfaceMuted"), + text: normalized_slot!(text, "text"), + text_muted: normalized_slot!(text_muted, "textMuted"), + border: normalized_slot!(border, "border"), + accent: normalized_slot!(accent, "accent"), + danger: normalized_slot!(danger, "danger"), + connector: normalized_slot!(connector, "connector"), + }) + } + + fn apply_to(&self, palette: &mut Palette) { + macro_rules! apply_slot { + ($field:ident) => { + if let Some(value) = &self.$field { + palette.$field.clone_from(value); + } + }; + } + + apply_slot!(canvas); + apply_slot!(surface); + apply_slot!(surface_muted); + apply_slot!(text); + apply_slot!(text_muted); + apply_slot!(border); + apply_slot!(accent); + apply_slot!(danger); + apply_slot!(connector); + } +} + +/// A catalog with all configured themes applied and a reproducible identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedThemeCatalog { + pub catalog: Catalog, + pub revision: String, + pub warnings: Vec, +} + +/// A non-fatal usability concern found in one configured palette. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ThemeOverrideWarning { + pub code: String, + pub theme_id: String, + pub message: String, +} + +/// A theme definition that cannot be resolved safely and deterministically. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ThemeOverrideError { + reason: String, +} + +impl ThemeOverrideError { + fn new(reason: impl Into) -> Self { + Self { + reason: reason.into(), + } + } + + #[must_use] + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl fmt::Display for ThemeOverrideError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.reason) + } +} + +impl std::error::Error for ThemeOverrideError {} + +/// Applies user palette definitions over an immutable built-in catalog. +/// +/// Every `extends` lookup uses `base_catalog`, including when the configured +/// name shadows a built-in theme. This makes `default extends default` an +/// intentional override instead of a recursive definition. +pub fn resolve_theme_overrides( + base_catalog: &Catalog, + base_revision: &str, + overrides: &ThemeOverrides, +) -> Result { + if overrides.0.len() > 32 { + return Err(ThemeOverrideError::new( + "theme overrides may contain at most 32 definitions", + )); + } + if overrides.0.is_empty() { + return Ok(ResolvedThemeCatalog { + catalog: base_catalog.clone(), + revision: base_revision.to_owned(), + warnings: Vec::new(), + }); + } + + let mut normalized = BTreeMap::new(); + let mut resolved = BTreeMap::new(); + let mut warnings = Vec::new(); + + for (theme_id, definition) in &overrides.0 { + if !is_theme_identifier(theme_id) { + return Err(ThemeOverrideError::new(format!( + "theme identifier {theme_id:?} is invalid" + ))); + } + if definition.palette.is_empty() { + return Err(ThemeOverrideError::new(format!( + "theme {theme_id} must override at least one palette color" + ))); + } + + let base_id = definition.extends.as_str(); + let Some(base_theme) = base_catalog.themes.iter().find(|theme| theme.id == base_id) else { + return Err(ThemeOverrideError::new(format!( + "built-in theme {base_id} is unavailable" + ))); + }; + let normalized_palette = definition.palette.normalized().map_err(|error| { + ThemeOverrideError::new(format!("theme {theme_id}: {}", error.reason())) + })?; + let normalized_definition = ThemeOverride { + extends: definition.extends, + palette: normalized_palette, + }; + + let mut theme = base_theme.clone(); + if theme.id != *theme_id { + theme.name.clone_from(theme_id); + theme.description = None; + } + theme.id.clone_from(theme_id); + normalized_definition.palette.apply_to(&mut theme.palette); + warnings.extend(palette_warnings(theme_id, &theme.palette)); + normalized.insert(theme_id.clone(), normalized_definition); + resolved.insert(theme_id.clone(), theme); + } + + let mut effective_catalog = base_catalog.clone(); + for theme in &mut effective_catalog.themes { + if let Some(configured) = resolved.remove(&theme.id) { + *theme = configured; + } + } + effective_catalog.themes.extend(resolved.into_values()); + + let normalized_json = serde_json::to_vec(&ThemeOverrides(normalized)) + .expect("theme overrides contain only serializable public records"); + let mut hash = Sha256::new(); + hash.update(b"stack-theme-effective-v1\0"); + hash.update(base_revision.as_bytes()); + hash.update(b"\0"); + hash.update(normalized_json); + + let mut revision = String::with_capacity(71); + revision.push_str("sha256:"); + for byte in hash.finalize() { + write!(&mut revision, "{byte:02x}").expect("writing to a string cannot fail"); + } + + Ok(ResolvedThemeCatalog { + catalog: effective_catalog, + revision, + warnings, + }) +} + +fn is_theme_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && bytes[0].is_ascii_lowercase() + && bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' || *byte == b'-' + }) + && !value.contains("--") +} + +fn normalize_color(value: &str) -> Option { + let bytes = value.as_bytes(); + if (bytes.len() == 7 || bytes.len() == 9) + && bytes[0] == b'#' + && bytes[1..].iter().all(u8::is_ascii_hexdigit) + { + Some(value.to_ascii_uppercase()) + } else { + None + } +} + +fn palette_warnings(theme_id: &str, palette: &Palette) -> Vec { + let colors = [ + ("canvas", &palette.canvas), + ("surface", &palette.surface), + ("surfaceMuted", &palette.surface_muted), + ("text", &palette.text), + ("textMuted", &palette.text_muted), + ("border", &palette.border), + ("accent", &palette.accent), + ("danger", &palette.danger), + ("connector", &palette.connector), + ]; + let mut warnings = Vec::new(); + for (token, color) in colors { + if has_transparency(color) { + warnings.push(ThemeOverrideWarning { + code: "theme-transparent-color".to_owned(), + theme_id: theme_id.to_owned(), + message: format!( + "palette.{token} uses transparency; contrast depends on its rendered backdrop" + ), + }); + } + } + + for (foreground, background, minimum) in [ + ("text", "surface", 4.5), + ("textMuted", "surface", 4.5), + ("danger", "surface", 4.5), + ("border", "surface", 3.0), + ("accent", "surface", 3.0), + ("connector", "canvas", 3.0), + ] { + let foreground_color = palette_color(palette, foreground); + let background_color = palette_color(palette, background); + let (Some(foreground_rgb), Some(background_rgb)) = + (opaque_rgb(foreground_color), opaque_rgb(background_color)) + else { + continue; + }; + let ratio = contrast_ratio(foreground_rgb, background_rgb); + if ratio < minimum { + warnings.push(ThemeOverrideWarning { + code: "theme-low-contrast".to_owned(), + theme_id: theme_id.to_owned(), + message: format!( + "palette.{foreground} against palette.{background} has {ratio:.2}:1 contrast; expected at least {minimum:.1}:1" + ), + }); + } + } + warnings +} + +fn palette_color<'a>(palette: &'a Palette, token: &str) -> &'a str { + match token { + "canvas" => &palette.canvas, + "surface" => &palette.surface, + "text" => &palette.text, + "textMuted" => &palette.text_muted, + "border" => &palette.border, + "accent" => &palette.accent, + "danger" => &palette.danger, + "connector" => &palette.connector, + _ => unreachable!("contrast pairs use known palette tokens"), + } +} + +fn opaque_rgb(value: &str) -> Option<[u8; 3]> { + if normalize_color(value).is_none() || has_transparency(value) { + return None; + } + Some([ + u8::from_str_radix(value.get(1..3)?, 16).ok()?, + u8::from_str_radix(value.get(3..5)?, 16).ok()?, + u8::from_str_radix(value.get(5..7)?, 16).ok()?, + ]) +} + +fn has_transparency(value: &str) -> bool { + value.len() == 9 + && !value + .get(7..9) + .is_some_and(|alpha| alpha.eq_ignore_ascii_case("ff")) +} + +fn contrast_ratio(left: [u8; 3], right: [u8; 3]) -> f64 { + let left = relative_luminance(left); + let right = relative_luminance(right); + (left.max(right) + 0.05) / (left.min(right) + 0.05) +} + +fn relative_luminance(color: [u8; 3]) -> f64 { + let channels = color.map(|channel| { + let value = f64::from(channel) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }); + channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722 +} + /// Typography values expressed without platform font measurement. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -515,6 +909,148 @@ mod tests { provider_schema["$id"], "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json" ); + let theme_overrides_schema: serde_json::Value = + serde_json::from_str(theme_overrides_schema_json()).unwrap(); + assert_eq!( + theme_overrides_schema["$id"], + "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/theme-overrides.schema.json" + ); + } + + #[test] + fn configured_themes_override_builtins_without_recursive_extends() { + let overrides: ThemeOverrides = serde_json::from_str( + r##"{ + "custom-theme":{"extends":"light","palette":{"accent":"#005DBB","connector":"#334155"}}, + "default":{"extends":"default","palette":{"canvas":"#F7F8FA"}} + }"##, + ) + .unwrap(); + let resolved = resolve_theme_overrides(catalog(), CATALOG_REVISION, &overrides).unwrap(); + + let default = resolved + .catalog + .themes + .iter() + .find(|theme| theme.id == "default") + .unwrap(); + let original_default = catalog() + .themes + .iter() + .find(|theme| theme.id == "default") + .unwrap(); + assert_eq!(default.palette.canvas, "#F7F8FA"); + assert_eq!(default.typography, original_default.typography); + assert_eq!( + default.node_kind_fallbacks, + original_default.node_kind_fallbacks + ); + + let custom = resolved + .catalog + .themes + .iter() + .find(|theme| theme.id == "custom-theme") + .unwrap(); + let original_light = catalog() + .themes + .iter() + .find(|theme| theme.id == "light") + .unwrap(); + assert_eq!(custom.name, "custom-theme"); + assert_eq!(custom.palette.accent, "#005DBB"); + assert_eq!(custom.palette.canvas, original_light.palette.canvas); + assert_eq!(custom.typography, original_light.typography); + assert_ne!(resolved.revision, CATALOG_REVISION); + assert!(resolved.revision.starts_with("sha256:")); + } + + #[test] + fn effective_revision_uses_normalized_definition_order_and_colors() { + let left: ThemeOverrides = serde_json::from_str( + r##"{ + "z_theme": {"extends":"dark","palette":{"accent":"#aabbcc"}}, + "a_theme": {"extends":"light","palette":{"canvas":"#123456"}} + }"##, + ) + .unwrap(); + let right: ThemeOverrides = serde_json::from_str( + r##"{ + "a_theme": {"extends":"light","palette":{"canvas":"#123456"}}, + "z_theme": {"extends":"dark","palette":{"accent":"#AABBCC"}} + }"##, + ) + .unwrap(); + + let left = resolve_theme_overrides(catalog(), CATALOG_REVISION, &left).unwrap(); + let right = resolve_theme_overrides(catalog(), CATALOG_REVISION, &right).unwrap(); + assert_eq!(left.revision, right.revision); + assert_eq!(left.catalog, right.catalog); + } + + #[test] + fn empty_overrides_preserve_the_base_catalog_identity() { + let resolved = + resolve_theme_overrides(catalog(), CATALOG_REVISION, &ThemeOverrides::default()) + .unwrap(); + assert_eq!(resolved.catalog, *catalog()); + assert_eq!(resolved.revision, CATALOG_REVISION); + assert!(resolved.warnings.is_empty()); + } + + #[test] + fn invalid_theme_definitions_fail_before_resolution() { + for (source, expected) in [ + ( + r##"{"invalid--name":{"extends":"default","palette":{"accent":"#000000"}}}"##, + "identifier", + ), + ( + r##"{"empty":{"extends":"default","palette":{}}}"##, + "at least one", + ), + ( + r##"{"bad_color":{"extends":"default","palette":{"accent":"red"}}}"##, + "hexadecimal color", + ), + ] { + let overrides: ThemeOverrides = serde_json::from_str(source).unwrap(); + let error = + resolve_theme_overrides(catalog(), CATALOG_REVISION, &overrides).unwrap_err(); + assert!(error.reason().contains(expected), "{}", error.reason()); + } + } + + #[test] + fn configured_palette_concerns_are_warnings_and_colors_are_unchanged() { + let overrides: ThemeOverrides = serde_json::from_str( + r##"{ + "soft":{"extends":"light","palette":{"text":"#ffffff"}}, + "glass":{"extends":"dark","palette":{"canvas":"#11223380"}} + }"##, + ) + .unwrap(); + let resolved = resolve_theme_overrides(catalog(), CATALOG_REVISION, &overrides).unwrap(); + + assert!( + resolved.warnings.iter().any(|warning| { + warning.theme_id == "soft" && warning.code == "theme-low-contrast" + }) + ); + assert!(resolved.warnings.iter().any(|warning| { + warning.theme_id == "glass" && warning.code == "theme-transparent-color" + })); + assert_eq!( + resolved + .catalog + .themes + .iter() + .find(|theme| theme.id == "soft") + .unwrap() + .palette + .text, + "#FFFFFF" + ); } #[test] diff --git a/docs/releases/v0.8.0.md b/docs/releases/v0.8.0.md new file mode 100644 index 0000000..83ada2b --- /dev/null +++ b/docs/releases/v0.8.0.md @@ -0,0 +1,7 @@ +# Stack Theme 0.8.0 + +This release adds a public schema and Rust resolver for palette-only user theme definitions. Configured themes may use any valid Stack theme identifier, including an intentional override of `default`, `light`, or `dark`. Every `extends` value resolves from the original built-in catalog, so `default extends default` is deterministic and does not recurse. + +The resolver preserves built-in typography, node styles, connectors, and icons, normalizes configured hexadecimal colors, emits warnings for transparency and low contrast, and never rewrites a user's selected color. An effective catalog revision identifies the combination of the built-in catalog and normalized user definitions. + +The built-in `default`, `light`, and `dark` palette values are unchanged. A future core palette redesign is a system catalog change rather than a user configuration change. diff --git a/package-lock.json b/package-lock.json index 521e940..0b891f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stack-theme-workspace", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stack-theme-workspace", - "version": "0.7.0", + "version": "0.8.0", "workspaces": [ "packages/theme" ], @@ -475,7 +475,7 @@ }, "packages/theme": { "name": "@stack-sh/theme", - "version": "0.6.0", + "version": "0.8.0", "license": "Apache-2.0" } } diff --git a/package.json b/package.json index f7334fb..314c205 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stack-theme-workspace", "private": true, - "version": "0.7.0", + "version": "0.8.0", "workspaces": [ "packages/theme" ], diff --git a/packages/theme/README.md b/packages/theme/README.md index 932ea12..fdfe8ec 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -4,6 +4,8 @@ The package is browser-safe and performs no filesystem, network, clock, locale, or host-font access. See the repository's [`CONTRACT.md`](https://github.com/stack-sh/theme/blob/main/CONTRACT.md) for the core catalog and [`PROVIDER_PACKS.md`](https://github.com/stack-sh/theme/blob/main/PROVIDER_PACKS.md) for the local provider-pack contract. -Catalog `0.7.0` includes 30 provider-neutral explicit icons shared by the `default`, `light`, and `dark` themes: `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. Resolve a core icon's catalog asset path through `iconSvg()`; do not treat a logical icon identifier as a filesystem path or URL. +Catalog `0.8.0` includes 30 provider-neutral explicit icons shared by the `default`, `light`, and `dark` themes. Resolve a core icon's catalog asset path through `iconSvg()`; do not treat a logical icon identifier as a filesystem path or URL. + +`themeOverridesSchema` describes palette-only user theme definitions. A user may add any valid theme identifier or intentionally shadow `default`, `light`, or `dark`; `extends` always reads one of the original built-in themes. The Rust package owns resolution and warning behavior, while this generated package exposes the shared schema and TypeScript types for browser configuration editors. `providerPackSchema` describes manifests produced from a provider archive that the user explicitly imports. It requires local-only processing, disabled package redistribution, provider-prefixed icon IDs, source and processed hashes, artwork-preservation policy, and user-visible terms notices. The package contains no vendor asset bytes and never downloads or uploads an archive. diff --git a/packages/theme/catalog-metadata.json b/packages/theme/catalog-metadata.json index a5ffc34..57cf89b 100644 --- a/packages/theme/catalog-metadata.json +++ b/packages/theme/catalog-metadata.json @@ -1,4 +1,4 @@ { - "catalogVersion": "0.7.0", - "catalogRevision": "sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a" + "catalogVersion": "0.8.0", + "catalogRevision": "sha256:e0d119e8048f74ebbc85e8cc34c1f25ccc5b4fe6cb8274c80a92271b81fa30ea" } diff --git a/packages/theme/catalog.generated.js b/packages/theme/catalog.generated.js index b3dcd5e..cb4bc15 100644 --- a/packages/theme/catalog.generated.js +++ b/packages/theme/catalog.generated.js @@ -2,7 +2,7 @@ const catalogData = { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.7.0", + "catalogVersion": "0.8.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", @@ -4185,6 +4185,84 @@ const providerPackSchemaData = { } } }; +const themeOverridesSchemaData = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/theme-overrides.schema.json", + "title": "Stack Theme Overrides", + "description": "Palette-only user theme definitions applied over built-in Stack themes.", + "type": "object", + "maxProperties": 32, + "propertyNames": { + "$ref": "#/$defs/identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/themeOverride" + }, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^(?!.*--)[a-z][a-z0-9_-]{0,63}$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + "themeOverride": { + "type": "object", + "additionalProperties": false, + "required": [ + "extends", + "palette" + ], + "properties": { + "extends": { + "enum": [ + "default", + "light", + "dark" + ] + }, + "palette": { + "$ref": "#/$defs/paletteOverride" + } + } + }, + "paletteOverride": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "canvas": { + "$ref": "#/$defs/color" + }, + "surface": { + "$ref": "#/$defs/color" + }, + "surfaceMuted": { + "$ref": "#/$defs/color" + }, + "text": { + "$ref": "#/$defs/color" + }, + "textMuted": { + "$ref": "#/$defs/color" + }, + "border": { + "$ref": "#/$defs/color" + }, + "accent": { + "$ref": "#/$defs/color" + }, + "danger": { + "$ref": "#/$defs/color" + }, + "connector": { + "$ref": "#/$defs/color" + } + } + } + } +}; const iconAssetsData = { "assets/core/ai.svg": "\n \n \n \n\n", "assets/core/analytics.svg": "\n \n \n \n\n", @@ -4260,8 +4338,9 @@ function deepFreeze(value) { export const catalog = deepFreeze(catalogData); export const providerPackSchema = deepFreeze(providerPackSchemaData); -export const catalogVersion = "0.7.0"; -export const catalogRevision = "sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a"; +export const themeOverridesSchema = deepFreeze(themeOverridesSchemaData); +export const catalogVersion = "0.8.0"; +export const catalogRevision = "sha256:e0d119e8048f74ebbc85e8cc34c1f25ccc5b4fe6cb8274c80a92271b81fa30ea"; export const iconAssets = deepFreeze(iconAssetsData); export function iconSvg(assetPath) { return iconAssets[assetPath]; diff --git a/packages/theme/catalog.json b/packages/theme/catalog.json index 87eebb1..72017fb 100644 --- a/packages/theme/catalog.json +++ b/packages/theme/catalog.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.7.0", + "catalogVersion": "0.8.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/packages/theme/index.d.ts b/packages/theme/index.d.ts index 784ea12..b41991b 100644 --- a/packages/theme/index.d.ts +++ b/packages/theme/index.d.ts @@ -74,6 +74,17 @@ export interface Palette { readonly connector: string; } +export type BuiltinThemeId = "default" | "light" | "dark"; + +export type ThemeOverrides = Readonly>; + +export interface ThemeOverride { + readonly extends: BuiltinThemeId; + readonly palette: PaletteOverride; +} + +export type PaletteOverride = Readonly>; + export interface Typography { readonly fontMetricsId: string; readonly nodeLabelSizeMilliPx: number; @@ -256,6 +267,7 @@ export interface ProviderIconAsset { export declare const catalog: Readonly; export declare const providerPackSchema: Readonly>; +export declare const themeOverridesSchema: Readonly>; export declare const catalogVersion: string; export declare const catalogRevision: `sha256:${string}`; export declare const iconAssets: Readonly>; diff --git a/packages/theme/index.js b/packages/theme/index.js index fd9ecab..c63a90a 100644 --- a/packages/theme/index.js +++ b/packages/theme/index.js @@ -5,4 +5,5 @@ export { iconAssets, iconSvg, providerPackSchema, + themeOverridesSchema, } from "./catalog.generated.js"; diff --git a/packages/theme/package.json b/packages/theme/package.json index 10711a6..9702fba 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,6 +1,6 @@ { "name": "@stack-sh/theme", - "version": "0.7.0", + "version": "0.8.0", "description": "Canonical Stack theme catalog contract and embedded catalog data", "type": "module", "license": "Apache-2.0", @@ -18,7 +18,8 @@ "./catalog.json": "./catalog.json", "./catalog-metadata.json": "./catalog-metadata.json", "./schema/catalog.schema.json": "./schema/catalog.schema.json", - "./schema/provider-pack.schema.json": "./schema/provider-pack.schema.json" + "./schema/provider-pack.schema.json": "./schema/provider-pack.schema.json", + "./schema/theme-overrides.schema.json": "./schema/theme-overrides.schema.json" }, "files": [ "assets", diff --git a/packages/theme/schema/catalog.schema.json b/packages/theme/schema/catalog.schema.json index 736b83f..3d8bccc 100644 --- a/packages/theme/schema/catalog.schema.json +++ b/packages/theme/schema/catalog.schema.json @@ -54,7 +54,7 @@ }, "identifier": { "type": "string", - "pattern": "^[a-z][a-z0-9_-]{0,63}$" + "pattern": "^(?!.*--)[a-z][a-z0-9_-]{0,63}$" }, "iconIdentifier": { "type": "string", diff --git a/packages/theme/schema/theme-overrides.schema.json b/packages/theme/schema/theme-overrides.schema.json new file mode 100644 index 0000000..99e844a --- /dev/null +++ b/packages/theme/schema/theme-overrides.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/theme-overrides.schema.json", + "title": "Stack Theme Overrides", + "description": "Palette-only user theme definitions applied over built-in Stack themes.", + "type": "object", + "maxProperties": 32, + "propertyNames": { + "$ref": "#/$defs/identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/themeOverride" + }, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^(?!.*--)[a-z][a-z0-9_-]{0,63}$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + "themeOverride": { + "type": "object", + "additionalProperties": false, + "required": ["extends", "palette"], + "properties": { + "extends": { + "enum": ["default", "light", "dark"] + }, + "palette": { + "$ref": "#/$defs/paletteOverride" + } + } + }, + "paletteOverride": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "canvas": { "$ref": "#/$defs/color" }, + "surface": { "$ref": "#/$defs/color" }, + "surfaceMuted": { "$ref": "#/$defs/color" }, + "text": { "$ref": "#/$defs/color" }, + "textMuted": { "$ref": "#/$defs/color" }, + "border": { "$ref": "#/$defs/color" }, + "accent": { "$ref": "#/$defs/color" }, + "danger": { "$ref": "#/$defs/color" }, + "connector": { "$ref": "#/$defs/color" } + } + } + } +} diff --git a/schemas/catalog.schema.json b/schemas/catalog.schema.json index 736b83f..3d8bccc 100644 --- a/schemas/catalog.schema.json +++ b/schemas/catalog.schema.json @@ -54,7 +54,7 @@ }, "identifier": { "type": "string", - "pattern": "^[a-z][a-z0-9_-]{0,63}$" + "pattern": "^(?!.*--)[a-z][a-z0-9_-]{0,63}$" }, "iconIdentifier": { "type": "string", diff --git a/schemas/theme-overrides.schema.json b/schemas/theme-overrides.schema.json new file mode 100644 index 0000000..99e844a --- /dev/null +++ b/schemas/theme-overrides.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/theme-overrides.schema.json", + "title": "Stack Theme Overrides", + "description": "Palette-only user theme definitions applied over built-in Stack themes.", + "type": "object", + "maxProperties": 32, + "propertyNames": { + "$ref": "#/$defs/identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/themeOverride" + }, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^(?!.*--)[a-z][a-z0-9_-]{0,63}$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + "themeOverride": { + "type": "object", + "additionalProperties": false, + "required": ["extends", "palette"], + "properties": { + "extends": { + "enum": ["default", "light", "dark"] + }, + "palette": { + "$ref": "#/$defs/paletteOverride" + } + } + }, + "paletteOverride": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "canvas": { "$ref": "#/$defs/color" }, + "surface": { "$ref": "#/$defs/color" }, + "surfaceMuted": { "$ref": "#/$defs/color" }, + "text": { "$ref": "#/$defs/color" }, + "textMuted": { "$ref": "#/$defs/color" }, + "border": { "$ref": "#/$defs/color" }, + "accent": { "$ref": "#/$defs/color" }, + "danger": { "$ref": "#/$defs/color" }, + "connector": { "$ref": "#/$defs/color" } + } + } + } +} diff --git a/scripts/catalog-lib.mjs b/scripts/catalog-lib.mjs index 8d3b0fc..fde73ed 100644 --- a/scripts/catalog-lib.mjs +++ b/scripts/catalog-lib.mjs @@ -430,6 +430,22 @@ export async function validateCatalog( return catalog; } +export async function validateThemeOverrides(themeOverrides) { + const schema = await readJson( + path.join(repositoryRoot, "schemas/theme-overrides.schema.json"), + ); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + const validate = ajv.compile(schema); + if (!validate(themeOverrides)) { + const details = validate.errors + .map((error) => `${error.instancePath || "/"} ${error.message}`) + .join("; "); + fail(`theme overrides schema validation failed: ${details}`); + } + + return themeOverrides; +} + export async function validateProviderPack( providerPack, { root = repositoryRoot, validateAssets = true } = {}, diff --git a/scripts/generate.mjs b/scripts/generate.mjs index 0f4a5e4..97e13dd 100644 --- a/scripts/generate.mjs +++ b/scripts/generate.mjs @@ -51,6 +51,11 @@ const providerPackSchemaText = await readFile( "utf8", ); const providerPackSchema = JSON.parse(providerPackSchemaText); +const themeOverridesSchemaText = await readFile( + path.join(repositoryRoot, "schemas/theme-overrides.schema.json"), + "utf8", +); +const themeOverridesSchema = JSON.parse(themeOverridesSchemaText); const assetEntriesByPath = new Map(); const licensePaths = new Set(["LICENSE"]); for (const metrics of catalog.fontMetrics) { @@ -77,7 +82,7 @@ const assetEntries = [...assetEntriesByPath].sort(([left], [right]) => ); const assetObject = Object.fromEntries(assetEntries); -const generatedJavaScript = `// Generated by scripts/generate.mjs. Do not edit.\nconst catalogData = ${JSON.stringify(catalog, null, 2)};\nconst providerPackSchemaData = ${JSON.stringify(providerPackSchema, null, 2)};\nconst iconAssetsData = ${JSON.stringify(assetObject, null, 2)};\n\nfunction deepFreeze(value) {\n Object.freeze(value);\n for (const child of Object.values(value)) {\n if (child !== null && typeof child === "object" && !Object.isFrozen(child)) {\n deepFreeze(child);\n }\n }\n return value;\n}\n\nexport const catalog = deepFreeze(catalogData);\nexport const providerPackSchema = deepFreeze(providerPackSchemaData);\nexport const catalogVersion = ${JSON.stringify(catalog.catalogVersion)};\nexport const catalogRevision = ${JSON.stringify(revision)};\nexport const iconAssets = deepFreeze(iconAssetsData);\nexport function iconSvg(assetPath) {\n return iconAssets[assetPath];\n}\n`; +const generatedJavaScript = `// Generated by scripts/generate.mjs. Do not edit.\nconst catalogData = ${JSON.stringify(catalog, null, 2)};\nconst providerPackSchemaData = ${JSON.stringify(providerPackSchema, null, 2)};\nconst themeOverridesSchemaData = ${JSON.stringify(themeOverridesSchema, null, 2)};\nconst iconAssetsData = ${JSON.stringify(assetObject, null, 2)};\n\nfunction deepFreeze(value) {\n Object.freeze(value);\n for (const child of Object.values(value)) {\n if (child !== null && typeof child === "object" && !Object.isFrozen(child)) {\n deepFreeze(child);\n }\n }\n return value;\n}\n\nexport const catalog = deepFreeze(catalogData);\nexport const providerPackSchema = deepFreeze(providerPackSchemaData);\nexport const themeOverridesSchema = deepFreeze(themeOverridesSchemaData);\nexport const catalogVersion = ${JSON.stringify(catalog.catalogVersion)};\nexport const catalogRevision = ${JSON.stringify(revision)};\nexport const iconAssets = deepFreeze(iconAssetsData);\nexport function iconSvg(assetPath) {\n return iconAssets[assetPath];\n}\n`; const rustAssetArms = assetEntries .map( ([assetPath]) => @@ -94,11 +99,13 @@ const artifacts = new Map([ ["crates/stack-theme/LICENSE", licenseText], ["crates/stack-theme/schema/catalog.schema.json", schemaText], ["crates/stack-theme/schema/provider-pack.schema.json", providerPackSchemaText], + ["crates/stack-theme/schema/theme-overrides.schema.json", themeOverridesSchemaText], ["crates/stack-theme/src/generated/catalog.json", catalogJson], ["crates/stack-theme/src/generated/metadata.rs", generatedRust], ["packages/theme/LICENSE", licenseText], ["packages/theme/schema/catalog.schema.json", schemaText], ["packages/theme/schema/provider-pack.schema.json", providerPackSchemaText], + ["packages/theme/schema/theme-overrides.schema.json", themeOverridesSchemaText], ["packages/theme/catalog.json", catalogJson], ["packages/theme/catalog-metadata.json", metadataJson], ["packages/theme/catalog.generated.js", generatedJavaScript], diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 9903302..41e9138 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -5,6 +5,7 @@ import { repositoryRoot, validateCatalog, validateProviderPack, + validateThemeOverrides, } from "./catalog-lib.mjs"; const catalogs = [ @@ -28,3 +29,10 @@ for (const fixture of ["valid.json", "multi-source.json"]) { }); console.log(`validated tests/fixtures/provider-pack/${fixture}`); } + +await validateThemeOverrides( + await readJson( + path.join(repositoryRoot, "tests/fixtures/theme-overrides/valid.json"), + ), +); +console.log("validated tests/fixtures/theme-overrides/valid.json"); diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs index 0524cf1..a441851 100644 --- a/tests/catalog.test.mjs +++ b/tests/catalog.test.mjs @@ -9,6 +9,7 @@ import { validateCatalog, validateProviderPack, validateSvgText, + validateThemeOverrides, } from "../scripts/catalog-lib.mjs"; import { catalog, @@ -17,6 +18,7 @@ import { iconAssets, iconSvg, providerPackSchema, + themeOverridesSchema, } from "../packages/theme/index.js"; test("the complete contract fixture is valid", async () => { @@ -26,6 +28,45 @@ test("the complete contract fixture is valid", async () => { await validateCatalog(fixture); }); +test("palette-only theme overrides accept custom and built-in names", async () => { + const fixture = await readJson( + path.join(repositoryRoot, "tests/fixtures/theme-overrides/valid.json"), + ); + await validateThemeOverrides(fixture); +}); + +for (const [name, mutate] of [ + ["consecutive hyphens", (fixture) => (fixture["invalid--name"] = fixture.default)], + ["unknown base", (fixture) => (fixture.default.extends = "custom-theme")], + ["empty palette", (fixture) => (fixture.default.palette = {})], + ["unknown palette token", (fixture) => (fixture.default.palette.shadow = "#000000")], + ["invalid color", (fixture) => (fixture.default.palette.canvas = "red")], +]) { + test(`theme overrides reject ${name}`, async () => { + const fixture = await readJson( + path.join(repositoryRoot, "tests/fixtures/theme-overrides/valid.json"), + ); + mutate(fixture); + await assert.rejects( + validateThemeOverrides(fixture), + /theme overrides schema validation failed/, + ); + }); +} + +test("theme overrides reject more than 32 definitions", async () => { + const fixture = Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [ + `theme_${index}`, + { extends: "default", palette: { accent: "#000000" } }, + ]), + ); + await assert.rejects( + validateThemeOverrides(fixture), + /theme overrides schema validation failed/, + ); +}); + test("database and cache fallbacks use cards with their existing icons", async () => { const sourceCatalog = await readJson( path.join(repositoryRoot, "catalog/catalog.json"), @@ -480,6 +521,21 @@ test("Cargo and npm artifacts expose one semantic catalog revision", async () => "packages/theme/schema/provider-pack.schema.json", ), ); + const sourceThemeOverridesSchema = await readJson( + path.join(repositoryRoot, "schemas/theme-overrides.schema.json"), + ); + const cargoThemeOverridesSchema = await readJson( + path.join( + repositoryRoot, + "crates/stack-theme/schema/theme-overrides.schema.json", + ), + ); + const npmThemeOverridesSchema = await readJson( + path.join( + repositoryRoot, + "packages/theme/schema/theme-overrides.schema.json", + ), + ); const metadata = await readJson( path.join(repositoryRoot, "packages/theme/catalog-metadata.json"), ); @@ -498,6 +554,9 @@ test("Cargo and npm artifacts expose one semantic catalog revision", async () => assert.deepEqual(cargoProviderPackSchema, sourceProviderPackSchema); assert.deepEqual(cargoProviderPackSchema, npmProviderPackSchema); assert.deepEqual(providerPackSchema, sourceProviderPackSchema); + assert.deepEqual(cargoThemeOverridesSchema, sourceThemeOverridesSchema); + assert.deepEqual(npmThemeOverridesSchema, sourceThemeOverridesSchema); + assert.deepEqual(themeOverridesSchema, sourceThemeOverridesSchema); assert.deepEqual(catalog, npmCatalog); assert.equal(catalogVersion, npmCatalog.catalogVersion); assert.equal(metadata.catalogVersion, catalogVersion); diff --git a/tests/fixtures/theme-overrides/valid.json b/tests/fixtures/theme-overrides/valid.json new file mode 100644 index 0000000..2f33013 --- /dev/null +++ b/tests/fixtures/theme-overrides/valid.json @@ -0,0 +1,15 @@ +{ + "custom-theme": { + "extends": "light", + "palette": { + "accent": "#005DBB", + "connector": "#334155" + } + }, + "default": { + "extends": "default", + "palette": { + "canvas": "#F7F8FA" + } + } +} diff --git a/tests/types.test.ts b/tests/types.test.ts index 9df79ae..1df7c23 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -5,8 +5,10 @@ import { iconAssets, iconSvg, providerPackSchema, + themeOverridesSchema, type Catalog, type ProviderPack, + type ThemeOverrides, type Theme, } from "@stack-sh/theme"; @@ -17,6 +19,10 @@ const revision: `sha256:${string}` = catalogRevision; const assets: Readonly> = iconAssets; const missingAsset: string | undefined = iconSvg("assets/missing.svg"); const schema: Readonly> = providerPackSchema; +const overrideSchema: Readonly> = themeOverridesSchema; +const overrides: ThemeOverrides = { + default: { extends: "default", palette: { accent: "#005DBB" } }, +}; declare const providerPack: ProviderPack; const providerId: string = providerPack.provider.id; @@ -26,4 +32,6 @@ void revision; void assets; void missingAsset; void schema; +void overrideSchema; +void overrides; void providerId;