collective

Browse / github.com/storytold/craft-libs / patch

feat(ui): add shared ArtCraft dark role palette

feat(ui): add shared ArtCraft dark role palette Standard dark interface colours are duplicated across Craft app token implementations. Add `SuitePalette::ARTCRAFT_DARK` with 21 immutable `Color32` roles so Rust frontends can map their standard Dark preset to one shared source. Apps retain local themes, preferences, extra tokens, accents and overrides; the existing `WidgetPalette` operation is unchanged. Other schemes can be constants of the same type. The [dark-theme contract](https://github.com/pourie-he/craft-libs/blob/codex/shared-artcraft-dark-palette/docs/artcraft-dark-theme-contract.md) records every role and explains the intention

Status
open
Score
0.00
Adopters
0 other operators
Author
pourie-he (imported from pull request #102, open)
Base
e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e
Size
+263 −7 in 3 files
Review
pending
Shared
2026-10-28
Id
89a3994674fa5a40e271f094

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui/src/theme/mod.rs docs/artcraft-dark-theme-contract.md docs/theme-infrastructure.md

diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs--- a/crates/ui/src/theme/mod.rs+++ b/crates/ui/src/theme/mod.rs@@ -1,10 +1,72 @@-//! Theme mechanisms for egui widgets, with palettes and theme selection owned by applications.+//! Theme mechanisms and shared interface role colors for egui frontends. //! //! This module does not install fonts, store a theme, or change global visuals. Applications-//! decide their colors, borders, typography, spacing and saved preferences independently.+//! adapt shared role colors to their tokens and retain borders, typography, spacing and saved+//! preferences independently.  use egui::{Color32, CornerRadius, Stroke, style::Widgets}; +/// Interface role colors shared by the Craft apps, one constant per scheme.+///+/// `ARTCRAFT_DARK` follows the [ArtCraft dark theme contract](https://github.com/storytold/craft-libs/blob/main/docs/artcraft-dark-theme-contract.md)+/// (14 October 2026). Applications map these roles into local tokens; this data does not apply+/// a theme or select a preference. An app can define its own scheme, or override single roles+/// with `SuitePalette { accent, ..SuitePalette::ARTCRAFT_DARK }`.+/// Alternate themes, semantic colors and document/canvas pixels remain application-owned.+/// `on_accent` is the foreground for blue filled controls; `selection_text` is the foreground+/// for selected interface rows. `primary`/`on_primary` support inverted primary buttons.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub struct SuitePalette {+    pub window: Color32,+    pub chrome: Color32,+    pub panel: Color32,+    pub raised: Color32,+    pub input: Color32,+    pub hover: Color32,+    pub pressed: Color32,+    pub border: Color32,+    pub border_strong: Color32,+    pub text: Color32,+    pub text_strong: Color32,+    pub text_secondary: Color32,+    pub text_disabled: Color32,+    pub accent: Color32,+    pub accent_hover: Color32,+    pub focus: Color32,+    pub on_accent: Color32,+    pub selection: Color32,+    pub selection_text: Color32,+    pub primary: Color32,+    pub on_primary: Color32,+}++impl SuitePalette {+    /// The ArtCraft standard-dark scheme.+    pub const ARTCRAFT_DARK: Self = Self {+        window: Color32::from_rgb(0x12, 0x13, 0x16),+        chrome: Color32::from_rgb(0x0a, 0x0a, 0x0c),+        panel: Color32::from_rgb(0x10, 0x10, 0x14),+        raised: Color32::from_rgb(0x1e, 0x1f, 0x22),+        input: Color32::from_rgb(0x06, 0x06, 0x07),+        hover: Color32::from_rgb(0x32, 0x33, 0x37),+        pressed: Color32::from_rgb(0x41, 0x42, 0x46),+        border: Color32::from_rgb(0x36, 0x37, 0x3a),+        border_strong: Color32::from_rgb(0x77, 0x78, 0x7c),+        text: Color32::from_rgb(0xf2, 0xf1, 0xee),+        text_strong: Color32::from_rgb(0xff, 0xff, 0xff),+        text_secondary: Color32::from_rgb(0xa6, 0xa6, 0xa8),+        text_disabled: Color32::from_rgb(0x71, 0x71, 0x76),+        accent: Color32::from_rgb(0x2d, 0x81, 0xff),+        accent_hover: Color32::from_rgb(0x59, 0xa7, 0xff),+        focus: Color32::from_rgb(0x74, 0xaa, 0xff),+        on_accent: Color32::from_rgb(0x06, 0x06, 0x07),+        selection: Color32::from_rgb(0x20, 0x3b, 0x65),+        selection_text: Color32::from_rgb(0xf2, 0xf1, 0xee),+        primary: Color32::from_rgb(0xff, 0xff, 0xff),+        on_primary: Color32::from_rgb(0x0b, 0x0b, 0x0c),+    };+}+ /// Background colors for egui's five widget interaction states. /// /// These are rendering inputs, not a theme schema: applications construct them from their own@@ -45,11 +107,87 @@ impl WidgetPalette { mod tests {     use super::*; +    #[test]+    fn artcraft_roles_match_the_dark_theme_contract() {+        let palette = SuitePalette::ARTCRAFT_DARK;+        let roles = [+            ("window", palette.window, [0x12, 0x13, 0x16, 0xff]),+            ("chrome", palette.chrome, [0x0a, 0x0a, 0x0c, 0xff]),+            ("panel", palette.panel, [0x10, 0x10, 0x14, 0xff]),+            ("raised", palette.raised, [0x1e, 0x1f, 0x22, 0xff]),+            ("input", palette.input, [0x06, 0x06, 0x07, 0xff]),+            ("hover", palette.hover, [0x32, 0x33, 0x37, 0xff]),+            ("pressed", palette.pressed, [0x41, 0x42, 0x46, 0xff]),+            ("border", palette.border, [0x36, 0x37, 0x3a, 0xff]),+            ("border_strong", palette.border_strong, [0x77, 0x78, 0x7c, 0xff]),+            ("text", palette.text, [0xf2, 0xf1, 0xee, 0xff]),+            ("text_strong", palette.text_strong, [0xff, 0xff, 0xff, 0xff]),+            ("text_secondary", palette.text_secondary, [0xa6, 0xa6, 0xa8, 0xff]),+            ("text_disabled", palette.text_disabled, [0x71, 0x71, 0x76, 0xff]),+            ("accent", palette.accent, [0x2d, 0x81, 0xff, 0xff]),+            ("accent_hover", palette.accent_hover, [0x59, 0xa7, 0xff, 0xff]),+            ("focus", palette.focus, [0x74, 0xaa, 0xff, 0xff]),+            ("on_accent", palette.on_accent, [0x06, 0x06, 0x07, 0xff]),+            ("selection", palette.selection, [0x20, 0x3b, 0x65, 0xff]),+            ("selection_text", palette.selection_text, [0xf2, 0xf1, 0xee, 0xff]),+            ("primary", palette.primary, [0xff, 0xff, 0xff, 0xff]),+            ("on_primary", palette.on_primary, [0x0b, 0x0b, 0x0c, 0xff]),+        ];+        for (role, color, expected) in roles {+            assert_eq!(color.to_array(), expected, "{role}");+        }+    }++    fn contrast(foreground: Color32, background: Color32) -> f64 {+        fn luminance(color: Color32) -> f64 {+            let [r, g, b, _] = color.to_array();+            let linear = |value: u8| {+                let value = f64::from(value) / 255.0;+                if value <= 0.04045 { value / 12.92 } else { ((value + 0.055) / 1.055).powf(2.4) }+            };+            0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)+        }+        let foreground = luminance(foreground);+        let background = luminance(background);+        (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05)+    }++    #[test]+    fn artcraft_intended_foregrounds_have_readable_contrast() {+        let palette = SuitePalette::ARTCRAFT_DARK;+        for surface in [palette.window, palette.chrome, palette.panel, palette.raised, palette.input, palette.hover, palette.pressed] {+            assert!(contrast(palette.text, surface) >= 4.5);+            assert!(contrast(palette.text_strong, surface) >= 4.5);+        }+        // Secondary labels are intended for resting/hovered surfaces, not pressed controls.+        for surface in [palette.window, palette.chrome, palette.panel, palette.raised, palette.input, palette.hover] {+            assert!(contrast(palette.text_secondary, surface) >= 4.5);+        }+        assert!(contrast(palette.selection_text, palette.selection) >= 4.5);+        assert!(contrast(palette.on_accent, palette.accent) >= 4.5);+        assert!(contrast(palette.on_accent, palette.accent_hover) >= 4.5);+        assert!(contrast(palette.on_primary, palette.primary) >= 4.5);+        assert!(contrast(palette.focus, palette.raised) >= 3.0);+        assert!(contrast(palette.border_strong, palette.raised) >= 3.0);+        // Disabled text and subtle borders intentionally do not promise body-text contrast.+    }+     #[test]     fn switching_palette_retains_custom_borders_and_expansion() {         let mut widgets = egui::Visuals::light().widgets;         widgets.active.bg_stroke = Stroke::new(3.0, Color32::YELLOW);         widgets.hovered.expansion = 7.0;+        widgets.noninteractive.expansion = 1.0;+        widgets.inactive.expansion = 2.0;+        widgets.active.expansion = 3.0;+        widgets.open.expansion = 4.0;+        let original_expansions = [+            widgets.noninteractive.expansion,+            widgets.inactive.expansion,+            widgets.hovered.expansion,+            widgets.active.expansion,+            widgets.open.expansion,+        ];         let original_borders = [             widgets.noninteractive.bg_stroke,             widgets.inactive.bg_stroke,@@ -76,7 +214,16 @@ mod tests {             assert_eq!(widget.corner_radius, CornerRadius::same(4));             assert_eq!(widget.fg_stroke, foreground);         }-        assert_eq!(widgets.hovered.expansion, 7.0);+        assert_eq!(+            [+                widgets.noninteractive.expansion,+                widgets.inactive.expansion,+                widgets.hovered.expansion,+                widgets.active.expansion,+                widgets.open.expansion+            ],+            original_expansions+        );         assert_eq!(             [                 widgets.noninteractive.bg_stroke,diff --git a/docs/artcraft-dark-theme-contract.md b/docs/artcraft-dark-theme-contract.mdnew file mode 100644--- /dev/null+++ b/docs/artcraft-dark-theme-contract.md@@ -0,0 +1,72 @@+# ArtCraft dark theme contract (14 October 2026)++This is the proposed standard-dark interface scheme exposed as+`craft_ui::theme::SuitePalette::ARTCRAFT_DARK`. Adoption and default selection remain decisions+for each app's maintainers and project owner. This document defines colors and adapter boundaries;+it does not prescribe a complete theme, layout, or application preference.++## Interface roles++All roles below are opaque sRGB colors. Names describe interface use, not document pixels.++| Role | Color |+| --- | --- |+| `window` | `#121316` |+| `chrome` | `#0a0a0c` |+| `panel` | `#101014` |+| `raised` | `#1e1f22` |+| `input` | `#060607` |+| `hover` | `#323337` |+| `pressed` | `#414246` |+| `border` | `#36373a` |+| `border_strong` | `#77787c` |+| `text` | `#f2f1ee` |+| `text_strong` | `#ffffff` |+| `text_secondary` | `#a6a6a8` |+| `text_disabled` | `#717176` |+| `accent` | `#2d81ff` |+| `accent_hover` | `#59a7ff` |+| `focus` | `#74aaff` |+| `on_accent` | `#060607` |+| `selection` | `#203b65` |+| `selection_text` | `#f2f1ee` |+| `primary` | `#ffffff` |+| `on_primary` | `#0b0b0c` |++Use `on_accent` on blue filled controls and `selection_text` on selected interface rows.+`primary` and `on_primary` support inverted white primary buttons. Disabled text and subtle+borders have different contrast requirements from normal body text.++## Relationship to the ArtCraft reference++The scheme reuses ArtCraft's near-black surfaces, warm foreground, blue accent and focus, and+inverted primary-button colors from+[the native reference](https://github.com/storytold/artcraft/blob/d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373/artcraft-native/src/theme.rs).+It is an intentional standardization, not an exact copy of every reference token.++That reference uses translucent `LINE` (white at 38/255 alpha), `LINE_STRONG`+(warm foreground at 102/255), `WASH` (white at 13/255), selected wash (white at 20/255),+and hover wash (white at 26/255). Translucent colors depend on the surface below them.+The shared border, stronger border, hover, pressed and selection roles are explicit opaque+choices for predictable appearance and contrast across different app surfaces; they are not+claimed to be flattened equivalents of those overlays. Secondary and disabled text similarly+use explicit opaque values. App-specific quiet washes, dashed targets, shadows and faded icons+can remain local translucent tokens.++## Adapter boundary++Applications map roles into their local tokens after selecting their standard-dark preset.+Keep saved preference IDs, stored choices, light and named alternative themes, High Contrast,+custom highlight/contrast settings, typography, spacing, radii and layout local. Inherited dark+constructors must not silently recolor alternate presets. An app may override roles or define+another scheme using the same `SuitePalette` type.++Preserve document, page, worksheet, slide, artboard and media colors; canvas preferences;+semantic warning/error/success/record/meter colors; swatches, guides and meaningful editing+selection overlays. A common UI accent does not imply a common document-selection accent.+The ArtCraft web frontend needs its own CSS adapter and parity checks because it cannot+directly consume the Rust constant.++Shared tests check role values and intended contrast pairs. Each app still needs adapter tests,+alternate-theme preservation checks and source UI verification. Publishing a shared-library+change alone does not update pinned consumers or installed applications.diff --git a/docs/theme-infrastructure.md b/docs/theme-infrastructure.md--- a/docs/theme-infrastructure.md+++ b/docs/theme-infrastructure.md@@ -2,8 +2,10 @@  The first shared mechanism is `craft-ui::theme::WidgetPalette`: it applies colors to egui's five widget states, with a supplied foreground stroke and corner radius. VectorCraft, DesignCraft and-LightCraft use the same operation. Their complete palettes, theme choices, fonts and preferences-remain in their frontends.+LightCraft use the same operation. Theme choices, fonts, preferences and application-specific palettes remain in their frontends.+The additive `SuitePalette::ARTCRAFT_DARK` now supplies canonical standard-dark interface roles;+it does not alter the existing widget operation. This revises the original boundary: basic+interface role colors may be shared, while complete app themes and their extra tokens remain local.  This follows the reusable-UI workstream of [craft-libs issue 2](https://github.com/storytold/craft-libs/issues/2). The [cross-app architecture discussion](https://github.com/storytold/vectorcraft/issues/647) and@@ -78,14 +80,49 @@ stay untouched. Apps apply their state-specific exceptions afterward. `nonintera egui's state, not a new disabled-control policy.  A separate `craft-theme` crate adds no useful boundary: this mechanism already depends on egui-and belongs beside the controls it styles. A universal token structure, theme enum, application-identifier or global context key is unnecessary. Constructing another large visual-options+and belongs beside the controls it styles. Shared role colors live in `SuitePalette`, because one+palette keeps the apps consistent; a theme enum, application identifier or global context key is+unnecessary. Constructing another large visual-options object would mostly rename `egui::Visuals` fields without simplifying their use. Context lookup and storage are already small and retain different fallback themes.  The identical packed-RGB helper in VectorCraft and DesignCraft is only a few casts; it remains local in this extraction. A contrast API also needs a second runtime consumer before extraction. +## Canonical ArtCraft standard dark roles++`craft_ui::theme::SuitePalette::ARTCRAFT_DARK` is the shared source of truth for the 21 role+colors in the [ArtCraft dark theme contract (14 October 2026)](artcraft-dark-theme-contract.md). Its public `Color32`+fields use the contract's names: `window`, `chrome`, `panel`, `raised`, `input`, `hover`,+`pressed`, `border`, `border_strong`, `text`, `text_strong`, `text_secondary`, `text_disabled`,+`accent`, `accent_hover`, `focus`, `on_accent`, `selection`, `selection_text`, `primary` and+`on_primary`. Each scheme is a constant of the same type, so light or app-group schemes can be added without+changing the role API. An app can define its own scheme or override individual roles with+`SuitePalette { accent, ..SuitePalette::ARTCRAFT_DARK }`. The palette is immutable data and has+no application or global-visuals method.++Applications adapt these roles to their existing local tokens in their standard dark path.+They retain preference IDs and defaults, system-theme resolution, custom highlight/contrast+settings, light and alternate brightness presets, High Contrast, fonts, metrics and radii.+Apply the canonical roles after choosing the standard dark preset so alternatives inheriting+its old base are not silently recolored. This API adds no app dependency and requires no+preference migration. Consumer dependency upgrades and releases remain separate decisions.++Use `on_accent` on blue filled controls and `selection_text` on `selection` interface rows;+`primary` and `on_primary` describe inverted white primary buttons. Preserve semantic warning,+error, success, record and meter colors, and editing/document pixels, swatches, guides, canvas+preferences and meaningful selection overlays. Do not replace literal colors mechanically.+`WidgetPalette` continues to accept caller-provided colors and changes exactly its existing+four properties; apps decide how role colors feed widget states and apply local exceptions.++Tests lock all 21 exact opaque values and check normal text on interface surfaces (secondary+labels through hover; pressed controls use normal/strong text), selected+rows, filled controls, focus edges and strong borders using sRGB relative luminance. Disabled+text and subtle borders intentionally have no normal-body-text contrast guarantee. Shared+palette tests cannot certify whole-app accessibility: each adapter still needs role/style tests,+light/alternate/High Contrast preservation checks and source-based UI renders. No installed or+released application outcome follows from this library-only addition.+ ## Fonts and styling outside theme modules  Font setup occupies hundreds of the audited lines. It depends on app-specific font catalogs,