diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6f307d43a..eb78e4392 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -656,7 +656,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); }); - if spec.ok.is_some() && ok_enabled && !after_panel && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + let rejected = ctx.data_mut(|d| d.remove_temp::(widgets::dialog_reject_commit_flag())).unwrap_or(false); + if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { ok = true; } if discard { diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 941687ae4..ce456fb26 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -58,8 +58,8 @@ pub(super) fn length_within( range: Option>, ) -> bool { let value = d.fields.contains_key(key).then(|| d.f64(key, 0.0)); - let Some(v) = crate::widgets::num_field(ui, ("dlg-len", key), value, unit, width) else { return false }; - let v = range.map_or(v, |r| v.max(*r.start()).min(*r.end())); + let bounds = range.unwrap_or(crate::widgets::ANY); + let Some(v) = crate::widgets::num_field_within(ui, ("dlg-len", key), value, unit, width, bounds) else { return false }; d.fields.insert(key.into(), json!(v)); true } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index ae9fb664f..d4b6698a8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2513,3 +2513,84 @@ fn an_effect_dialog_keeps_a_typed_distance_within_the_effects_range() { assert_eq!(node.appearance.effects.last().map(|e| e.params["maxRadius"].clone()), Some(json!(applied)), "{typed}"); } } + +/// #4638: typing invalid input in an effect length field does not silently revert to a sub-minimum +/// or out-of-range value; sub-minimum input is clamped immediately and invalid input clears cleanly. +#[test] +fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { + let mut app = app(); + let _id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + out + }; + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + frame(&mut app, vec![]); + // 1. Enter 0.5 px (sub-minimum): clamped immediately to 4 pt on commit + frame(&mut app, vec![egui::Event::Text("0.5 px".into())]); + frame(&mut app, vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + let s1 = frame(&mut app, vec![]); + let shows_clamped_text = s1.shapes.iter().any(|s| match &s.shape { + egui::Shape::Text(t) => t.galley.text().trim() == "4 pt", + _ => false, + }); + assert!(shows_clamped_text, "rendered field must show clamped '4 pt' text after sub-minimum commit"); + + // 2. Return focus to Max Radius with Shift+Tab, type invalid text, and commit with Enter + frame( + &mut app, + vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }], + ); + frame(&mut app, vec![egui::Event::Text("1jgghgh".into())]); + let _out = frame( + &mut app, + vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + // Dialog remains open and retains the clamped 4.0 pt rather than reverting to 0.5 px + assert!(app.ui.dialog.is_some(), "dialog must remain open on invalid Enter commit"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + + // The field paints with rejection highlight (width 1.5, accent_strong) + let next_out = frame(&mut app, vec![]); + let has_rejection_highlight = next_out.shapes.iter().any(|s| match &s.shape { + egui::Shape::Rect(r) => (r.stroke.width - 1.5).abs() < 1e-3 && r.stroke.color.a() > 0, + _ => false, + }); + assert!(has_rejection_highlight, "Max Radius must display rejection highlight after invalid input"); + let shows_clamped_after_invalid = next_out.shapes.iter().any(|s| match &s.shape { + egui::Shape::Text(t) => t.galley.text().trim() == "4 pt", + _ => false, + }); + assert!(shows_clamped_after_invalid, "rendered field must retain '4 pt' text after invalid input rejection"); +} + +#[test] +fn bounded_field_with_inches_does_not_clamp_point_bounds_to_inch_numbers() { + let mut test_app = app(); + let _id = test_app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + // Switch document units to Inches + test_app.run("document.setup", json!({"units": "Inches"})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + test_app.run("select.all", json!({})).unwrap(); + test_app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + frame(&mut test_app, vec![]); + // Type 1 in: 72 pt is within 4..127 pt range and must not clamp to ~1.76 pt + frame(&mut test_app, vec![egui::Event::Text("1 in".into())]); + frame( + &mut test_app, + vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert_eq!(test_app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(72.0)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e4032b53d..87fa6ac84 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -47,17 +47,18 @@ pub use dropdowns::{ }; pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader}; pub(crate) use labels::{label_field, take_previous_label}; +pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit}; pub use numeric::{ - SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, opt_field, opt_field_named, range_field, range_field_named, - range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, - spin_field_stops_named, spin_plain, spin_plain_named, + SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, num_field_within, opt_field, opt_field_named, range_field, + range_field_named, range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, + spin_field_named, spin_field_stops_named, spin_plain, spin_plain_named, }; #[cfg(test)] use numeric::{decimal_point, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth}; -pub(crate) use numeric::{round_to, set_bare_numbers_are_points, typed_unit}; pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar}; pub(crate) use text::{ - arm_field_focus, dialog_focus_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter, set_dialog_open, take_dialog_focus, + arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter, + reject_dialog_commit, set_dialog_open, take_dialog_focus, }; pub use text::{ exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, @@ -87,7 +88,7 @@ impl std::fmt::Debug for HashOnly { } /// `parent.with(salt)` for a salt that needs only `Hash`. -fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id { +pub(crate) fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id { parent.with(HashOnly(salt)) } diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index e815d75fe..1867eb271 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -49,6 +49,19 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit num_field_named(ui, id, None, value, unit, width) } +/// [`num_field`] whose value is clamped to `bounds` (points). +pub fn num_field_within( + ui: &mut Ui, + id: impl std::hash::Hash, + value: Option, + unit: Unit, + width: f32, + bounds: RangeInclusive, +) -> Option { + let label = take_previous_label(ui); + unit_field(ui, id, label, None, value, unit, width, false, bounds, &[]) +} + /// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with /// its label painted separately): screen readers name the field `name` (already translated). pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { @@ -57,7 +70,7 @@ pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str> } /// Any number: the bounds of a field that has none. -const ANY: RangeInclusive = f64::NEG_INFINITY..=f64::INFINITY; +pub(crate) const ANY: RangeInclusive = f64::NEG_INFINITY..=f64::INFINITY; /// `v` within `bounds` (bounds the wrong way round or not numbers don't panic, as `f64::clamp` would). fn within(v: f64, bounds: &RangeInclusive) -> f64 { @@ -89,15 +102,33 @@ fn unit_field( // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. let typed = typed_unit(ui.ctx(), unit); let read = |b: &str| typed.parse(&decimal_point(b)); - let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); + let bounds_unit = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); let stops: Vec = stops.iter().map(|&s| unit.from_pt(s)).collect(); - let stepped = step_value(ui, &resp, &mut buf, &bounds, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None, &stops); - let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds); + let stepped = + step_value(ui, &resp, &mut buf, &bounds_unit, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None, &stops); + let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit); let commit = commit && buf != shown; - ui.data_mut(|d| d.insert_temp(id, buf.clone())); if commit { - read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) + match read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) { + Some(v) => { + let clamped = within(v, &bounds); + let formatted = unit.format(clamped); + ui.data_mut(|d| d.insert_temp(id, formatted)); + Some(clamped) + } + None => { + let end = ui.input(|i| i.time + 0.25); + ui.data_mut(|d| { + d.remove::(id); + d.insert_temp(id.with("invalid-flash"), end); + }); + crate::widgets::reject_dialog_commit(ui.ctx()); + ui.ctx().request_repaint(); + None + } + } } else { + ui.data_mut(|d| d.insert_temp(id, buf.clone())); stepped.or(scrubbed).map(|v| unit.to_pt(v)) } } diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index ccde3ba38..a8aa9e802 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -87,6 +87,16 @@ fn dialog_open_flag() -> egui::Id { egui::Id::unique("dialog-open") } +/// The flag that tells the dialog runner a field rejected an invalid commit on Enter in this frame. +pub(crate) fn dialog_reject_commit_flag() -> egui::Id { + egui::Id::unique("dialog-reject-commit") +} + +/// Suppress confirming an open dialog on Enter during this frame when an invalid value was committed. +pub(crate) fn reject_dialog_commit(ctx: &egui::Context) { + ctx.data_mut(|d| d.insert_temp(dialog_reject_commit_flag(), true)); +} + /// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field /// applies its value and confirms the dialog as Enter does ([`shift_enter_keeps_focus`]). pub(crate) fn set_dialog_open(ctx: &egui::Context, open: bool) { @@ -237,7 +247,20 @@ pub(super) fn recessed_text( .allocate_ui_with_layout(vec2(width, height), layout, |ui| { ui.set_min_size(vec2(width, height)); let rect = ui.max_rect(); - ui.painter().rect(rect, 5.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside); + let now = ui.input(|i| i.time); + let flash_end = ui.data(|d| d.get_temp::(id.with("invalid-flash"))); + let invalid = flash_end.is_some_and(|end| now <= end); + if invalid { + ui.ctx().request_repaint(); + } + let stroke = if invalid { + Stroke::new(1.5, t.accent_strong) + } else if editing { + Stroke::new(1.0, t.accent) + } else { + Stroke::new(1.0, t.input_border) + }; + ui.painter().rect(rect, 5.0, t.input, stroke, StrokeKind::Inside); let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 }; let resp = egui::Frame::NONE .inner_margin(margin) @@ -267,7 +290,9 @@ pub(super) fn recessed_text( d.insert_temp(id, buf.clone()); d.insert_temp(focus_id, focused); }); - let commit = resp.lost_focus() || kept; + // Plain Enter only: Ctrl/Cmd+Enter keeps editing a field that takes it (X and Y in Transform). + let enter_commit = rows == 1 && editing && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.is_none()); + let commit = resp.lost_focus() || kept || enter_commit; (buf, commit, resp, rect) } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 312096587..0c0bc052d 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Effect dialogs: bounded length fields (Color Halftone Max Radius) clamp on commit and visibly reject invalid input (#4638); scores unchanged. | | 2026-10-23 | minor | Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)): the Pathfinder panel's Trap… (`object.pathfinder.trap`, one undo step, the traps grouped above the topmost selected object and selected) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add an overprinting strip of the lighter colour inside the darker one where two solid colours meet (Reverse Traps: the darker into the lighter, but not a rich black), with Thickness, Height/Width, Tint Reduction and Traps with Process Color; the effect also has Precision and Remove Redundant Points. Recounted: Hard Mix (#3061) and Soft Mix had shipped, so Pathfinder 11/13 → 13/13 and Illustrator effects 46/50 → 49/50 (SVG Filters remains); remaining 6–10 → 4–8 h. Tests: `vectorcraft_pathops` `trap::tests`, `vectorcraft_effects` `trap::tests` and `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `vectorcraft_engine` `tests_trap`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. | | 2026-10-22 | minor | The Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, with visible checkboxes (#885); on a fill's or stroke's own Transform effect the last two are still missing. Counts unchanged. | | 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6bd431b95..e1f58f3af 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,7 @@ ## Feature submission evidence -**Feature evidence last updated:** 2026-10-22. +**Feature evidence last updated:** 2026-10-30. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Raster effects: bounded length fields in effect dialogs clamp on commit and reject invalid inputs visibly (#4638); estimates unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile subsequent upstream progress updates without changing the runtime or repeating completed evidence; retain every dated upstream and feature revision. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2ee6feda9..43dc70596 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Smart Pen, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Effects: bounded effect length inputs clamp on commit and reject invalid text visibly (#4638)) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Effects: bounded length inputs clamp on commit and reject invalid text visibly (#4638); scores unchanged. | | 2026-10-29 | minor | File formats: attached Affinity Recolor Adjustment (RcRA) layers import as color overlays (#4420); scores unchanged. | | 2026-10-29 | minor | Type core: copied text runs retain character attributes across document switches and canvas pastes (#4459); scores unchanged. | | 2026-10-29 | minor | Type core: soft return (Shift+Enter forced line break, U+2028) breaks lines without starting new paragraphs (#2754); scores unchanged. | @@ -1128,6 +1129,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB or PCX file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Color Halftone Max Radius 4..127 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. - **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, the substitution highlights (Highlight Substituted Fonts and Highlight Alternate Glyphs in Preferences › Type, Highlight Substituted Glyphs in Document Setup), a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). - **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness, the stored source-color model determines the adjustment before document/profile conversion and blur: subtract Darkness/100 from each RGB channel, clamped at zero, or add it to CMYK black K and Gray black, clamped at one, preserving CMYK C/M/Y. At the near-100% endpoint the shadow source becomes gray black before conversion. Shadow opacity stays separate. Original gradient stops follow the same application rule before interpolation; exact resource-level gradient parity remains open. Application SVG export keeps a live filter for simple uniform sRGB sources and uses rendered shadow images for other working RGB profiles, source models and gradients. Low-level SVG filter output warns when this preparation is needed; proportional intermediate SVG filters are not misread as additive Darkness. Exact spot/custom-color and composite-source parity remain open, and resulting CMYK ink values follow the application color pipeline. CSS export leaves the shadow out and marks the object unsupported. When a `.ai`, EPS or PDF opens from its editing data, a Drop Shadow with Darkness in an object's art style is read as one with Color is, with the shadow's Darkness percentage (100% when the style gives none). Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434).