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
@@ -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,new file mode 100644@@ -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.@@ -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,