diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs
--- a/crates/compose/src/adjust.rs
+++ b/crates/compose/src/adjust.rs
@@ -21,12 +21,14 @@ pub use channels::{
     lab_stored_op, stored_channel_adjustment, stored_channels, threshold_white,
 };
 use channels::{apply_lab, legacy_brightness_contrast, legacy_brightness_contrast_hdr};
-use color::gradient;
+#[cfg(test)]
+use color::rgb8_hue_ratio;
 pub use color::{
     BalanceCurve, VIBRANCE_BOOST, VIBRANCE_GREY, bayer4, black_white_gray, color_balance_channel, colorize_saturation, hdr_saturation_factor, hsl_to_rgb,
     hue_range_tables, hue_saturation, hue_saturation_in, lut3d_sample, photo_filter_matrix, range_lightness, rgb_to_hsl, selective_color,
     selective_color_weights, vibrance_lin, vibrance_px,
 };
+use color::{gradient, hue_saturation_rgb8, rgb8_lightness_levels};
 #[cfg(test)]
 use tone::LUT_SIZE;
 pub(crate) use tone::apply_tone;
@@ -107,7 +109,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
 }
 
 /// [`apply_depth`] in a document of colour `mode` (which decides whether float curves extend,
-/// see [`float_tone_extrapolates`]).
+/// see [`float_tone_extrapolates`]). Neutral-range RGB/8 Hue/Saturation at nonpositive hue uses
+/// whole channel levels; other depths, modes, slider domains and colour ranges use the float path.
 fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
     match adj {
         Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
@@ -128,6 +131,10 @@ fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, dept
             }
         }
         Adjustment::BrightnessContrast { brightness, contrast, .. } => {
+            // A neutral adjustment must preserve negative and above-white float samples.
+            if *brightness == 0.0 && *contrast == 0.0 {
+                return;
+            }
             // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth.
             // Unlike the legacy linear scale, both are smooth curves that pin pure black and white
             // (see `modern_brightness` and `modern_contrast`), applied brightness then contrast
@@ -179,6 +186,39 @@ fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, dept
         }
         Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
             let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));
+            let rgb8 = mode == ColorMode::Rgb
+                && depth == Some(SampleType::U8)
+                && !*colorize
+                && table.is_none()
+                && hue.is_finite()
+                && (-180.0..=0.0).contains(hue)
+                && saturation.is_finite()
+                && (-100.0..=100.0).contains(saturation)
+                && lightness.is_finite()
+                && (-100.0..=100.0).contains(lightness);
+            if rgb8 {
+                let sat = (*saturation / 100.0).clamp(-1.0, 1.0);
+                // RGB/8 Lightness keeps only whole 1/255 slider levels. Photoshop's
+                // +41 and -16 ramps both use floor(abs(percent) * 255 / 100).
+                let light = (*lightness / 100.0).clamp(-1.0, 1.0);
+                let light = light.signum() * (light.abs() * 255.0 + 1e-4).floor() / 255.0;
+                if *hue == 0.0 && sat == 0.0 && light == 0.0 {
+                    return;
+                }
+                let factor = if sat < 0.0 {
+                    256 - (-sat * 256.0 + 1e-4).floor() as i32
+                } else {
+                    let steps = ((sat * 254.0 + 1e-4).floor() as i32).clamp(0, 254);
+                    (65280 / (255 - steps)).min(32767)
+                };
+                // The quarter-step phase matches measured RGB/8 slider positions at -62,
+                // -138 and -158; the sector-crossing rule below handles their endpoints.
+                let shift = if *hue == 0.0 { 0 } else { (*hue * 256.0 / 60.0 - 0.25).floor() as i32 };
+                let rotated = *hue != 0.0;
+                let lightness_levels = rgb8_lightness_levels(light);
+                map_rgb(buf, |c| hue_saturation_rgb8(c, factor, shift, rotated, light, &lightness_levels));
+                return;
+            }
             map_rgb(buf, |c| {
                 let (dh, ds, dl) = match &table {
                     Some(t) => {
@@ -191,7 +231,8 @@ fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, dept
                     None => (0.0, 0.0, 0.0),
                 };
                 let sat = colorize_saturation((*saturation / 100.0 + ds).clamp(-1.0, 1.0), *lightness / 100.0, *colorize, depth);
-                let out = hue_saturation_in(c, *hue + dh, sat, (*lightness / 100.0).clamp(-1.0, 1.0), *colorize, depth);
+                let light = (*lightness / 100.0).clamp(-1.0, 1.0);
+                let out = hue_saturation_in(c, *hue + dh, sat, light, *colorize, depth);
                 range_lightness(out, dl)
             })
         }
@@ -326,6 +367,7 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O
 #[cfg(test)]
 mod lookup_tests {
     use super::*;
+    use photocraft_geom::Rect;
 
     fn identity(n: usize) -> Vec<f32> {
         let m = (n - 1) as f32;
@@ -345,6 +387,148 @@ mod lookup_tests {
         }
     }
 
+    /// Photoshop 26.11.6 RGB/8 pixels from the clipped Hue/Saturation case
+    /// `image-1-hs` (source PNG and independently exported Photoshop PNG).
+    #[test]
+    fn clipped_hue_saturation_matches_photoshop_rgb8_pixels() {
+        let measured: [([u8; 3], [u8; 3]); 8] = [
+            ([1, 34, 65], [109, 100, 46]),
+            ([1, 83, 133], [169, 136, 39]),
+            ([108, 159, 180], [202, 178, 130]),
+            ([74, 141, 183], [207, 180, 99]),
+            ([84, 138, 166], [192, 169, 110]),
+            ([12, 19, 25], [72, 70, 60]),
+            ([170, 184, 193], [207, 202, 185]),
+            ([143, 178, 198], [214, 199, 159]),
+        ];
+        let adjustment = Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() };
+        for (input, expected) in measured {
+            let px = input.map(|channel| channel as f32 / 255.0);
+            let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[px[0], px[1], px[2], 1.0]] };
+            apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));
+            let actual: [u8; 3] = std::array::from_fn(|i| (buffer.px[0][i] * 255.0).round() as u8);
+            assert_eq!(actual, expected, "input {input:?}");
+        }
+    }
+
+    /// Pure secondary RGB/8 colours select the descending hue sector at a shared maximum,
+    /// matching Photoshop's Saturation-only and Lightness-only channel tie outputs.
+    #[test]
+    fn rgb8_hue_saturation_resolves_primary_ties_like_photoshop() {
+        for (saturation, lightness, expected) in [
+            (20.0, 0.0, [[254, 255, 0], [0, 254, 255], [255, 0, 254]]),
+            (0.0, 20.0, [[254, 255, 51], [51, 254, 255], [255, 51, 254]]),
+            (20.0, 20.0, [[254, 255, 51], [51, 254, 255], [255, 51, 254]]),
+        ] {
+            let adjustment = Adjustment::HueSaturation { hue: 0.0, saturation, lightness, colorize: false, ranges: HueRange::defaults() };
+            for (input, want) in [[255, 255, 0], [0, 255, 255], [255, 0, 255]].into_iter().zip(expected) {
+                let rgb = input.map(|v| v as f32 / 255.0);
+                let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[rgb[0], rgb[1], rgb[2], 1.0]] };
+                apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));
+                let got: [u8; 3] = std::array::from_fn(|i| (buffer.px[0][i] * 255.0).round() as u8);
+                assert_eq!(got, want, "{input:?}, s{saturation} l{lightness}");
+            }
+        }
+    }
+
+    /// Fifth-step percentages retain their prior scalar rounding. Other signed percentages
+    /// follow measured Photoshop RGB/8 grey samples rather than unquantized scalar arithmetic.
+    #[test]
+    fn rgb8_lightness_levels_match_photoshop_rounding() {
+        let source: Vec<[f32; 4]> = (0..=255).map(|v| [v as f32 / 255.0, v as f32 / 255.0, v as f32 / 255.0, 1.0]).collect();
+        for percent in [0, 20, 40, 60, 80, 100] {
+            let lightness = percent as f32 / 100.0;
+            let adjustment =
+                Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: percent as f32, colorize: false, ranges: HueRange::defaults() };
+            let mut buffer = Buffer { rect: Rect::new(0, 0, 256, 1), px: source.clone() };
+            apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));
+            for (v, pixel) in buffer.px.iter().enumerate() {
+                let expected = (v as f32 + (255 - v) as f32 * lightness).round() as u8;
+                let actual: [u8; 3] = std::array::from_fn(|channel| (pixel[channel] * 255.0).round() as u8);
+                assert_eq!(actual, [expected; 3], "lightness {percent}%, level {v}");
+            }
+        }
+        let measured = [(41.0, [104, 105, 107, 113, 123, 142, 161, 180, 199, 218, 237, 254]), (-16.0, [0, 1, 4, 13, 27, 54, 81, 108, 135, 162, 189, 214])];
+        let levels = [0, 1, 5, 16, 32, 64, 96, 128, 160, 192, 224, 254];
+        for (lightness, expected) in measured {
+            let adjustment = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness, colorize: false, ranges: HueRange::defaults() };
+            for (level, want) in levels.into_iter().zip(expected) {
+                let v = level as f32 / 255.0;
+                let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[v, v, v, 1.0]] };
+                apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));
+                let actual: [u8; 3] = std::array::from_fn(|channel| (buffer.px[0][channel] * 255.0).round() as u8);
+                assert_eq!(actual, [want; 3], "lightness {lightness}%, level {level}");
+            }
+        }
+    }
+
+    /// A neutral adjustment leaves fractional working pixels untouched, while high depths,
+    /// non-RGB documents and out-of-slider values keep their existing float behavior.
+    #[test]
+    fn rgb8_hue_saturation_branch_preserves_identity_and_other_depths() {
+        let fractional = [0.12345, 0.45678, 0.78901, 0.375];
+        let neutral = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() };
+        let mut identity = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![fractional] };
+        apply_depth(&neutral, &mut identity, Transfer::Srgb, Some(SampleType::U8));
+        assert_eq!(identity.px[0], fractional, "neutral adjustment must preserve fractional working pixels");
+
+        let adjusted = Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() };
+        for depth in [SampleType::U16, SampleType::F32] {
+            let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![fractional] };
+            apply_depth(&adjusted, &mut buffer, Transfer::Srgb, Some(depth));
+            let expected = hue_saturation_in([fractional[0], fractional[1], fractional[2]], -158.0, 0.2, 0.2, false, Some(depth));
+            for (got, want) in buffer.px[0][..3].iter().zip(expected) {
+                assert!((got - want).abs() < 1e-6, "{depth:?}: {got} vs {want}");
+            }
+            assert_eq!(buffer.px[0][3], fractional[3]);
+        }
+        for (mode, saturation, lightness) in [(ColorMode::Rgb, 120.0, 20.0), (ColorMode::Rgb, 20.0, 120.0), (ColorMode::Lab, 20.0, 20.0)] {
+            let adjustment = Adjustment::HueSaturation { hue: -158.0, saturation, lightness, colorize: false, ranges: HueRange::defaults() };
+            let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![fractional] };
+            apply_depth_mode(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8), mode);
+            let expected = hue_saturation_in(
+                [fractional[0], fractional[1], fractional[2]],
+                -158.0,
+                (saturation / 100.0).clamp(-1.0, 1.0),
+                (lightness / 100.0).clamp(-1.0, 1.0),
+                false,
+                Some(SampleType::U8),
+            );
+            for (got, want) in buffer.px[0][..3].iter().zip(expected) {
+                assert!((got - want).abs() < 1e-6, "{mode:?} s{saturation} l{lightness}: {got} vs {want}");
+            }
+        }
+    }
+
+    /// The bounded RGB/8 float quotient must equal signed integer division for every possible
+    /// source hue numerator and nonzero chroma (32,895 magnitudes, checked in both signs).
+    #[test]
+    fn rgb8_hue_ratio_float_division_matches_integer_for_all_channel_differences() {
+        for chroma in 1..=255 {
+            for numerator in 0..=chroma {
+                let want = (numerator * 255 + chroma / 2) / chroma;
+                assert_eq!(rgb8_hue_ratio(numerator, chroma), want, "chroma {chroma}, numerator {numerator}");
+                assert_eq!(rgb8_hue_ratio(-numerator, chroma), -want, "chroma {chroma}, numerator -{numerator}");
+            }
+        }
+    }
+
+    /// A +40-degree RGB/8 adjustment retains the earlier float path on the measured red ramp
+    /// pixel, where its green result is one level closer to Photoshop than the integer phase.
+    #[test]
+    fn positive_hue_rgb8_keeps_the_existing_float_path() {
+        // Photoshop's +40° ramp sample: the existing float path is one channel level closer
+        // than the new integer path, whose empirically fitted phase is specific to nonpositive hue.
+        let input = [255.0 / 255.0, 1.0 / 255.0, 0.0];
+        let adjustment = Adjustment::HueSaturation { hue: 40.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() };
+        let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[input[0], input[1], input[2], 1.0]] };
+        apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));
+        let old = hue_saturation_in(input, 40.0, 0.2, 0.2, false, Some(SampleType::U8));
+        assert_eq!(&buffer.px[0][..3], &old);
+        let actual: [u8; 3] = std::array::from_fn(|i| (buffer.px[0][i] * 255.0).round() as u8);
+        assert_eq!(actual, [255, 188, 51]);
+    }
+
     /// Photoshop 27.11, measured: absolute Selective Color on greys (red channel after one range's
     /// cyan change).
     #[test]
diff --git a/crates/compose/src/adjust/channels.rs b/crates/compose/src/adjust/channels.rs
--- a/crates/compose/src/adjust/channels.rs
+++ b/crates/compose/src/adjust/channels.rs
@@ -240,6 +240,9 @@ pub(super) fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<Sample
         }
         Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
             let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);
+            if !*legacy && *brightness == 0.0 && *contrast == 0.0 {
+                return;
+            }
             let modern = (!*legacy).then(|| modern_lut(*brightness, *contrast));
             let curve = |v: f32| match &modern {
                 Some(table) => lut(table, v),
diff --git a/crates/compose/src/adjust/color.rs b/crates/compose/src/adjust/color.rs
--- a/crates/compose/src/adjust/color.rs
+++ b/crates/compose/src/adjust/color.rs
@@ -65,6 +65,78 @@ pub fn hue_saturation_in(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool,
     hsl_to_rgb(hh, ss, ll)
 }
 
+/// Evaluates one RGB/8 pixel using whole channel and hue levels for the neutral-range master
+/// adjustment at hue -180..=0 degrees. `factor` is chroma
+/// gain in 1/256 units, `shift` is a 1/256-sector hue offset, and `lightness_levels` contains
+/// the rounded channel levels after Lightness. The 256-step sectors have 255 source positions.
+/// Measured against the clipped Photoshop 26.11.6 RGB/8 source/PSD oracle (`image-1-hs`).
+pub(super) fn hue_saturation_rgb8(c: [f32; 3], factor: i32, shift: i32, rotated: bool, lightness: f32, lightness_levels: &[i32; 256]) -> [f32; 3] {
+    let rgb = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32);
+    let rgb = rgb.map(|v| lightness_levels.get(v as usize).copied().unwrap_or_else(|| rgb8_lightness(v as f32, lightness).round() as i32));
+    let (r, g, b) = (rgb[0], rgb[1], rgb[2]);
+    let (minimum, maximum) = (r.min(g).min(b), r.max(g).max(b));
+    let old_chroma = maximum - minimum;
+    if old_chroma == 0 {
+        return [minimum as f32 / 255.0; 3];
+    }
+    let sum = minimum + maximum;
+    let chroma = ((old_chroma * factor + 127) / 256).min(sum.min(510 - sum));
+    let low = (sum - chroma) / 2;
+    let high = (sum + chroma) / 2;
+
+    // At a shared maximum, choose the next primary around the hue wheel. This puts yellow,
+    // cyan and magenta on the same descending sector edge as Photoshop's RGB/8 oracle.
+    let (base, numerator) = if r > g && r >= b {
+        (0, g - b)
+    } else if g > b && g >= r {
+        (512, b - r)
+    } else {
+        (1024, r - g)
+    };
+    let ratio = rgb8_hue_ratio(numerator, old_chroma);
+    // The rotated primary identifies the sector before the source hue's signed offset. Only a
+    // source hue that crosses that sector boundary needs the extra endpoint step.
+    let primary_sector = (base + shift).rem_euclid(1536) / 256;
+    let initial = (base + ratio + shift).rem_euclid(1536);
+    let initial_sector = initial / 256;
+    let phase = if rotated && numerator > 0 && initial_sector != primary_sector && initial_sector % 2 == 0 { 2 } else { 0 };
+    let position = (initial + phase).rem_euclid(1536);
+    let sector = position / 256;
+    let offset = position % 256;
+    let rising = low + (chroma * offset + 127) / 256;
+    // Crossing into the preceding descending sector uses its full endpoint. A source hue that
+    // stays in the rotated primary's sector uses the usual 254-step endpoint.
+    let falling_span = if !rotated || (numerator < 0 && initial_sector != primary_sector) { 256 } else { 254 };
+    let falling = low + (chroma * (falling_span - offset) + 127).div_euclid(256);
+    let out = match sector {
+        0 => [high, rising, low],
+        1 => [falling, high, low],
+        2 => [low, high, rising],
+        3 => [low, falling, high],
+        4 => [rising, low, high],
+        _ => [high, low, falling],
+    };
+    out.map(|v| v.clamp(0, 255) as f32 / 255.0)
+}
+
+/// Maps each 0..=255 RGB/8 channel level through signed Lightness once per adjustment.
+/// Each entry preserves the f32 operation order and rounding of the channel transform.
+pub(super) fn rgb8_lightness_levels(lightness: f32) -> [i32; 256] {
+    std::array::from_fn(|v| rgb8_lightness(v as f32, lightness).round() as i32)
+}
+
+fn rgb8_lightness(value: f32, lightness: f32) -> f32 {
+    if lightness >= 0.0 { value + (255.0 - value) * lightness } else { value * (1.0 + lightness) }
+}
+
+/// Rounded position within a 255-step source hue sector. The numerator is at most 65,152,
+/// exactly representable in f32; a nonintegral quotient is at least 1/255 from either integer.
+/// Direct division and floor therefore produce the same quotient as integer division for RGB/8.
+pub(super) fn rgb8_hue_ratio(numerator: i32, chroma: i32) -> i32 {
+    let rounded_numerator = numerator.abs() * 255 + chroma / 2;
+    numerator.signum() * ((rounded_numerator as f32 / chroma as f32).floor() as i32)
+}
+
 /// The factor 32-bit Hue/Saturation scales chroma by for saturation `s` (-1..1), as Photoshop
 /// computes it (measured at every whole percent from -100 to +100 on a 32-bit document): lowering
 /// cuts `s` to 1/256 steps, raising cuts it to 254 steps out of 255, takes 255 / (255 - steps) to
diff --git a/crates/compose/src/adjust/tone.rs b/crates/compose/src/adjust/tone.rs
--- a/crates/compose/src/adjust/tone.rs
+++ b/crates/compose/src/adjust/tone.rs
@@ -141,17 +141,11 @@ fn lut_extended(table: &[f32], v: f32) -> f32 {
     if v < 0.0 { table[0] + v * (table[k] - table[0]) * per_unit } else { table[n - 1] + (v - 1.0) * (table[n - 1] - table[n - 1 - k]) * per_unit }
 }
 
-/// Modern Brightness curve (one channel, `brightness` in [-150, 150]); pins pure black and white,
-/// and `b = 0` is the identity. Raised, it is a line of slope `s = 1.375^(b/50)` from the origin
-/// up to a knee at output 126/255, then a cubic Hermite to (1, 1) whose end slope is
-/// `1 / (1 + 0.084 b)`; past about +110, where no such cubic stays below white, the roll-off is
-/// `1 - (1 - y0)(1 - u)^α` instead (continuous slope `α` at the knee, flat at white). Lowered, it
-/// is the inverse of the curve raised by as much.
-///
-/// Fit to every Photoshop measurement we have, all within about a level and a half: its ±50
-/// samples (`modern_brightness_matches_photoshop`), the photoshop corpus's brightness-contrast.psd
-/// (+25 with contrast +30) and psd-tools' brightnesscontrast_rgb.psd, whose masked layers give
-/// +23, -31 and -95 (+150 within 6 levels there, where every curve family tried bottoms out).
+/// Modern Brightness curve, fitted to Photoshop 26.11.6 RGB/8 and RGB/16 ramps.
+/// Raised: a line of slope `2^(b/110)` up to output 0.5, then a Hermite roll-off to white
+/// with endpoint slope `max(0.1, 1/(12s-11))`. Lowered: the inverse of the raised curve.
+/// Very high positive brightness uses a monotone power roll-off instead of an overshooting
+/// cubic; that fallback remains an approximation of Photoshop's highlight curve.
 pub fn modern_brightness(v: f32, brightness: f32) -> f32 {
     if brightness == 0.0 || !brightness.is_finite() {
         return v;
@@ -172,13 +166,15 @@ struct Brightness {
 }
 
 /// Output level of the Brightness curve's knee.
-const BRIGHTNESS_KNEE: f32 = 126.0 / 255.0;
+const BRIGHTNESS_KNEE: f32 = 0.5;
 
 impl Brightness {
+    /// Precomputes the capped slider's slope, knee and white-endpoint parameters.
+    /// Selects a monotone power roll-off when the Hermite slopes risk overshoot.
     fn new(brightness: f32) -> Self {
-        let b = brightness.abs();
-        let s = 1.375f32.powf(b / 50.0);
-        let e = 1.0 / (1.0 + 0.084 * b);
+        let b = brightness.abs().min(150.0);
+        let s = (b / 110.0).exp2();
+        let e = (1.0 / (12.0 * s - 11.0)).max(0.1);
         let x0 = (BRIGHTNESS_KNEE / s).min(1.0);
         let secant = (1.0 - BRIGHTNESS_KNEE) / (1.0 - x0).max(1e-6);
         let (alpha, beta) = (s / secant, e / secant);
@@ -243,6 +239,10 @@ pub fn modern_lut(brightness: f32, contrast: f32) -> Vec<f32> {
         (0..LUT_SIZE)
             .map(|k| {
                 let y = step(k, LUT_SIZE);
+                // Raised highlights can round to white before the final sample.
+                if k == 0 || k + 1 == LUT_SIZE {
+                    return y;
+                }
                 while j + 2 < fine && ys.get(j + 1).is_some_and(|&n| n < y) {
                     j += 1;
                 }
@@ -255,19 +255,16 @@ pub fn modern_lut(brightness: f32, contrast: f32) -> Vec<f32> {
     bright.into_iter().map(|b| modern_contrast(b, contrast).clamp(0.0, 1.0)).collect()
 }
 
-/// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve
-/// pivoting at 0.5: endpoint slope `1 - c/128`, centre slope `1 + c/128`. Near-exact vs Photoshop
-/// (≤0.5/255). `c = 0` is the identity; pins 0, 0.5 and 1.
+/// Modern Contrast: two symmetric quadratic segments, pinned at black, middle gray and white.
+/// Photoshop 26.11.6 high-precision ramps give `k = 0.0076c`; `c/128` drifts across byte thresholds.
 pub fn modern_contrast(v: f32, contrast: f32) -> f32 {
-    if contrast == 0.0 {
+    if contrast == 0.0 || !contrast.is_finite() {
         return v;
     }
-    let k = contrast / 128.0;
-    let (end, mid) = (1.0 - k, 1.0 + k);
-    // Cubic Hermite on [0, 0.5]: pinned (0,0) slope `end`, (0.5,0.5) slope `mid`. `t` in [0,1].
-    let half = |t: f32| end * 0.5 * (t * t * t - 2.0 * t * t + t) + (-2.0 * t * t * t + 3.0 * t * t) * 0.5 + mid * 0.5 * (t * t * t - t * t);
+    let k = contrast.clamp(-50.0, 100.0) * 0.0076;
+    let half = |x: f32| x + k * (2.0 * x * x - x);
     let v = v.clamp(0.0, 1.0);
-    if v <= 0.5 { half(v / 0.5) } else { 1.0 - half((1.0 - v) / 0.5) }
+    if v <= 0.5 { half(v) } else { 1.0 - half(1.0 - v) }
 }
 
 /// Photoshop posterize: `n` equal input bins over 0..=255, output levels
diff --git a/crates/compose/tests/brightness_contrast.rs b/crates/compose/tests/brightness_contrast.rs
new file mode 100644
--- /dev/null
+++ b/crates/compose/tests/brightness_contrast.rs
@@ -0,0 +1,172 @@
+//! Black-box Photoshop 26.11.6 measurements, RGB/8, generated sRGB gray ramp.
+//! Each pixel is (x, x, x), x=0..255. Measurements taken through PS Generator Bridge.
+//! The issue's (64,64,64), brightness 75, contrast -17 must become (105,105,105).
+
+use photocraft_compose::{Buffer, adjust};
+use photocraft_doc::Adjustment;
+use photocraft_geom::Rect;
+
+const PHOTOSHOP: [(f32, f32, [u8; 256]); 3] = [
+    (
+        75.0,
+        -17.0,
+        [
+            0, 2, 4, 5, 7, 9, 11, 13, 14, 16, 18, 20, 21, 23, 25, 27, 28, 30, 32, 33, 35, 37, 39, 40, 42, 44, 45, 47, 49, 50, 52, 54, 55, 57, 59, 60, 62, 63,
+            65, 67, 68, 70, 71, 73, 75, 76, 78, 79, 81, 82, 84, 86, 87, 89, 90, 92, 93, 95, 96, 98, 99, 101, 102, 104, 105, 107, 108, 110, 111, 113, 114, 115,
+            117, 118, 120, 121, 123, 124, 125, 127, 128, 130, 131, 132, 134, 135, 136, 138, 139, 141, 142, 143, 144, 146, 147, 148, 150, 151, 152, 153, 155,
+            156, 157, 158, 160, 161, 162, 163, 165, 166, 167, 168, 169, 170, 172, 173, 174, 175, 176, 177, 178, 179, 180, 182, 183, 184, 185, 186, 187, 188,
+            189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 199, 200, 201, 202, 203, 204, 205, 206, 207, 207, 208, 209, 210, 211, 212, 212, 213, 214,
+            215, 215, 216, 217, 218, 218, 219, 220, 221, 221, 222, 223, 223, 224, 225, 225, 226, 227, 227, 228, 228, 229, 230, 230, 231, 231, 232, 233, 233,
+            234, 234, 235, 235, 236, 236, 237, 237, 238, 238, 239, 239, 240, 240, 240, 241, 241, 242, 242, 243, 243, 243, 244, 244, 244, 245, 245, 246, 246,
+            246, 247, 247, 247, 248, 248, 248, 248, 249, 249, 249, 250, 250, 250, 250, 251, 251, 251, 251, 252, 252, 252, 252, 252, 253, 253, 253, 253, 253,
+            254, 254, 254, 254, 254, 254, 255, 255, 255, 255,
+        ],
+    ),
+    (
+        75.0,
+        0.0,
+        [
+            0, 2, 3, 5, 6, 8, 10, 11, 13, 14, 16, 18, 19, 21, 22, 24, 26, 27, 29, 30, 32, 34, 35, 37, 38, 40, 42, 43, 45, 47, 48, 50, 51, 53, 55, 56, 58, 59,
+            61, 63, 64, 66, 67, 69, 71, 72, 74, 75, 77, 79, 80, 82, 83, 85, 87, 88, 90, 91, 93, 95, 96, 98, 99, 101, 103, 104, 106, 107, 109, 111, 112, 114,
+            115, 117, 119, 120, 122, 124, 125, 127, 128, 130, 131, 133, 135, 136, 138, 139, 141, 142, 144, 145, 147, 148, 149, 151, 152, 154, 155, 156, 158,
+            159, 160, 162, 163, 164, 166, 167, 168, 169, 171, 172, 173, 174, 175, 177, 178, 179, 180, 181, 182, 183, 185, 186, 187, 188, 189, 190, 191, 192,
+            193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 203, 204, 205, 206, 207, 208, 209, 209, 210, 211, 212, 213, 214, 214, 215, 216, 217, 217,
+            218, 219, 220, 220, 221, 222, 222, 223, 224, 224, 225, 226, 226, 227, 227, 228, 229, 229, 230, 230, 231, 232, 232, 233, 233, 234, 234, 235, 235,
+            236, 236, 237, 237, 238, 238, 239, 239, 239, 240, 240, 241, 241, 242, 242, 242, 243, 243, 243, 244, 244, 245, 245, 245, 246, 246, 246, 247, 247,
+            247, 247, 248, 248, 248, 249, 249, 249, 249, 250, 250, 250, 250, 251, 251, 251, 251, 252, 252, 252, 252, 252, 253, 253, 253, 253, 253, 253, 254,
+            254, 254, 254, 254, 254, 254, 255, 255, 255, 255,
+        ],
+    ),
+    (
+        0.0,
+        -17.0,
+        [
+            0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
+            42, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79,
+            80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 106, 107, 108, 109, 110,
+            111, 112, 113, 114, 115, 116, 116, 117, 118, 119, 120, 121, 122, 123, 124, 124, 125, 126, 127, 128, 129, 130, 131, 131, 132, 133, 134, 135, 136,
+            137, 138, 139, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163,
+            163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191,
+            192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 213, 214, 215, 216, 217, 218, 219, 220, 221,
+            222, 223, 224, 225, 226, 227, 228, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 242, 243, 244, 245, 246, 247, 248, 249, 250, 252, 253,
+            254, 255,
+        ],
+    ),
+];
+
+/// Matches all 256 RGB/8 gray-ramp samples to the measured Photoshop settings,
+/// including the issue's brightness 75 / contrast -17 rounding regression.
+#[test]
+fn modern_brightness_contrast_matches_photoshop_ramps_exactly() {
+    for (brightness, contrast, expected) in PHOTOSHOP {
+        let mut buffer = Buffer { rect: Rect::new(0, 0, 256, 1), px: (0..256).map(|x| [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0]).collect() };
+        adjust::apply(&Adjustment::BrightnessContrast { brightness, contrast, legacy: false }, &mut buffer);
+        for (x, (pixel, expected)) in buffer.to_rgba8().pixels.chunks_exact(4).zip(expected).enumerate() {
+            assert_eq!(pixel, &[expected, expected, expected, 255], "b={brightness} c={contrast} input={x}");
+        }
+    }
+}
+
+/// A neutral modern adjustment must preserve negative and above-white F32 samples
+/// and alpha exactly, rather than clipping through the lookup table's 0..1 domain.
+#[test]
+fn neutral_modern_brightness_contrast_preserves_float_range() {
+    use photocraft_color::SampleType;
+    let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[2.0, -0.25, 1.5, 0.4]] };
+    let before = buffer.clone();
+    adjust::apply_depth(
+        &Adjustment::BrightnessContrast { brightness: 0.0, contrast: 0.0, legacy: false },
+        &mut buffer,
+        adjust::Transfer::Gamma(1.0),
+        Some(SampleType::F32),
+    );
+    assert_eq!(buffer, before);
+}
+
+/// Checks the measured RGB/8 result for a partially transparent pixel while
+/// preserving its alpha and leaving a fully transparent pixel's hidden RGB intact.
+#[test]
+fn modern_brightness_contrast_preserves_alpha_and_transparent_rgb() {
+    let source = [[64.0 / 255.0, 33.0 / 255.0, 0.0, 0.4], [0.2, 0.3, 0.4, 0.0]];
+    let mut buffer = Buffer { rect: Rect::new(0, 0, 2, 1), px: source.to_vec() };
+    adjust::apply(&Adjustment::BrightnessContrast { brightness: 75.0, contrast: -17.0, legacy: false }, &mut buffer);
+    assert_eq!(&buffer.to_rgba8().pixels[..4], &[105, 57, 0, 102]);
+    assert_eq!(buffer.px[0][3], source[0][3]);
+    assert_eq!(buffer.px[1], source[1]);
+}
+
+// RGB/16 PNG samples (stored as 0..65535); Photoshop internally uses 0..32768.
+const INPUT16: [u16; 17] = [0, 4096, 8192, 12288, 16384, 20480, 24576, 28672, 32768, 36863, 40959, 45055, 49151, 53247, 57343, 61439, 65535];
+const PHOTOSHOP16: [(f32, f32, [u16; 17]); 11] = [
+    (25.0, 0.0, [0, 4794, 9590, 14384, 19180, 23974, 28770, 33563, 38307, 42923, 47337, 51477, 55269, 58645, 61529, 63849, 65535]),
+    (50.0, 0.0, [0, 5612, 11226, 16838, 22452, 28064, 33671, 38983, 43835, 48223, 52141, 55587, 58553, 61037, 63031, 64531, 65535]),
+    (75.0, 0.0, [0, 6570, 13142, 19712, 26282, 32853, 38993, 44335, 48933, 52841, 56113, 58809, 60981, 62683, 63973, 64905, 65535]),
+    (100.0, 0.0, [0, 7692, 15384, 23076, 30766, 38119, 44333, 49473, 53649, 56973, 59551, 61495, 62915, 63919, 64617, 65119, 65535]),
+    (-50.0, 0.0, [0, 2990, 5978, 8968, 11956, 14946, 17934, 20924, 23912, 26996, 30294, 33863, 37791, 42213, 47399, 53999, 65535]),
+    (0.0, -50.0, [0, 5458, 10526, 15206, 19496, 23398, 26910, 30034, 32768, 35501, 38625, 42137, 46039, 50329, 55009, 60077, 65535]),
+    (0.0, -17.0, [0, 4560, 8986, 13280, 17442, 21472, 25370, 29136, 32768, 36399, 40165, 44063, 48093, 52255, 56549, 60975, 65535]),
+    (0.0, 25.0, [0, 3416, 7024, 10828, 14828, 19020, 23408, 27992, 32768, 37543, 42127, 46515, 50707, 54707, 58511, 62119, 65535]),
+    (0.0, 50.0, [0, 2734, 5858, 9370, 13272, 17562, 22242, 27310, 32768, 38225, 43293, 47973, 52263, 56165, 59677, 62801, 65535]),
+    (0.0, 100.0, [0, 1372, 3522, 6452, 10158, 14644, 19906, 25948, 32768, 39587, 45629, 50891, 55377, 59083, 62013, 64163, 65535]),
+    (75.0, -17.0, [0, 7250, 14158, 20726, 26954, 32841, 38341, 43369, 47873, 51835, 55247, 58119, 60473, 62347, 63781, 64825, 65535]),
+];
+
+/// Compares RGB/16 ramps to Photoshop within two internal 1/32768 steps,
+/// guarding against byte quantization without claiming exact RGB/16 parity.
+#[test]
+fn modern_brightness_contrast_retains_16_bit_precision() {
+    use photocraft_color::SampleType;
+    for (brightness, contrast, expected) in PHOTOSHOP16 {
+        let mut buffer =
+            Buffer { rect: Rect::new(0, 0, 17, 1), px: INPUT16.iter().map(|&x| [x as f32 / 65535.0, x as f32 / 65535.0, x as f32 / 65535.0, 1.0]).collect() };
+        adjust::apply_depth(
+            &Adjustment::BrightnessContrast { brightness, contrast, legacy: false },
+            &mut buffer,
+            adjust::Transfer::Srgb,
+            Some(SampleType::U16),
+        );
+        for (&pixel, expected) in buffer.px.iter().zip(expected) {
+            assert!((pixel[0] - expected as f32 / 65535.0).abs() <= 2.0 / 32768.0, "b={brightness} c={contrast} pixel={pixel:?} PS={expected}");
+            assert_eq!(pixel[3], 1.0);
+        }
+    }
+}
+
+/// Checks LUT monotonicity and exact black/white endpoints across slider extremes.
+/// Non-finite slider inputs must leave the directly evaluated samples unchanged.
+#[test]
+fn modern_brightness_contrast_curves_are_monotone_and_pin_endpoints() {
+    for brightness in [-150.0, -125.0, -75.0, 0.0, 75.0, 125.0, 150.0] {
+        for contrast in [-50.0, -17.0, 0.0, 100.0] {
+            let table = adjust::modern_lut(brightness, contrast);
+            assert_eq!(table.first(), Some(&0.0));
+            assert_eq!(table.last(), Some(&1.0));
+            assert!(table.windows(2).all(|p| p[0].is_finite() && p[0] <= p[1]));
+        }
+    }
+    for x in [0.0, 0.25, 0.5, 0.75, 1.0] {
+        assert_eq!(adjust::modern_brightness(x, f32::NAN), x);
+        assert_eq!(adjust::modern_contrast(x, f32::INFINITY), x);
+    }
+}
+
+/// Matches a clipped adjustment's rendered RGB/8 ramp to the Photoshop reference,
+/// exercising document compositing in addition to direct buffer adjustment.
+#[test]
+fn clipped_brightness_contrast_render_matches_photoshop() {
+    use photocraft_color::{ColorMode, PixelFormat, SampleType};
+    use photocraft_doc::{Document, Layer, LayerContent, Size};
+    use photocraft_raster::Surface;
+    let mut doc = Document::new("clipped ramp", Size::new(256, 1), ColorMode::Rgb, SampleType::U8);
+    let mut surface = Surface::new(PixelFormat::RGBA8);
+    let bytes: Vec<u8> = (0..=255).flat_map(|x| [x, x, x, 255]).collect();
+    surface.write_interleaved(doc.bounds(), &bytes);
+    doc.layers.push(Layer::new("base", LayerContent::Raster(surface)));
+    let mut adj = Layer::new("brightness", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 75.0, contrast: -17.0, legacy: false }));
+    adj.clipped = true;
+    doc.layers.push(adj);
+    let actual = photocraft_compose::render(&doc, doc.bounds()).to_rgba8();
+    for (pixel, expected) in actual.pixels.chunks_exact(4).zip(PHOTOSHOP[0].2) {
+        assert_eq!(pixel, &[expected, expected, expected, 255]);
+    }
+}
diff --git a/crates/compose/tests/stacked_adjustments.rs b/crates/compose/tests/stacked_adjustments.rs
new file mode 100644
--- /dev/null
+++ b/crates/compose/tests/stacked_adjustments.rs
@@ -0,0 +1,91 @@
+//! RGB/8 pixels measured from Photoshop 26.11.6 exports of the issue's clipped
+//! brightness/contrast and hue/saturation stacks. The source pixel comes from
+//! the same PSD's opaque image layer at (0, 0).
+
+use photocraft_color::{ColorMode, PixelFormat, SampleType};
+use photocraft_compose::render;
+use photocraft_doc::adjust::HueRange;
+use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Size};
+use photocraft_raster::Surface;
+
+fn clipped_stack_on_pixel(source: [u8; 4], adjustments: impl IntoIterator<Item = Adjustment>) -> [u8; 4] {
+    let mut doc = Document::new("stacked adjustments", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
+    let mut surface = Surface::new(PixelFormat::RGBA8);
+    surface.write_interleaved(doc.bounds(), &source);
+    doc.layers.push(Layer::new("image", LayerContent::Raster(surface)));
+    for adjustment in adjustments {
+        let mut layer = Layer::new("adjustment", LayerContent::Adjustment(adjustment));
+        layer.clipped = true;
+        doc.layers.push(layer);
+    }
+    let pixels = render(&doc, doc.bounds()).to_rgba8().pixels;
+    [pixels[0], pixels[1], pixels[2], pixels[3]]
+}
+
+fn clipped_stack(adjustments: impl IntoIterator<Item = Adjustment>) -> [u8; 4] {
+    clipped_stack_on_pixel([1, 34, 65, 255], adjustments)
+}
+
+#[test]
+fn clipped_brightness_then_hue_saturation_matches_photoshop_rgb8() {
+    let first = Adjustment::BrightnessContrast { brightness: -52.0, contrast: 43.0, legacy: false };
+    let second = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() };
+    assert_eq!(clipped_stack([first, second]), [0, 31, 15, 255]);
+}
+
+#[test]
+fn clipped_hue_saturation_then_brightness_matches_photoshop_rgb8() {
+    let first = Adjustment::HueSaturation { hue: -138.0, saturation: -42.0, lightness: 41.0, colorize: false, ranges: HueRange::defaults() };
+    let second = Adjustment::BrightnessContrast { brightness: -54.0, contrast: 39.0, legacy: false };
+    assert_eq!(clipped_stack([first, second]), [85, 88, 71, 255]);
+}
+
+#[test]
+fn single_hue_saturation_from_second_stack_matches_photoshop_rgb8() {
+    let adjustment = Adjustment::HueSaturation { hue: -138.0, saturation: -42.0, lightness: 41.0, colorize: false, ranges: HueRange::defaults() };
+    assert_eq!(clipped_stack([adjustment]), [130, 134, 112, 255]);
+}
+
+#[test]
+fn single_brightness_contrast_from_second_stack_matches_photoshop_rgb8() {
+    let adjustment = Adjustment::BrightnessContrast { brightness: -54.0, contrast: 39.0, legacy: false };
+    assert_eq!(clipped_stack([adjustment]), [1, 18, 38, 255]);
+}
+
+#[test]
+fn single_hue_saturation_from_first_stack_matches_photoshop_rgb8() {
+    let adjustment = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() };
+    assert_eq!(clipped_stack([adjustment]), [0, 56, 25, 255]);
+}
+
+#[test]
+fn hue_saturation_on_brightness_result_keeps_the_sector_edge_below_maximum() {
+    let adjustment = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() };
+    // The first stack's BC layer produces this exact byte pixel. Photoshop's single-layer
+    // probe returns 132 in red even though blue is the maximum channel at 133.
+    assert_eq!(clipped_stack_on_pixel([154, 107, 105, 255], [adjustment]), [132, 85, 133, 255]);
+}
+
+/// Crossed Lightness signs and near-tie colours from a separate Photoshop RGB/8
+/// probe keep the byte-path choice independent of either issue parameter tuple.
+#[test]
+fn hue_saturation_rgb8_crossed_lightness_and_sector_edges_match_photoshop() {
+    let inputs = [[2, 38, 74], [1, 34, 65], [1, 19, 19], [27, 8, 27], [131, 130, 112], [43, 103, 104]];
+    let oracles = [
+        (-62.0, 15.0, 16.0, [[37, 107, 70], [36, 99, 64], [40, 57, 39], [45, 46, 64], [151, 132, 133], [72, 132, 71]]),
+        (-138.0, -42.0, -41.0, [[30, 35, 10], [26, 30, 8], [9, 7, 3], [7, 13, 11], [72, 68, 75], [54, 48, 32]]),
+        (-62.0, 15.0, -16.0, [[0, 64, 30], [0, 56, 25], [1, 17, 0], [5, 6, 24], [111, 92, 93], [32, 92, 31]]),
+        (-138.0, -42.0, 41.0, [[134, 139, 114], [130, 134, 112], [113, 111, 107], [111, 117, 115], [176, 172, 179], [158, 152, 136]]),
+    ];
+    let mut mismatches = Vec::new();
+    for (hue, saturation, lightness, expected) in oracles {
+        let adjustment = Adjustment::HueSaturation { hue, saturation, lightness, colorize: false, ranges: HueRange::defaults() };
+        for (input, want) in inputs.into_iter().zip(expected) {
+            let got = clipped_stack_on_pixel([input[0], input[1], input[2], 255], [adjustment.clone()]);
+            if got != [want[0], want[1], want[2], 255] {
+                mismatches.push((hue, saturation, lightness, input, got, want));
+            }
+        }
+    }
+    assert!(mismatches.is_empty(), "{mismatches:?}");
+}
diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl
--- a/crates/gpu/src/compose.wgsl
+++ b/crates/gpu/src/compose.wgsl
@@ -635,6 +635,76 @@ fn tone_channel(row: i32, v: f32, identity: f32, extrapolate: f32) -> f32 {
     return lut(row, v);
 }
 
+/// RGB/8 master adjustment on whole channel and 1/256-sector hue levels for p1.y-selected
+/// neutral-range hue -180..=0 degrees. Mirrors compose::adjust::hue_saturation_rgb8 stage rounding.
+fn hue_saturation_rgb8(c: vec3<f32>, hue: f32, saturation: f32, lightness: f32) -> vec3<f32> {
+    if (hue == 0.0 && saturation == 0.0 && lightness == 0.0) { return c; }
+    var rgb = vec3<i32>(floor(clamp(c, vec3(0.0), vec3(1.0)) * 255.0 + vec3(0.5)));
+    let light = sign(lightness) * floor(abs(lightness) * 255.0 + 1e-4) / 255.0;
+    if (light >= 0.0) {
+        let levels = vec3<f32>(rgb);
+        rgb = vec3<i32>(floor(levels + (vec3(255.0) - levels) * light + vec3(0.5)));
+    } else {
+        rgb = vec3<i32>(floor(vec3<f32>(rgb) * (1.0 + light) + vec3(0.5)));
+    }
+    let minimum = min(rgb.r, min(rgb.g, rgb.b));
+    let maximum = max(rgb.r, max(rgb.g, rgb.b));
+    let old_chroma = maximum - minimum;
+    if (old_chroma == 0) { return vec3(f32(minimum) / 255.0); }
+    let sum = minimum + maximum;
+    var factor = 0;
+    if (saturation < 0.0) {
+        factor = 256 - i32(floor(-saturation * 256.0 + 1e-4));
+    } else {
+        let steps = clamp(i32(floor(saturation * 254.0 + 1e-4)), 0, 254);
+        factor = min(65280 / (255 - steps), 32767);
+    }
+    let chroma = min((old_chroma * factor + 127) / 256, min(sum, 510 - sum));
+    let low = (sum - chroma) / 2;
+    let high = (sum + chroma) / 2;
+    var base = 0;
+    var numerator = rgb.g - rgb.b;
+    if (rgb.r > rgb.g && rgb.r >= rgb.b) {
+        base = 0;
+    } else if (rgb.g > rgb.b && rgb.g >= rgb.r) {
+        base = 512;
+        numerator = rgb.b - rgb.r;
+    } else {
+        base = 1024;
+        numerator = rgb.r - rgb.g;
+    }
+    let ratio = sign(numerator) * ((abs(numerator) * 255 + old_chroma / 2) / old_chroma);
+    var shift = 0;
+    // Match compose's measured RGB/8 quarter-step slider phase and sector crossing.
+    if (hue != 0.0) { shift = i32(floor(hue * 256.0 / 60.0 - 0.25)); }
+    let primary = base + shift;
+    let primary_sector = (((primary % 1536) + 1536) % 1536) / 256;
+    let raw = base + ratio + shift;
+    let initial = ((raw % 1536) + 1536) % 1536;
+    let initial_sector = initial / 256;
+    var phase = 0;
+    if (hue != 0.0 && numerator > 0 && initial_sector != primary_sector && initial_sector % 2 == 0) { phase = 2; }
+    let position = (initial + phase) % 1536;
+    let sector = position / 256;
+    let offset = position % 256;
+    let rising = low + (chroma * offset + 127) / 256;
+    var complement = 254;
+    if (hue == 0.0 || (numerator < 0 && initial_sector != primary_sector)) { complement = 256; }
+    let falling = low + i32(floor(f32(chroma * (complement - offset) + 127) / 256.0));
+    var result = vec3<i32>(high, rising, low);
+    switch sector {
+        case 1: { result = vec3<i32>(falling, high, low); }
+        case 2: { result = vec3<i32>(low, high, rising); }
+        case 3: { result = vec3<i32>(low, falling, high); }
+        case 4: { result = vec3<i32>(rising, low, high); }
+        case 5: { result = vec3<i32>(high, low, falling); }
+        default: {}
+    }
+    return vec3<f32>(clamp(result, vec3<i32>(0), vec3<i32>(255))) / 255.0;
+}
+
+/// Applies the packed adjustment kind to one encoded RGB pixel; kind 8 selects the bounded
+/// whole-level RGB/8 Hue/Saturation kernel only when plan.rs sets p1.y, otherwise the float path.
 fn adjust(c: vec3<f32>) -> vec3<f32> {
     let hdr = (op.flags & F_HDR) != 0u;
     let p0 = op.p0;
@@ -668,6 +738,7 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
         case 7: { return vec3(tone_channel(0, c.r, p0.x, p0.w), tone_channel(1, c.g, p0.y, p0.w), tone_channel(2, c.b, p0.z, p0.w)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut)
         case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); }
         case 8: {                                                              // Hue/Saturation
+            if (p1.y > 0.5) { return hue_saturation_rgb8(c, p0.x, p0.y, p0.z); }
             let hsl = rgb_to_hsl(c);
             var hh: f32;
             var ss: f32;
diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs
--- a/crates/gpu/src/plan/lower.rs
+++ b/crates/gpu/src/plan/lower.rs
@@ -115,7 +115,8 @@ fn transfer_exponent(t: Transfer) -> f32 {
     }
 }
 
-/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.
+/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`;
+/// Hue/Saturation sets p1.y for the bounded neutral-range RGB/8 integer shader path.
 pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType, mode: photocraft_color::ColorMode) -> Program {
     let mut p = [[0.0f32; 4]; 4];
     match adj {
@@ -147,6 +148,11 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
             (4, p, None)
         }
         Adjustment::BrightnessContrast { brightness, contrast, .. } => {
+            // The lookup table covers 0..1; identity must retain the full HDR range.
+            if *brightness == 0.0 && *contrast == 0.0 {
+                p[0] = [1.0, 1.0, 1.0, 0.0];
+                return (7, p, None);
+            }
             // Modern B/C: the CPU's lookup table (compose::adjust::modern_lut) on every channel,
             // through the Levels / Curves kernel.
             let row = to_row(&adjust::modern_lut(*brightness, *contrast));
@@ -183,6 +189,20 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
                 p[1][0] = 1.0;
                 (8, p, Some(adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect()))
             } else {
+                // compose::adjust uses whole RGB/8 channel and hue levels for the neutral-range
+                // master adjustment. p1.y selects the matching shader path.
+                if mode == photocraft_color::ColorMode::Rgb
+                    && depth == photocraft_color::SampleType::U8
+                    && !*colorize
+                    && hue.is_finite()
+                    && (-180.0..=0.0).contains(hue)
+                    && saturation.is_finite()
+                    && (-100.0..=100.0).contains(saturation)
+                    && lightness.is_finite()
+                    && (-100.0..=100.0).contains(lightness)
+                {
+                    p[1][1] = 1.0;
+                }
                 (8, p, None)
             }
         }
diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs
--- a/crates/gpu/tests/it/parity.rs
+++ b/crates/gpu/tests/it/parity.rs
@@ -27,12 +27,15 @@ struct Gpu {
 /// Simulated texture limit (pages and chunks of 256 px).
 const PAGED_LIMIT: u32 = 256;
 
+/// Compares HDR adjustments with the CPU reference through whole and paged textures.
+/// Neutral modern Brightness/Contrast must retain above-white F32 values.
 #[test]
 fn hdr_adjustments_preserve_range_and_cpu_parity() {
     let Some(mut g) = gpu() else { return };
     let identity = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
     let channel = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
     for adj in [
+        Adjustment::BrightnessContrast { brightness: 0.0, contrast: 0.0, legacy: false },
         Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 },
         Adjustment::Exposure { exposure: 1.0, offset: 0.1, gamma: 1.3 },
         Adjustment::Exposure { exposure: 0.8, offset: -0.02, gamma: 1.2 },
@@ -227,6 +230,30 @@ fn check(g: &mut Gpu, doc: &Document, what: &str) {
     diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}"));
 }
 
+/// Compares clipped modern Brightness/Contrast ramps with the CPU at U8, U16 and F32
+/// depths, including partial alpha, through both whole and paged GPU textures.
+#[test]
+fn modern_brightness_contrast_clipped_ramp() {
+    let Some(mut g) = gpu() else { return };
+    for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
+        for brightness in [-150.0, -75.0, 0.0, 75.0, 150.0] {
+            let mut doc = Document::new("brightness ramp", Size::new(256, 2), ColorMode::Rgb, depth);
+            let mut surface = photocraft_raster::Surface::new(doc.pixel_format());
+            for x in 0..256 {
+                let value = x as f32 / 255.0;
+                for y in 0..2 {
+                    surface.write_pixel(x, y, &[value, 1.0 - value, 0.25, if y == 0 { 1.0 } else { 0.4 }]);
+                }
+            }
+            doc.layers.push(Layer::new("base", LayerContent::Raster(surface)));
+            let mut adj = Layer::new("brightness", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness, contrast: -17.0, legacy: false }));
+            adj.clipped = true;
+            doc.layers.push(adj);
+            check(&mut g, &doc, &format!("modern B/C {depth:?} brightness={brightness}"));
+        }
+    }
+}
+
 /// Growing a layer's tile footprint keeps the old texels in the overlap (`keep_overlap`)
 /// while the same update rewrites tiles inside that overlap (#6243): whatever submission
 /// shape the copy takes, the kept overlap must never overwrite tiles the same update
@@ -652,6 +679,86 @@ fn hue_ranges() -> [HueRange; 6] {
     r
 }
 
+/// The neutral-range RGB/8 GPU path reproduces eight Photoshop pixels from the issue source
+/// and matches the CPU compositor after the document's 8-bit quantization.
+#[test]
+fn rgb8_hue_saturation_matches_cpu_and_photoshop_samples() {
+    let Some(mut g) = gpu() else { return };
+    let samples = [
+        ([1, 34, 65], [109, 100, 46]),
+        ([1, 83, 133], [169, 136, 39]),
+        ([108, 159, 180], [202, 178, 130]),
+        ([74, 141, 183], [207, 180, 99]),
+        ([84, 138, 166], [192, 169, 110]),
+        ([12, 19, 25], [72, 70, 60]),
+        ([170, 184, 193], [207, 202, 185]),
+        ([143, 178, 198], [214, 199, 159]),
+    ];
+    let mut doc = Document::new("RGB8 hue saturation", Size::new(samples.len() as u32, 1), ColorMode::Rgb, SampleType::U8);
+    let mut raster = Layer::raster("source", PixelFormat::RGBA8);
+    let pixels: Vec<f32> = samples.iter().flat_map(|(rgb, _)| rgb.iter().map(|&v| v as f32 / 255.0).chain(std::iter::once(1.0))).collect();
+    raster.surface_mut().unwrap().write_region(doc.bounds(), &pixels);
+    doc.layers.push(raster);
+    doc.layers.push(Layer::new(
+        "Hue/Saturation",
+        LayerContent::Adjustment(Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() }),
+    ));
+    let cpu = photocraft_compose::flatten(&doc);
+    let gpu = render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).unwrap();
+    for (i, (_, expected)) in samples.iter().enumerate() {
+        let cpu_rgb: [u8; 3] = std::array::from_fn(|channel| (cpu.px[i][channel] * 255.0).round() as u8);
+        let gpu_rgb: [u8; 3] = std::array::from_fn(|channel| (gpu[i][channel] * 255.0).round() as u8);
+        assert_eq!(cpu_rgb, *expected, "CPU sample {i}");
+        assert_eq!(gpu_rgb, *expected, "GPU sample {i}");
+    }
+}
+
+/// Hue zero places yellow and cyan on Photoshop's descending sector edge for positive
+/// Saturation or Lightness, identically on the CPU and GPU.
+#[test]
+fn rgb8_hue_saturation_primary_ties_match_photoshop() {
+    let Some(mut g) = gpu() else { return };
+    for (saturation, lightness, expected) in [(20.0, 0.0, [[254, 255, 0], [0, 254, 255]]), (0.0, 20.0, [[254, 255, 51], [51, 254, 255]])] {
+        let mut doc = Document::new("RGB8 primary ties", Size::new(2, 1), ColorMode::Rgb, SampleType::U8);
+        let mut raster = Layer::raster("source", PixelFormat::RGBA8);
+        raster.surface_mut().unwrap().write_region(doc.bounds(), &[1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0]);
+        doc.layers.push(raster);
+        doc.layers.push(Layer::new(
+            "Hue/Saturation",
+            LayerContent::Adjustment(Adjustment::HueSaturation { hue: 0.0, saturation, lightness, colorize: false, ranges: HueRange::defaults() }),
+        ));
+        let cpu = photocraft_compose::flatten(&doc);
+        let gpu = render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).unwrap();
+        for (i, want) in expected.into_iter().enumerate() {
+            let cpu_rgb: [u8; 3] = std::array::from_fn(|channel| (cpu.px[i][channel] * 255.0).round() as u8);
+            let gpu_rgb: [u8; 3] = std::array::from_fn(|channel| (gpu[i][channel] * 255.0).round() as u8);
+            assert_eq!(cpu_rgb, want, "CPU s{saturation} l{lightness}, sample {i}");
+            assert_eq!(gpu_rgb, want, "GPU s{saturation} l{lightness}, sample {i}");
+        }
+    }
+}
+
+/// A positive hue uses the existing float shader path; the +40-degree red ramp pixel keeps
+/// the same one-level-closer green channel result as the CPU compositor.
+#[test]
+fn rgb8_positive_hue_keeps_existing_float_shader_path() {
+    let Some(mut g) = gpu() else { return };
+    let mut doc = Document::new("positive hue fallback", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
+    let mut raster = Layer::raster("source", PixelFormat::RGBA8);
+    raster.surface_mut().unwrap().write_region(doc.bounds(), &[1.0, 1.0 / 255.0, 0.0, 1.0]);
+    doc.layers.push(raster);
+    doc.layers.push(Layer::new(
+        "Hue/Saturation",
+        LayerContent::Adjustment(Adjustment::HueSaturation { hue: 40.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() }),
+    ));
+    let cpu = photocraft_compose::flatten(&doc);
+    let gpu = render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).unwrap();
+    let cpu_rgb: [u8; 3] = std::array::from_fn(|channel| (cpu.px[0][channel] * 255.0).round() as u8);
+    let gpu_rgb: [u8; 3] = std::array::from_fn(|channel| (gpu[0][channel] * 255.0).round() as u8);
+    assert_eq!(cpu_rgb, [255, 188, 51]);
+    assert_eq!(gpu_rgb, cpu_rgb);
+}
+
 fn adjustments() -> Vec<Adjustment> {
     let lc = |a: f32, b: f32, g: f32| LevelsChannel { in_black: a, in_white: b, gamma: g, out_black: 0.05, out_white: 0.95 };
     let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::<Vec<_>>();
@@ -788,6 +895,54 @@ fn adjustment_layers() {
     }
 }
 
+#[test]
+fn clipped_stacked_adjustments_match_photoshop_rgb8_bytes() {
+    let Some(mut g) = gpu() else { return };
+    let cases = [
+        (
+            [1, 34, 65, 255],
+            [
+                Adjustment::BrightnessContrast { brightness: -52.0, contrast: 43.0, legacy: false },
+                Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() },
+            ],
+            [0, 31, 15, 255],
+        ),
+        (
+            [194, 153, 151, 255],
+            [
+                Adjustment::BrightnessContrast { brightness: -52.0, contrast: 43.0, legacy: false },
+                Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() },
+            ],
+            [132, 85, 133, 255],
+        ),
+        (
+            [1, 34, 65, 255],
+            [
+                Adjustment::HueSaturation { hue: -138.0, saturation: -42.0, lightness: 41.0, colorize: false, ranges: HueRange::defaults() },
+                Adjustment::BrightnessContrast { brightness: -54.0, contrast: 39.0, legacy: false },
+            ],
+            [85, 88, 71, 255],
+        ),
+    ];
+    for (source, adjustments, expected) in cases {
+        let mut doc = Document::new("stacked RGB8", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
+        let mut surface = photocraft_raster::Surface::new(PixelFormat::RGBA8);
+        surface.write_interleaved(doc.bounds(), &source);
+        doc.layers.push(Layer::new("image", LayerContent::Raster(surface)));
+        for adjustment in adjustments {
+            let mut layer = Layer::new("adjustment", LayerContent::Adjustment(adjustment));
+            layer.clipped = true;
+            doc.layers.push(layer);
+        }
+        assert_eq!(photocraft_compose::render(&doc, doc.bounds()).to_rgba8().pixels, expected);
+        for comp in [&mut g.comp, &mut g.paged] {
+            let pixels = render_to_vec(comp, &g.device, &g.queue, &doc, doc.bounds()).expect("render stacked adjustments");
+            let actual: [u8; 4] = pixels[0].map(|channel| (channel.clamp(0.0, 1.0) * 255.0).round() as u8);
+            assert_eq!(actual, expected, "GPU stacked RGB8 source {source:?}");
+        }
+    }
+}
+
 #[test]
 fn groups_masks_and_clipping() {
     let Some(mut g) = gpu() else { return };
