diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -12,6 +12,10 @@
 
 DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
 
+- **2026-10-26** · Text takes a gradient like an object: with text selected (or Formatting
+  Affects Text), the Gradient panel and Apply Gradient edit the characters' gradient, not the
+  frame's. The type Control panel shows the text's Fill and Stroke, and the Character section of
+  Properties its Stroke too. No readiness percentage change.
 - **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that
   starts on a floating panel over the bar moves the panel, not the window, and in full screen the
   bar no longer zooms or moves the window. No readiness percentage change.
diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs
--- a/crates/engine/src/cmd/style.rs
+++ b/crates/engine/src/cmd/style.rs
@@ -411,8 +411,8 @@ pub fn specs() -> Vec<CommandSpec> {
             "Gradient",
             [],
             None,
-            "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: a color swatch's name (as dropped on the Gradient panel: its color on screen, its inks in print), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops: [{location, color, swatch?, opacity, midpoint}]} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)",
-            super::has_selection,
+            "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: a color swatch's name (as dropped on the Gradient panel: its color on screen, its inks in print), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, text?: bool, ids?} → {swatch, kind, stops: [{location, color, swatch?, opacity, midpoint}]} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch); on selected text, or with `text` on the text of the selected frames, the characters' gradient",
+            has_selection_or_text,
             apply_gradient
         ),
         cmd!(
@@ -1779,41 +1779,22 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
         Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))),
     };
     let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to"));
+    let edit = GradientEdit { kind, stops, reverse };
+    // Selected text (or a text frame with `text`): the gradient goes on the characters, as a
+    // colour does with `object.color`.
+    let named = p.get("ids").is_some() || p.get("id").is_some();
+    let text = !named
+        && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some());
+    if text {
+        return text_gradient(s, &edit, stroke);
+    }
     let ids = super::targets(s, p)?;
     s.edit(|d, _| {
         let mut out = Value::Null;
         for id in &ids {
             let Some(it) = d.item(*id) else { continue };
             let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() };
-            let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned();
-            let mut g = cur.clone().unwrap_or_default();
-            if let Some(k) = kind {
-                g.kind = k;
-            }
-            if let Some(st) = &stops {
-                g.stops = st.clone();
-                g.sort();
-            }
-            if reverse {
-                g.reverse();
-            }
-            // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch
-            // or add an unnamed one.
-            let name = if cur.as_ref() == Some(&g) {
-                cur_name
-            } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) {
-                w.name.clone()
-            } else {
-                let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient");
-                d.swatches.push(Swatch {
-                    name: name.clone(),
-                    value: SwatchValue::Gradient { gradient: g.clone() },
-                    locked: false,
-                    named: false,
-                    hidden: true,
-                });
-                name
-            };
+            let (name, g) = edit.apply(d, cur_name);
             let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse());
             let Some(it) = d.item_mut(*id) else { continue };
             // The fill's or the stroke's paint: swatch, tint, angle, vector.
@@ -1842,6 +1823,87 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
     })
 }
 
+/// Objects selected, or text (a text selection or table cells).
+fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
+    if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { has_doc(s) } else { super::has_selection(s) }
+}
+
+/// What `object.gradient` changes in a gradient: its kind, its stops, its direction.
+struct GradientEdit {
+    kind: Option<designcraft_color::GradientKind>,
+    stops: Option<Vec<designcraft_color::GradientStop>>,
+    reverse: bool,
+}
+
+impl GradientEdit {
+    /// The gradient of the paint `cur_name` (a new one when it is no gradient) with the edit made,
+    /// and the swatch that holds it: `cur_name` while the gradient is unchanged, else an equal
+    /// gradient swatch, else a new unnamed one.
+    fn apply(&self, d: &mut designcraft_doc::Document, cur_name: String) -> (String, designcraft_color::Gradient) {
+        let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned();
+        let mut g = cur.clone().unwrap_or_default();
+        if let Some(k) = self.kind {
+            g.kind = k;
+        }
+        if let Some(st) = &self.stops {
+            g.stops = st.clone();
+            g.sort();
+        }
+        if self.reverse {
+            g.reverse();
+        }
+        let name = if cur.as_ref() == Some(&g) {
+            cur_name
+        } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) {
+            w.name.clone()
+        } else {
+            let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient");
+            d.swatches.push(Swatch {
+                name: name.clone(),
+                value: SwatchValue::Gradient { gradient: g.clone() },
+                locked: false,
+                named: false,
+                hidden: true,
+            });
+            name
+        };
+        (name, g)
+    }
+}
+
+/// `object.gradient` on the selected text: the gradient of the first selected character's fill
+/// (or stroke), edited, on every selected character; with a caret, on the next typing.
+fn text_gradient(s: &mut Session, edit: &GradientEdit, stroke: bool) -> Result<Value> {
+    let texts = super::text::format_targets_pub(s);
+    let Some(first) = texts.first() else { return Err(bad("object.gradient", "select the text to colour")) };
+    let cur_name = {
+        let st = s.doc()?;
+        let story = st.doc.text_story(first.story, first.cell).ok_or(designcraft_doc::DocError::NoStory(first.story))?;
+        let pf = story.paras.get(story.para_at(first.range.start)).ok_or_else(|| bad("object.gradient", "no paragraph at the selection"))?;
+        let (_, base) = st.doc.styles.resolve_para(pf);
+        let cp = st.doc.styles.resolve_char(&base, &super::text::effective_char_format(s, first)?);
+        if stroke { cp.stroke } else { cp.fill }
+    };
+    let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());
+    let r = s.edit(|d, _| {
+        let (name, g) = edit.apply(d, cur_name);
+        if !caret {
+            let a = if stroke {
+                CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() }
+            } else {
+                CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() }
+            };
+            super::text::apply_char_attrs(d, &texts, &a, &[]);
+        }
+        Ok(gradient_json(&name, &g))
+    })?;
+    if caret && let Some(name) = r.get("swatch").cloned() {
+        let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) };
+        super::text::format_chars(s, &attrs)?;
+    }
+    Ok(r)
+}
+
 fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
     let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?;
     let stroke = str_param(p, "target") == Some("stroke");
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -2947,6 +2947,40 @@ mod text_color_tests {
         assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str());
         assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour");
     }
+
+    /// `object.gradient` on selected text gives the selected characters a gradient fill (the frame
+    /// keeps its own); a later edit starts from the text's gradient.
+    #[test]
+    fn a_gradient_goes_to_the_selected_text() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap();
+        let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
+        let frame_fill = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
+        let before = frame_fill(&s);
+        s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 6})).unwrap();
+        let stops = json!([{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]);
+        let r = s.execute("object.gradient", &json!({"stops": stops})).unwrap();
+        let name = r["swatch"].as_str().unwrap().to_string();
+        let d = &s.doc().unwrap().doc;
+        assert!(designcraft_color::swatch::resolve_gradient(&d.swatches, &name).is_some(), "{name} is a gradient");
+        let story = d.story(designcraft_doc::StoryId(sid)).unwrap();
+        assert_eq!(story.format_after(0).over.fill.as_deref(), Some(name.as_str()), "the selected characters");
+        assert_ne!(story.format_after(7).over.fill.as_deref(), Some(name.as_str()), "not the rest");
+        assert_eq!(frame_fill(&s), before, "the frame keeps its fill");
+        // Radial from the panel: the same stops, now radial, still on the text.
+        let r = s.execute("object.gradient", &json!({"kind": "radial"})).unwrap();
+        assert_eq!(r["kind"], "radial");
+        assert_eq!(r["stops"].as_array().unwrap().len(), 2, "the text's stops are kept");
+        let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap();
+        assert_eq!(story.format_after(0).over.fill.as_deref(), r["swatch"].as_str());
+        // A frame selected with `text`: the whole story.
+        s.execute("selection.set", &json!({"ids": [fid]})).unwrap();
+        let r = s.execute("object.gradient", &json!({"stops": stops, "target": "stroke", "text": true})).unwrap();
+        let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap();
+        assert_eq!(story.format_after(8).over.stroke.as_deref(), r["swatch"].as_str());
+        assert_eq!(frame_fill(&s), before);
+    }
 }
 
 #[cfg(test)]
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -670,6 +670,27 @@ fn control_character(
     para: &serde_json::Value,
     tr: &dyn Fn(&str) -> String,
 ) {
+    // The text's Fill and Stroke, as the Control panel shows them for type: any swatch, a gradient
+    // swatch too (its direction: the Gradient Swatch tool).
+    rows(ui, |ui, row| {
+        let stroke = row == 1;
+        let key = if stroke { "stroke" } else { "fill" };
+        let resp = panels::swatch_picker(
+            app,
+            ui,
+            if stroke { "ctl_text_stroke" } else { "ctl_text_fill" },
+            chars[key].as_str().map(str::to_string),
+            |app, name| {
+                let _ = app.run("type.char", json!({"attrs": {key: name}}));
+            },
+        );
+        if let Some(resp) = resp {
+            let label = tr(if stroke { "Text Stroke" } else { "Text Fill" });
+            resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
+            resp.on_hover_text(label);
+        }
+    });
+    vsep(ui);
     ui.vertical(|ui| {
         let fam = chars["fontFamily"].as_str().unwrap_or("").to_string();
         let sty = chars["fontStyle"].as_str().unwrap_or("").to_string();
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -1709,6 +1709,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("A, B, C, D...", "A, B, C, D..."),
     ("Alpha Channel", "Alphakanal"),
     ("Black", "Schwarz"),
+    ("Text Fill", "Textfläche"),
+    ("Text Stroke", "Textkontur"),
+    ("Stroke:", "Kontur:"),
     // Document Setup
     ("Page Size:", "Seitenformat:"),
     ("Inside:", "Innen:"),
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1821,6 +1821,13 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
         });
         crate::rtl::label(ui, c["fill"].as_str().unwrap_or(""));
     });
+    ui.horizontal(|ui| {
+        caption(ui, crate::i18n::tr(&app.ui.language, "Stroke:"));
+        super::swatch_picker(app, ui, "tstroke", c["stroke"].as_str().map(str::to_string), |app, n| {
+            let _ = app.run("type.char", json!({"attrs": {"stroke": n}}));
+        });
+        crate::rtl::label(ui, c["stroke"].as_str().unwrap_or(""));
+    });
     ui.horizontal(|ui| {
         // Bold and Italic pick the family's own styles (style linking).
         let (weight, italic) = super::fonts(app).traits_of(c["fontFamily"].as_str().unwrap_or_default(), c["fontStyle"].as_str().unwrap_or_default());
diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs
--- a/crates/ui-egui/src/panels/swatches.rs
+++ b/crates/ui-egui/src/panels/swatches.rs
@@ -694,12 +694,18 @@ fn set_stop(stops: &mut [serde_json::Value], i: usize, key: &str, v: serde_json:
 pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
     let t = Tokens::get(ui.ctx());
     let lang = app.ui.language.clone();
+    // Selected text (or Formatting Affects Text): the gradient of the text's fill or stroke.
+    let text = super::colors_affect_text(app);
+    let stroke_front = app.ui.stroke_front;
+    let text_paint = if text { super::color_target(app).map(|(fill, _, stroke, _)| if stroke_front { stroke } else { fill }) } else { None };
     let Some(st) = app.session.active() else { return };
     let item = st.selection.items.first().and_then(|i| st.doc.item(*i));
-    let has_item = item.is_some();
+    let has_item = item.is_some() || text_paint.is_some();
     // The fill's gradient, or the stroke's when the toolbox's Stroke box is in front.
-    let stroke_front = app.ui.stroke_front;
-    let fill = item.map(|it| if stroke_front { it.stroke.as_fill() } else { it.fill.clone() }).unwrap_or_default();
+    let fill = match &text_paint {
+        Some(swatch) => designcraft_doc::Fill { swatch: swatch.clone(), ..Default::default() },
+        None => item.map(|it| if stroke_front { it.stroke.as_fill() } else { it.fill.clone() }).unwrap_or_default(),
+    };
     let cur = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned();
     let is_gradient = cur.is_some();
     let g = cur.unwrap_or_default();
@@ -772,7 +778,8 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
                     crate::widgets::caption(ui, crate::i18n::tr(&lang, "Angle:"));
                     let angle =
                         fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0);
-                    ui.add_enabled_ui(is_gradient && !radial, |ui| {
+                    // Text takes its direction from the Gradient Swatch tool.
+                    ui.add_enabled_ui(is_gradient && !radial && !text, |ui| {
                         if let Some(v) = crate::widgets::number(ui, "grad_angle", is_gradient.then_some(angle), "°", 44.0, 1) {
                             send = Some(json!({"angle": v}));
                         }
@@ -993,6 +1000,9 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
     if let Some(mut p) = send {
         if let Some(o) = p.as_object_mut() {
             o.insert("target".to_string(), json!(if stroke_front { "stroke" } else { "fill" }));
+            if text {
+                o.insert("text".to_string(), json!(true));
+            }
         }
         let _ = app.run("object.gradient", p);
     }
diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs
--- a/crates/ui-egui/src/toolbar.rs
+++ b/crates/ui-egui/src/toolbar.rs
@@ -598,11 +598,10 @@ pub(crate) fn apply_gradient(app: &mut DesignApp) -> Result<Value, String> {
     if let Some(sw) = known {
         return crate::panels::apply_swatch(app, stroke, &sw, None);
     }
-    if crate::panels::colors_affect_text(app) {
-        return Err("no gradient to apply yet: make one in the Gradient panel".into());
-    }
+    // A new white-to-black gradient, on the text when colour edits go to the text.
+    let text = crate::panels::colors_affect_text(app);
     let stops = json!([{"location": 0, "color": "#ffffff"}, {"location": 100, "color": "#000000"}]);
-    let r = app.run("object.gradient", json!({"stops": stops, "target": if stroke { "stroke" } else { "fill" }}))?;
+    let r = app.run("object.gradient", json!({"stops": stops, "target": if stroke { "stroke" } else { "fill" }, "text": text}))?;
     if let Some(sw) = r["swatch"].as_str() {
         app.ui.last_gradient = Some(sw.to_string());
     }
@@ -871,6 +870,25 @@ mod tests {
         }
     }
 
+    /// Apply Gradient with text selected and no gradient yet: a new white-to-black gradient on the
+    /// text, not on its frame.
+    #[test]
+    fn apply_gradient_with_text_selected_colours_the_text() {
+        use serde_json::json;
+        let mut app = app(true);
+        app.run("file.new", json!({})).unwrap();
+        let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "gradient", "caret": false})).unwrap();
+        let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
+        app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 8})).unwrap();
+        let frame_before = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
+        app.run("color.applyGradient", json!({})).unwrap();
+        let doc = &app.session.active().unwrap().doc;
+        let fill = doc.story(designcraft_doc::StoryId(sid)).unwrap().format_after(0).over.fill.clone().expect("the text's fill");
+        let grad = designcraft_color::swatch::resolve_gradient(&doc.swatches, &fill).expect("a gradient on the text");
+        assert_eq!((grad.stops[0].color.to_rgb(), grad.stops[1].color.to_rgb()), ([1.0; 3], [0.0; 3]));
+        assert_eq!(doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch, frame_before, "the frame keeps its fill");
+    }
+
     /// A new default gradient on the stroke in front is one undo step; the fill keeps its swatch and tint.
     #[test]
     fn apply_gradient_to_the_stroke_is_one_undo_step() {
