diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs
--- a/crates/captions/src/burn.rs
+++ b/crates/captions/src/burn.rs
@@ -1,8 +1,8 @@
 //! Caption layout and burn-in.
 //!
 //! [`overlay`] lays out the caption showing on a track at a time with the track's
-//! [`CaptionStyle`] (size relative to a 1080-line frame, colour, background box, outline,
-//! alignment, anchor and margin; WebVTT `line:N%` and `align:` cue settings override the style)
+//! [`CaptionStyle`] (size relative to a 1080-line frame, colour, background box, outline, wrap
+//! width, alignment, anchor and margin; WebVTT `line:N%` and `align:` cue settings override the style)
 //! and rasterises it into a small premultiplied linear-light RGBA [`Overlay`] positioned in the
 //! frame. Renderers composite overlays over the finished picture.
 
@@ -117,7 +117,9 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
     }
     let scale = h as f32 / 1080.0;
     let px = (style.size.clamp(4.0, MAX_CAPTION_SIZE_1080) * scale).clamp(4.0, h as f32);
-    let max_w = w as f32 * 0.9;
+    // a hostile wrap width (NaN, negative, huge) falls back to or is clamped near the 90 % default
+    let wrap_at = if style.max_width.is_finite() { style.max_width.clamp(0.05, 1.0) } else { 0.9 };
+    let max_w = w as f32 * wrap_at;
     let ts = caption_style(&style.font, px);
     let mut lines: Vec<String> = Vec::new();
     // (speaker names are metadata; like Premiere, they are not burned in)
@@ -377,6 +379,185 @@ mod tests {
         }
     }
 
+    /// `render_caption` as it was before the caption box settings (#3354-#3358), kept verbatim
+    /// (with the SemiBold face inlined) so every new style field can prove its default leaves
+    /// existing projects pixel-identical.
+    #[allow(clippy::needless_range_loop)]
+    fn reference_render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> Option<Overlay> {
+        if w == 0 || h == 0 {
+            return None;
+        }
+        let scale = h as f32 / 1080.0;
+        let px = (style.size.clamp(4.0, MAX_CAPTION_SIZE_1080) * scale).clamp(4.0, h as f32);
+        let max_w = w as f32 * 0.9;
+        let family = if style.font.trim().is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY } else { style.font.trim() };
+        let ts = TextStyle { family: family.into(), style: "SemiBold".into(), size: px, ..Default::default() };
+        let mut lines: Vec<String> = Vec::new();
+        // (speaker names are metadata; like Premiere, they are not burned in)
+        for l in c.plain_lines() {
+            lines.extend(wrap(l.trim(), &ts, max_w));
+        }
+        lines.retain(|l| !l.is_empty());
+        if lines.is_empty() {
+            return None;
+        }
+        let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px);
+        let (asc, desc) = (vm.ascent, vm.descent);
+        let lh = px * style.line_spacing.clamp(0.8, 4.0);
+        let pad_x = (px * 0.3).round();
+        let block_h = lh * lines.len() as f32;
+        let (line_override, align_override) = vtt_overrides(&c.settings);
+        let align = align_override.unwrap_or(style.align);
+        let margin = style.margin.clamp(0.0, 0.45) * h as f32;
+        let top = match (line_override, style.anchor) {
+            (Some(f), _) => (f * h as f32).min(h as f32 - block_h),
+            (None, CaptionAnchor::Top) => margin,
+            (None, CaptionAnchor::Middle) => (h as f32 - block_h) / 2.0,
+            (None, CaptionAnchor::Bottom) => h as f32 - margin - block_h,
+        }
+        .max(0.0)
+        .round();
+        let side = w as f32 * 0.05;
+        let widths: Vec<f32> = lines.iter().map(|l| filmcraft_text::measure(l, &ts)).collect();
+        let xs: Vec<f32> = widths
+            .iter()
+            .map(|&lw| match align {
+                CaptionAlign::Left => side + pad_x,
+                CaptionAlign::Right => w as f32 - side - pad_x - lw,
+                CaptionAlign::Center => (w as f32 - lw) / 2.0,
+            })
+            .collect();
+        let outline = (style.outline * scale).clamp(0.0, MAX_OUTLINE_RADIUS);
+        let grow = outline.ceil() + 1.0;
+        let bx0 = xs.iter().zip(&widths).map(|(x, _)| x - pad_x - grow).fold(f32::MAX, f32::min).floor();
+        let bx1 = xs.iter().zip(&widths).map(|(x, lw)| x + lw + pad_x + grow).fold(f32::MIN, f32::max).ceil();
+        let by0 = (top - grow).floor();
+        let by1 = (top + block_h + grow).ceil();
+        // Text can be wider than the frame (an unbroken imported WebVTT word, for example). Raster
+        // only the visible intersection; `draw` receives the original coordinates and clips into it.
+        let (left, top) = (bx0.clamp(0.0, w as f32), by0.clamp(0.0, h as f32));
+        let (right, bottom) = (bx1.clamp(0.0, w as f32), by1.clamp(0.0, h as f32));
+        if right <= left || bottom <= top {
+            return None;
+        }
+        let (ox, oy) = (left as i32, top as i32);
+        let (ow, oh) = ((right - left) as usize, (bottom - top) as usize);
+        if ow.checked_mul(oh).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) {
+            return None;
+        }
+        let mut cover = vec![0.0f32; ow * oh];
+        let mut bg = vec![0.0f32; ow * oh];
+        for (i, line) in lines.iter().enumerate() {
+            let lt = top + lh * i as f32;
+            if style.background && style.background_color[3] > 0 {
+                let (x0, x1) = (xs[i] - pad_x, xs[i] + widths[i] + pad_x);
+                let (y0, y1) = (lt, lt + lh);
+                for y in (y0.floor() as i32).max(oy)..(y1.ceil() as i32).min(oy + oh as i32) {
+                    let fy = ((y as f32 + 1.0).min(y1) - (y as f32).max(y0)).clamp(0.0, 1.0);
+                    for x in (x0.floor() as i32).max(ox)..(x1.ceil() as i32).min(ox + ow as i32) {
+                        let fx = ((x as f32 + 1.0).min(x1) - (x as f32).max(x0)).clamp(0.0, 1.0);
+                        let j = (y - oy) as usize * ow + (x - ox) as usize;
+                        bg[j] = (bg[j] + fx * fy).min(1.0);
+                    }
+                }
+            }
+            let baseline = lt + (lh - (asc + desc)) / 2.0 + asc;
+            let l = layout(line, &ts, &ParagraphStyle::default());
+            let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) };
+            render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy));
+            cover = m.a;
+        }
+        let stroke = if outline > 0.0 { dilate(&cover, ow, oh, outline) } else { Vec::new() };
+        let (tc, bc, oc) = (lin(style.color), lin(style.background_color), lin(style.outline_color));
+        let mut out = vec![0.0f32; ow * oh * 4];
+        for j in 0..ow * oh {
+            let mut p = [0.0f32; 4];
+            let over = |p: &mut [f32; 4], c: [f32; 4], a: f32| {
+                if a > 0.0 {
+                    for k in 0..4 {
+                        p[k] = c[k] * a + p[k] * (1.0 - c[3] * a);
+                    }
+                }
+            };
+            over(&mut p, bc, bg[j]);
+            if !stroke.is_empty() {
+                over(&mut p, oc, stroke[j]);
+            }
+            over(&mut p, tc, cover[j]);
+            out[j * 4..j * 4 + 4].copy_from_slice(&p);
+        }
+        Some(Overlay { x: ox, y: oy, w: ow, h: oh, px: out })
+    }
+
+    /// Styles and captions existing projects use, at the frame sizes they are drawn at.
+    fn reference_cases() -> Vec<(Caption, CaptionStyle, usize, usize)> {
+        let (long, unbroken) = ("word ".repeat(40), "x".repeat(300));
+        let texts = ["Hello world", "Two\nlines here", long.as_str(), unbroken.as_str(), "<i>Styled</i> &amp; done"];
+        let styles = [
+            CaptionStyle::default(),
+            CaptionStyle { anchor: CaptionAnchor::Top, align: CaptionAlign::Left, size: 40.0, ..Default::default() },
+            CaptionStyle { anchor: CaptionAnchor::Middle, align: CaptionAlign::Right, line_spacing: 1.6, ..Default::default() },
+            CaptionStyle { outline: 3.0, background_color: [20, 40, 200, 128], margin: 0.31, ..Default::default() },
+            CaptionStyle { background: false, font: "Noto Serif".into(), ..Default::default() },
+            CaptionStyle { size: 200.0, margin: 0.0, anchor: CaptionAnchor::Top, ..Default::default() },
+        ];
+        let mut out = Vec::new();
+        for (w, h) in [(1920, 1080), (1280, 720), (1080, 1920), (640, 360)] {
+            for st in &styles {
+                for t in texts {
+                    let mut c = cap(t);
+                    out.push((c.clone(), st.clone(), w, h));
+                    c.settings = "line:30% align:end".into();
+                    out.push((c, st.clone(), w, h));
+                }
+            }
+        }
+        out
+    }
+
+    #[test]
+    fn default_style_renders_like_the_reference() {
+        for (c, st, w, h) in reference_cases() {
+            assert_eq!(render_caption(&c, &st, w, h), reference_render_caption(&c, &st, w, h), "{:?} {w}x{h} {st:?}", c.text);
+        }
+    }
+
+    #[test]
+    fn max_width_sets_where_lines_wrap() {
+        let text = "a caption that wraps to a few lines on a vertical frame";
+        let lines = |st: &CaptionStyle| {
+            let o = render_caption(&cap(text), st, 1080, 1920).unwrap();
+            let lh = (st.size * 1920.0 / 1080.0 * st.line_spacing) as usize;
+            (o, lh)
+        };
+        let ts = caption_style(&CaptionStyle::default().font, 54.0 * 1920.0 / 1080.0);
+        for frac in [0.9_f32, 0.52, 0.4] {
+            let st = CaptionStyle { max_width: frac, ..Default::default() };
+            let (o, lh) = lines(&st);
+            // every wrapped line fits the width: the box (text + 0.3 × size a side) is at most that
+            // wide, and the caption has as many lines as greedy wrapping at that width gives
+            let pad = (54.0 * 1920.0 / 1080.0 * 0.3_f32).round();
+            assert!(o.w as f32 - 2.0 * pad - 2.0 <= frac * 1080.0 + 1.0, "{frac}: {} px wide", o.w);
+            let want = wrap(text, &ts, frac * 1080.0).len();
+            assert_eq!(o.h.saturating_sub(2) / lh, want, "{frac}: {} px tall, {want} lines", o.h);
+        }
+        // a line break typed into the caption is kept however wide the wrap is
+        let full = CaptionStyle { max_width: 1.0, ..Default::default() };
+        let one = render_caption(&cap("One"), &full, 1080, 1920).unwrap();
+        let two = render_caption(&cap("One\nTwo"), &full, 1080, 1920).unwrap();
+        let lh = (54.0 * 1920.0 / 1080.0 * full.line_spacing) as usize;
+        assert!(two.h.abs_diff(one.h + lh) <= 2, "typed break: {} vs {} + {lh}", two.h, one.h);
+    }
+
+    #[test]
+    fn hostile_wrap_widths_are_clamped() {
+        let at = |f: f32| render_caption(&cap("Hello world, a longer caption"), &CaptionStyle { max_width: f, ..Default::default() }, 640, 360);
+        assert_eq!(at(f32::NAN), at(0.9), "NaN is the default");
+        assert_eq!(at(f32::INFINITY), at(0.9), "infinity is the default");
+        assert_eq!(at(-3.0), at(0.05), "negative clamps to the narrowest");
+        assert_eq!(at(1e30), at(1.0), "huge clamps to the frame");
+    }
+
     #[test]
     fn hidden_track_draws_nothing() {
         let mut t = CaptionTrack::new(TrackId(1), "S".into(), CaptionFormat::Subtitle);
diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs
--- a/crates/engine/src/captions.rs
+++ b/crates/engine/src/captions.rs
@@ -248,6 +248,7 @@ fn track_json(s: &Session, t: &CaptionTrack, index: usize) -> Value {
             "background": t.style.background, "backgroundColor": hex(t.style.background_color),
             "align": format!("{:?}", t.style.align).to_lowercase(), "anchor": format!("{:?}", t.style.anchor).to_lowercase(),
             "margin": t.style.margin, "lineSpacing": t.style.line_spacing, "outline": t.style.outline, "outlineColor": hex(t.style.outline_color),
+            "maxWidth": t.style.max_width,
         },
         "captions": t.captions.iter().map(|c| caption_json(s, c)).collect::<Vec<_>>(),
     })
@@ -352,7 +353,7 @@ pub fn commands() -> Vec<CommandSpec> {
             "Caption Track Style",
             &[],
             None,
-            r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":0..0.45 (fraction of frame height)?,"lineSpacing":f32?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,
+            r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":0..0.45 (fraction of frame height)?,"lineSpacing":f32?,"maxWidth":0.05..1=0.9 (fraction of the frame width lines wrap at)?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,
             has_caption_track,
             |s, p| {
                 let tid = track_param(s, p).ok_or_else(|| {
@@ -396,6 +397,9 @@ pub fn commands() -> Vec<CommandSpec> {
                     if let Some(v) = f64_p(&p, "margin") {
                         y.margin = (v as f32).clamp(0.0, 0.45);
                     }
+                    if let Some(v) = f64_p(&p, "maxWidth").filter(|v| v.is_finite()) {
+                        y.max_width = (v as f32).clamp(0.05, 1.0);
+                    }
                     if let Some(v) = f64_p(&p, "lineSpacing") {
                         y.line_spacing = (v as f32).clamp(0.8, 4.0);
                     }
diff --git a/crates/engine/src/captions/tests.rs b/crates/engine/src/captions/tests.rs
--- a/crates/engine/src/captions/tests.rs
+++ b/crates/engine/src/captions/tests.rs
@@ -224,3 +224,16 @@ fn captions_copy_cut_and_paste() {
     s.execute("edit.undo", json!({})).unwrap();
     assert_eq!(caption_spans(&s).len(), 1);
 }
+
+#[test]
+fn set_style_max_width() {
+    let mut s = demo();
+    s.execute("captions.add", json!({"text": "Wrapped", "seconds": 1.0})).unwrap();
+    assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.max_width, 0.9);
+    s.execute("captions.setStyle", json!({"maxWidth": 0.52})).unwrap();
+    assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["style"]["maxWidth"].as_f64().map(|v| v as f32), Some(0.52));
+    for (v, want) in [(json!(40), 1.0), (json!(-1), 0.05)] {
+        s.execute("captions.setStyle", json!({"maxWidth": v})).unwrap();
+        assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.max_width, want);
+    }
+}
diff --git a/crates/project/src/caption.rs b/crates/project/src/caption.rs
--- a/crates/project/src/caption.rs
+++ b/crates/project/src/caption.rs
@@ -93,6 +93,8 @@ pub struct CaptionStyle {
     /// Text outline width in pixels at 1080 lines (0 = none).
     pub outline: f32,
     pub outline_color: [u8; 4],
+    /// Width lines wrap at, as a fraction of the frame width (0.05–1).
+    pub max_width: f32,
 }
 
 impl Default for CaptionStyle {
@@ -109,6 +111,7 @@ impl Default for CaptionStyle {
             line_spacing: 1.25,
             outline: 0.0,
             outline_color: [0, 0, 0, 255],
+            max_width: 0.9,
         }
     }
 }
@@ -303,6 +306,15 @@ pub fn plain_text(s: &str) -> String {
 mod tests {
     use super::*;
 
+    #[test]
+    fn styles_saved_without_a_wrap_width_wrap_at_90_percent() {
+        let old = r#"{"font":"Inter","size":54.0,"color":[255,255,255,255],"background":true,"background_color":[0,0,0,191],
+            "align":"Center","anchor":"Bottom","margin":0.08,"line_spacing":1.25,"outline":0.0,"outline_color":[0,0,0,255]}"#;
+        let st: CaptionStyle = serde_json::from_str(old).unwrap();
+        assert_eq!(st, CaptionStyle::default());
+        assert_eq!(st.max_width, 0.9);
+    }
+
     #[test]
     fn plain() {
         assert_eq!(plain_text("<i>Hello</i> &amp; <b>bye</b>"), "Hello & bye");
diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs
--- a/crates/ui-egui/src/panels/text.rs
+++ b/crates/ui-egui/src/panels/text.rs
@@ -167,10 +167,28 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
         x += 28.0;
     }
 
-    // ---- style strip
-    let style_h = 30.0;
+    // ---- style strip: its controls wrap onto more rows in a narrow panel, so the strip is as tall
+    // as they were on the last frame (one 30 px row at least, half the panel at most)
+    let strip_id = egui::Id::unique(("caption-style-height", track.id.0));
+    let max_h = (rect.height() * 0.5).max(30.0);
+    let style_h = ui.ctx().data(|d| d.get_temp::<f32>(strip_id)).unwrap_or(30.0).clamp(30.0, max_h);
     let style_rect = Rect::from_min_max(pos2(rect.min.x + 10.0, rect.max.y - style_h), pos2(rect.max.x - 10.0, rect.max.y - 2.0));
-    style_strip(app, ui, style_rect, track_idx, &mut actions);
+    // when the wrapped controls are taller than the cap, the strip scrolls so every row stays reachable
+    let mut strip_ui = ui.new_child(egui::UiBuilder::new().max_rect(style_rect).id_salt("caption-style-strip"));
+    let scroll = egui::ScrollArea::vertical().id_salt(("caption-style-scroll", track.id.0)).auto_shrink([false, false]).show(&mut strip_ui, |ui| {
+        let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), style_h));
+        let used = style_strip(app, ui, r, track_idx, &mut actions);
+        // the strip's child doesn't advance this ui's cursor: claim its height so the scroll area knows it
+        ui.allocate_rect(Rect::from_min_size(r.min, vec2(r.width(), used.max(1.0))), Sense::hover());
+        used
+    });
+    let used = scroll.inner;
+    // capped like style_h, so controls taller than the cap don't request a repaint every frame
+    let wanted = (used + 6.0).clamp(30.0, max_h);
+    if (wanted - style_h).abs() > 0.5 {
+        ui.ctx().data_mut(|d| d.insert_temp(strip_id, wanted));
+        ui.ctx().request_repaint();
+    }
 
     // ---- segment list
     let list = Rect::from_min_max(pos2(rect.min.x + 6.0, bar.max.y + 8.0), pos2(rect.max.x - 6.0, style_rect.min.y - 6.0));
@@ -655,12 +673,14 @@ fn filmcraft_speech_available(app: &FilmcraftApp) -> bool {
     app.session.transcriber.is_some() || filmcraft_engine::transcript::speech_available()
 }
 
-fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: usize, actions: &mut Vec<(String, Value)>) {
-    let Some(tr) = app.session.active_sequence().and_then(|q| q.caption_tracks.get(track_idx)).cloned() else { return };
+/// The track style controls in `r`, wrapping onto more rows when they don't fit; returns the
+/// height they took.
+fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: usize, actions: &mut Vec<(String, Value)>) -> f32 {
+    let Some(tr) = app.session.active_sequence().and_then(|q| q.caption_tracks.get(track_idx)).cloned() else { return 0.0 };
     let st = tr.style.clone();
     let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r).id_salt("caption-style"));
-    child.horizontal_centered(|ui| {
-        ui.spacing_mut().item_spacing.x = 6.0;
+    child.horizontal_wrapped(|ui| {
+        ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0);
         let r = craft_ui::ComboBox::from_id_salt(("caption-font", tr.id.0)).selected_text(&st.font).width(130.0).height(400.0).show_ui(ui, |ui| {
             if !filmcraft_text::fonts::system_scanned() {
                 filmcraft_text::fonts::scan_system();
@@ -694,6 +714,15 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us
             let c = col.to_srgba_unmultiplied();
             actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "color": c})));
         }
+        let mut wrap = (st.max_width * 100.0).round();
+        let resp = ui
+            .add(crate::widgets::number_drag(&mut wrap).range(5.0..=100.0).speed(0.5).prefix(tl!("Wrap ")).suffix(" %"))
+            .on_hover_text(tl!("Wrap lines at this share of the frame width"));
+        app.auto.add("text.captions.style.maxWidth", resp.rect, "Caption wrap width");
+        let escaped = resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape));
+        if !escaped && (resp.drag_stopped() || (resp.changed() && !resp.dragged())) {
+            actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "maxWidth": wrap / 100.0})));
+        }
         let mut bg = st.background;
         let resp = ui.checkbox(&mut bg, tl!("Box"));
         app.auto.add("text.captions.style.background", resp.rect, "Background box");
@@ -725,6 +754,7 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us
             }
         }
     });
+    child.min_rect().height()
 }
 
 fn run(app: &mut FilmcraftApp, ui: &egui::Ui, actions: Vec<(String, Value)>) {
diff --git a/docs/captions.md b/docs/captions.md
--- a/docs/captions.md
+++ b/docs/captions.md
@@ -12,7 +12,8 @@ version 2; version 1 projects open with no caption tracks).
   font size (pixels at 1080 lines, scaled to the frame), text colour, background box and its colour,
   outline width and colour, alignment (left/centre/right), position (top/middle/bottom) and
   margin (a fraction of the frame height, default 0.08; `captions.setStyle` clamps it to 0–0.45),
-  line spacing.
+  line spacing, and the wrap width (`maxWidth`: lines wrap at this fraction of the frame width,
+  0.05–1, default 0.9; line breaks typed into a caption are kept).
 - **Caption**: in/out in exact `Tick`s, text (lines separated by `\n`; `<i>`, `<b>`, `<u>` kept
   as written), optional speaker, and the WebVTT cue id and cue settings, kept for round trips.
   Captions on a track never overlap.
@@ -51,7 +52,7 @@ Details of each format and the SCC encoder are in [`crates/captions/README.md`](
 | `edit.copy` / `edit.cut` / `edit.paste` / `edit.pasteInsert` | ⌘C / ⌘X / ⌘V / ⇧⌘V with only captions selected | captions paste at the playhead on the tracks they came from, overwriting (Paste Insert pushes later captions on); Paste places whichever of clips or captions was copied last |
 | `captions.select`, `captions.goTo` | click / number button | `captions`, `add` / `caption` |
 | `captions.setTrack` | caption track header (eye, lock) | `track`, `name`, `format`, `language`, `enabled`, `locked`, `syncLock` |
-| `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `reset` |
+| `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `maxWidth`, `reset` |
 | `captions.deleteTrack` | | `track` |
 | `captions.import` / `captions.export` | File ▸ Export ▸ Captions… | `path`, `format`, `track`, `dropFrame` |
 | `captions.list` | (query) | `track` — tracks, styles and captions with timecodes (a `track` that names no caption track is an error) |
@@ -71,7 +72,7 @@ also splits captions.
 - **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add /
   split / merge / delete / export buttons, the list of segments with editable in/out timecodes
   and text (committed when the field loses focus; Arabic and Hebrew show in reading order and are
-  edited in logical order while the field has focus), and the track style strip (size, colour, box,
+  edited in logical order while the field has focus), and the track style strip (size, colour, wrap width, box,
   alignment, position). The list keeps the caption under the playhead in view (centred as the
   playhead reaches it, not while a field is being edited). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`,
   `text.captions.<id>.in|out|text|goto|row`, `text.captions.style.*`.
