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.md
new 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,
