diff --git a/ROADMAP.md b/ROADMAP.md index 23347c0ea..74a617f3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -361,6 +361,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | +| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | | 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index 9788a27e0..ec2847f05 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -86,3 +86,6 @@ sync_github_name = true [people.leandrodslv] sync_github_name = true + +[people.mateusvillain] +sync_github_name = true diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml index 0bd588524..755e35a10 100644 --- a/crates/color/Cargo.toml +++ b/crates/color/Cargo.toml @@ -3,7 +3,7 @@ name = "figcraft-color" version.workspace = true edition.workspace = true license.workspace = true -description = "FigCraft colour and paint: RGBA colours, HSB/HSL, hex/CSS parsing, solid/gradient/image paints, blend modes" +description = "FigCraft colour and paint: RGBA colours, HSB/HSL/LCH/OKLCH, hex/CSS parsing, solid/gradient/image paints, blend modes" [lints] workspace = true diff --git a/crates/color/src/hsx.rs b/crates/color/src/hsx.rs index a19a5fd59..01f31d313 100644 --- a/crates/color/src/hsx.rs +++ b/crates/color/src/hsx.rs @@ -1,4 +1,5 @@ -//! HSB (HSV) and HSL, the models the colour picker offers besides RGB and hex. +//! HSB (HSV) and HSL, two of the models the colour picker offers besides RGB and hex (LCH and +//! OKLCH are in `lch.rs`). use crate::Color; use serde::{Deserialize, Serialize}; diff --git a/crates/color/src/lch.rs b/crates/color/src/lch.rs new file mode 100644 index 000000000..5abf9abfa --- /dev/null +++ b/crates/color/src/lch.rs @@ -0,0 +1,164 @@ +//! CIE LCH (CSS `lch()`, Lab with a D50 white) and OKLCH (CSS `oklch()`), the perceptual models +//! the colour picker offers. Colours are read as sRGB, like the hex, RGB, HSL and HSB fields. +//! +//! The matrices are the ones CSS Color 4 publishes (sRGB ↔ XYZ D65, Bradford D65 ↔ D50) and +//! Björn Ottosson's published OKLab matrices. + +use crate::Color; +use crate::profile::{apply as mul, decode, encode}; +use serde::{Deserialize, Serialize}; + +/// CIE LCH: lightness 0..100, chroma 0.. (about 0..150 for real colours), hue 0..360. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Lch { + pub l: f32, + pub c: f32, + pub h: f32, +} + +/// OKLCH: lightness 0..1, chroma 0.. (about 0..0.4 for real colours), hue 0..360. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Oklch { + pub l: f32, + pub c: f32, + pub h: f32, +} + +type V3 = [f64; 3]; + +fn linear_rgb(c: Color) -> V3 { + [decode(c.r), decode(c.g), decode(c.b)] +} + +const SRGB_TO_XYZ: [V3; 3] = [ + [0.412_390_799_265_959_34, 0.357_584_339_383_878, 0.180_480_788_401_834_3], + [0.212_639_005_871_510_27, 0.715_168_678_767_756, 0.072_192_315_360_733_71], + [0.019_330_818_715_591_82, 0.119_194_779_794_625_98, 0.950_532_152_249_660_7], +]; +const XYZ_TO_SRGB: [V3; 3] = [ + [3.240_969_941_904_522_6, -1.537_383_177_570_094, -0.498_610_760_293_003_4], + [-0.969_243_636_280_879_6, 1.875_967_501_507_720_2, 0.041_555_057_407_175_59], + [0.055_630_079_696_993_66, -0.203_976_958_888_976_52, 1.056_971_514_242_878_6], +]; +const D65_TO_D50: [V3; 3] = [ + [1.047_929_820_840_548_8, 0.022_946_793_341_019_088, -0.050_192_229_543_135_57], + [0.029_627_815_688_159_344, 0.990_434_484_573_249, -0.017_073_825_029_385_14], + [-0.009_243_058_152_591_178, 0.015_055_144_896_577_895, 0.751_874_289_958_000_8], +]; +const D50_TO_D65: [V3; 3] = [ + [0.955_473_452_704_218_2, -0.023_098_536_874_261_423, 0.063_259_308_661_021_7], + [-0.028_369_706_963_208_136, 1.009_995_458_005_822_6, 0.021_041_398_966_943_008], + [0.012_314_001_688_319_899, -0.020_507_696_433_477_912, 1.330_365_936_608_075_3], +]; +const D50_WHITE: V3 = [0.3457 / 0.3585, 1.0, (1.0 - 0.3457 - 0.3585) / 0.3585]; +const KAPPA: f64 = 24389.0 / 27.0; +const EPSILON: f64 = 216.0 / 24389.0; + +const SRGB_TO_LMS: [V3; 3] = [ + [0.412_221_470_8, 0.536_332_536_3, 0.051_445_992_9], + [0.211_903_498_2, 0.680_699_545_1, 0.107_396_956_6], + [0.088_302_461_9, 0.281_718_837_6, 0.629_978_700_5], +]; +const LMS_TO_OKLAB: [V3; 3] = [ + [0.210_454_255_3, 0.793_617_785_0, -0.004_072_046_8], + [1.977_998_495_1, -2.428_592_205_0, 0.450_593_709_9], + [0.025_904_037_1, 0.782_771_766_2, -0.808_675_766_0], +]; +const OKLAB_TO_LMS: [V3; 3] = + [[1.0, 0.396_337_777_4, 0.215_803_757_3], [1.0, -0.105_561_345_8, -0.063_854_172_8], [1.0, -0.089_484_177_5, -1.291_485_548_0]]; +const LMS_TO_SRGB: [V3; 3] = [ + [4.076_741_662_1, -3.307_711_591_3, 0.230_969_929_2], + [-1.268_438_004_6, 2.609_757_401_1, -0.341_319_396_5], + [-0.004_196_086_3, -0.703_418_614_7, 1.707_614_701_0], +]; + +fn lab_from_color(c: Color) -> V3 { + let xyz = mul(&D65_TO_D50, mul(&SRGB_TO_XYZ, linear_rgb(c))); + let f = |v: f64| if v > EPSILON { v.cbrt() } else { (KAPPA * v + 16.0) / 116.0 }; + let [fx, fy, fz] = [f(xyz[0] / D50_WHITE[0]), f(xyz[1] / D50_WHITE[1]), f(xyz[2] / D50_WHITE[2])]; + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +fn linear_from_lab([l, a, b]: V3) -> V3 { + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + let inv = |f: f64| if f.powi(3) > EPSILON { f.powi(3) } else { (116.0 * f - 16.0) / KAPPA }; + let y = if l > KAPPA * EPSILON { fy.powi(3) } else { l / KAPPA }; + let xyz = [inv(fx) * D50_WHITE[0], y * D50_WHITE[1], inv(fz) * D50_WHITE[2]]; + mul(&XYZ_TO_SRGB, mul(&D50_TO_D65, xyz)) +} + +fn oklab_from_color(c: Color) -> V3 { + let lms = mul(&SRGB_TO_LMS, linear_rgb(c)); + mul(&LMS_TO_OKLAB, [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]) +} + +fn linear_from_oklab(lab: V3) -> V3 { + let lms = mul(&OKLAB_TO_LMS, lab); + mul(&LMS_TO_SRGB, [lms[0].powi(3), lms[1].powi(3), lms[2].powi(3)]) +} + +/// Lab → (lightness, chroma, hue); grey has hue 0. +fn polar([l, a, b]: V3, grey: f64) -> (f32, f32, f32) { + let c = a.hypot(b); + let h = if c < grey { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) }; + (l as f32, c as f32, h as f32) +} + +fn finite(v: f32) -> f64 { + if v.is_finite() { f64::from(v) } else { 0.0 } +} + +fn in_gamut(rgb: V3) -> bool { + rgb.iter().all(|v| (-1e-6..=1.0 + 1e-6).contains(v)) +} + +/// The sRGB colour for a lightness, chroma and hue: out of gamut, the chroma comes down (a binary +/// search) until it fits, so lightness and hue stay what was asked for, as CSS gamut mapping does. +fn map_to_srgb(l: f64, c: f64, h: f64, to_linear_rgb: impl Fn(V3) -> V3) -> Color { + let (sin, cos) = h.to_radians().sin_cos(); + let lab = |c: f64| [l, c * cos, c * sin]; + let mut rgb = to_linear_rgb(lab(c)); + if !in_gamut(rgb) { + let (mut lo, mut hi) = (0.0, c); + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if in_gamut(to_linear_rgb(lab(mid))) { + lo = mid; + } else { + hi = mid; + } + } + rgb = to_linear_rgb(lab(lo)); + } + Color::new(encode(rgb[0]), encode(rgb[1]), encode(rgb[2]), 1.0) +} + +impl Lch { + pub fn from_color(c: Color) -> Self { + let (l, c, h) = polar(lab_from_color(c), 0.02); + Self { l, c, h } + } + + /// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show. + pub fn to_color(self) -> Color { + let l = finite(self.l).clamp(0.0, 100.0); + let c = finite(self.c).clamp(0.0, 230.0); + map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_lab) + } +} + +impl Oklch { + pub fn from_color(c: Color) -> Self { + let (l, c, h) = polar(oklab_from_color(c), 0.000_2); + Self { l, c, h } + } + + /// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show. + pub fn to_color(self) -> Color { + let l = finite(self.l).clamp(0.0, 1.0); + let c = finite(self.c).clamp(0.0, 0.5); + map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_oklab) + } +} diff --git a/crates/color/src/lch_tests.rs b/crates/color/src/lch_tests.rs new file mode 100644 index 000000000..0bf249b07 --- /dev/null +++ b/crates/color/src/lch_tests.rs @@ -0,0 +1,105 @@ +use super::*; + +fn close(a: f32, b: f32, tol: f32) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn lch_matches_css_reference_values() { + // CSS Color 4 values for sRGB primaries and white (D50 Lab). + let red = Lch::from_color(Color::from_hex("FF0000").unwrap()); + assert!(close(red.l, 54.29, 0.05) && close(red.c, 106.84, 0.1) && close(red.h, 40.85, 0.1), "{red:?}"); + let white = Lch::from_color(Color::WHITE); + assert!(close(white.l, 100.0, 0.01) && white.c < 0.05, "{white:?}"); + assert_eq!(Lch::from_color(Color::BLACK).l.round(), 0.0); +} + +#[test] +fn oklch_matches_reference_values() { + let red = Oklch::from_color(Color::from_hex("FF0000").unwrap()); + assert!(close(red.l, 0.628, 0.001) && close(red.c, 0.2577, 0.001) && close(red.h, 29.23, 0.1), "{red:?}"); + let white = Oklch::from_color(Color::WHITE); + assert!(close(white.l, 1.0, 0.001) && white.c < 0.001 && white.h == 0.0, "{white:?}"); +} + +#[test] +fn lch_and_oklch_round_trip_through_srgb() { + for hex in ["0D99FF", "FF0000", "00FF00", "0000FF", "FFFFFF", "000000", "808080", "663399", "F24E1E", "1ABC9C"] { + let c = Color::from_hex(hex).unwrap(); + assert_eq!(Lch::from_color(c).to_color().to_hex(), hex, "{hex} via LCH"); + assert_eq!(Oklch::from_color(c).to_color().to_hex(), hex, "{hex} via OKLCH"); + } +} + +#[test] +fn out_of_gamut_keeps_lightness_and_hue() { + // Far more chroma than sRGB holds: the chroma comes down, lightness and hue stay. + let asked = Oklch { l: 0.7, c: 0.45, h: 150.0 }; + let got = Oklch::from_color(asked.to_color()); + assert!(close(got.l, 0.7, 0.01) && close(got.h, 150.0, 1.5) && got.c < 0.45, "{got:?}"); + let asked = Lch { l: 50.0, c: 200.0, h: 300.0 }; + let got = Lch::from_color(asked.to_color()); + assert!(close(got.l, 50.0, 1.0) && close(got.h, 300.0, 2.0) && got.c < 200.0, "{got:?}"); +} + +#[test] +fn junk_values_never_panic() { + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, -5.0, 0.0] { + let a = Lch { l: v, c: v, h: v }.to_color(); + let b = Oklch { l: v, c: v, h: v }.to_color(); + for x in [a.r, a.g, a.b, b.r, b.g, b.b] { + assert!(x.is_finite() && (0.0..=1.0).contains(&x)); + } + } + let nan = Color { r: f32::NAN, g: 2.0, b: -1.0, a: 1.0 }; + assert!(Lch::from_color(nan.sanitized()).l.is_finite()); + assert!(Oklch::from_color(nan.sanitized()).l.is_finite()); +} + +#[test] +fn css_reads_lch_and_oklch() { + assert_eq!(Color::parse_css("oklch(62.8% 0.2577 29.23)").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::parse_css("oklch(0.628 0.2577 29.23deg / 50%)").unwrap().to_rgba8(), [255, 0, 0, 128]); + assert_eq!(Color::parse_css("lch(54.29 106.84 40.85)").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::parse_css("LCH(100% 0 0)").unwrap().to_hex(), "FFFFFF"); + assert!(Color::parse_css("oklch(nan 0 0)").is_none()); + assert!(Color::parse_css("lch(50)").is_none()); +} + +#[test] +fn css_hue_units_and_none() { + let blue = Color::parse_css("oklch(0.6 0.2 260)").unwrap(); + for h in ["260deg", "0.7222222turn", "288.8888grad", "4.537856rad"] { + assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}"); + } + assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex()); + assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]); +} + +#[test] +fn css_lch_and_oklch_junk_is_refused_or_in_range() { + let long = "9".repeat(100_000); + let junk = [ + "oklch()".to_string(), + "oklch(,,)".into(), + "lch(1 2 3 4 5)".into(), + "oklch(0.5 0.1 1e40)".into(), + "lch(inf 0 0)".into(), + "oklch(0.5 0.1 deg)".into(), + "lch(50 50 50 / / /)".into(), + "oklch(🟥 0 0)".into(), + "lch(\u{0} 0 0)".into(), + format!("oklch({long} {long} {long})"), + format!("lch(50 50 {long}turn)"), + ]; + for j in &junk { + if let Some(c) = Color::parse_css(j) { + for v in [c.r, c.g, c.b, c.a] { + assert!(v.is_finite() && (0.0..=1.0).contains(&v), "{j:.40}: {c:?}"); + } + } + } + for j in ["oklch()", "lch(1 2 3 4 5)", "lch(inf 0 0)", "oklch(0.5 0.1 deg)", "oklch(🟥 0 0)", "oklch(0.5 0.1 1e40)"] { + assert!(Color::parse_css(j).is_none(), "{j}"); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 0b7ae704b..6e61084d6 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -11,10 +11,12 @@ use serde::{Deserialize, Serialize}; mod blend; mod hsx; mod icc; +mod lch; mod named; mod profile; pub use blend::BlendMode; pub use hsx::*; +pub use lch::{Lch, Oklch}; pub use profile::{ColorProfile, ProfileConversion}; /// A non-premultiplied sRGB colour with alpha, components in `0..=1`. Deserializes from @@ -147,7 +149,8 @@ impl Color { Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255))) } - /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`. + /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, `lch()`, `oklch()`, a CSS colour + /// name or `transparent`. LCH and OKLCH outside sRGB come back as the nearest sRGB colour. pub fn parse_css(s: &str) -> Option { let t = s.trim().to_ascii_lowercase(); if t.starts_with('#') { @@ -186,6 +189,15 @@ impl Color { } return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } + // Percentages per CSS Color 4: OKLCH 100% is lightness 1, chroma 0.4; LCH 100% is 100 and 150. + if let Some(inner) = t.strip_prefix("oklch(").and_then(|r| r.strip_suffix(')')) { + let ([l, c, h], alpha) = css_polar(inner, 1.0, 0.4)?; + return Some(Oklch { l, c, h }.to_color().with_alpha(alpha)); + } + if let Some(inner) = t.strip_prefix("lch(").and_then(|r| r.strip_suffix(')')) { + let ([l, c, h], alpha) = css_polar(inner, 100.0, 150.0)?; + return Some(Lch { l, c, h }.to_color().with_alpha(alpha)); + } if t == "transparent" { return Some(Color::TRANSPARENT); } @@ -233,6 +245,46 @@ fn css_args(inner: &str) -> Vec { inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect() } +/// The arguments of `lch()` / `oklch()`: lightness, chroma, hue and alpha. Percentages are of +/// `l_full` / `c_full`; `none` is 0. +fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> { + let parts = css_args(inner); + if !(3..=4).contains(&parts.len()) { + return None; + } + let l = css_number(parts.first()?, l_full)?; + let c = css_number(parts.get(1)?, c_full)?; + let h = css_hue(parts.get(2)?)?; + let alpha = match parts.get(3).map(String::as_str) { + None | Some("none") => 1.0, + Some(a) => parse_alpha(a)?, + }; + Some(([l, c, h], alpha)) +} + +/// A CSS number, a percentage of `full` or `none` (0); finite only. +fn css_number(p: &str, full: f32) -> Option { + let v = match p.strip_suffix('%') { + _ if p == "none" => 0.0, + Some(pc) => pc.trim().parse::().ok()? / 100.0 * full, + None => p.parse::().ok()?, + }; + v.is_finite().then_some(v) +} + +/// A CSS hue in degrees: a number, `deg`, `rad`, `grad`, `turn` or `none` (0); finite only. +fn css_hue(p: &str) -> Option { + if p == "none" { + return Some(0.0); + } + let (num, per_degree) = [("grad", 400.0 / 360.0), ("turn", 1.0 / 360.0), ("rad", std::f32::consts::PI / 180.0), ("deg", 1.0)] + .into_iter() + .find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k))) + .unwrap_or((p, 1.0)); + let v = num.parse::().ok()? / per_degree; + v.is_finite().then_some(v) +} + fn parse_alpha(a: &str) -> Option { if let Some(pc) = a.strip_suffix('%') { pc.parse::().ok().map(|v| v / 100.0) } else { a.parse::().ok() } } @@ -671,6 +723,8 @@ impl Paint { #[cfg(test)] mod gradient_tests; #[cfg(test)] +mod lch_tests; +#[cfg(test)] mod pattern_tests; #[cfg(test)] mod profile_tests; diff --git a/crates/color/src/profile.rs b/crates/color/src/profile.rs index c810e372c..f6d9fec60 100644 --- a/crates/color/src/profile.rs +++ b/crates/color/src/profile.rs @@ -172,13 +172,13 @@ fn outside(l: f64) -> bool { const ENCODE_STEPS: usize = 4096; /// The sRGB transfer function, encoded value → linear light. -fn decode(v: f32) -> f64 { +pub(crate) fn decode(v: f32) -> f64 { let v = f64::from(if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } } /// The sRGB transfer function, linear light → encoded value (clipped to 0..=1). -fn encode(l: f64) -> f32 { +pub(crate) fn encode(l: f64) -> f32 { let l = if l.is_finite() { l.clamp(0.0, 1.0) } else { 0.0 }; (if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }) as f32 } diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index f1cde02f1..accb70fdf 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -236,7 +236,7 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec { } /// Colour models the value row can show. -const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; +const MODELS: [&str; 7] = ["Hex", "RGB", "HSL", "HSB", "LCH", "OKLCH", "CSS"]; /// The model dropdown with its fields and the alpha field. Returns a new colour and whether the /// edit is done (false while a field is being scrubbed). @@ -248,7 +248,9 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { ui.horizontal(|ui| { ui.add_space(16.0); ui.spacing_mut().item_spacing.x = 4.0; - let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), 52.0, None); + // "LCH" and "OKLCH" need a wider dropdown; their fields and alpha give up the room. + let wide = matches!(MODELS.get(model).copied(), Some("LCH" | "OKLCH")); + let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), if wide { 66.0 } else { 52.0 }, None); crate::right::menu_popup(&resp).show(|ui| { for (i, m) in MODELS.iter().enumerate() { if crate::right::menu_item(ui, m, "") { @@ -257,25 +259,22 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } } }); - let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field( - ui, - Id::unique(("cp_f", id)), - widgets::Lead::None, - Some(v.round()), - "", - w, - 1.0, - ) { - Some(widgets::FieldEdit::Set(x)) => Some((x, true)), - Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)), - None => None, + // `decimals` places shown; `step` is what a scrub or arrow moves by. + let num = |ui: &mut Ui, id: &str, v: f64, w: f32, decimals: i32, step: f64| { + let p = 10f64.powi(decimals); + match widgets::number_field(ui, Id::unique(("cp_f", id)), widgets::Lead::None, Some((v * p).round() / p), "", w, step) { + Some(widgets::FieldEdit::Set(x)) => Some((x, true)), + Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)), + None => None, + } }; - match MODELS.get(model).copied().unwrap_or("Hex") { + let name = MODELS.get(model).copied().unwrap_or("Hex"); + match name { "RGB" => { let [r, g, b, _] = c.to_rgba8(); let vals = [f64::from(r), f64::from(g), f64::from(b)]; for (k, v) in vals.iter().enumerate() { - if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) { + if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0, 0, 1.0) { done = end; let mut rgb = vals; if let Some(slot) = rgb.get_mut(k) { @@ -285,28 +284,38 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } } } - "HSL" | "HSB" => { - let hsl = model == 2; - let (h, s2, l2) = if hsl { - let x = figcraft_color::Hsl::from_color(c); - (x.h, x.s, x.l) - } else { - let x = Hsb::from_color(c); - (x.h, x.s, x.b) - }; - let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0]; - for (k, v) in vals.iter().enumerate() { - if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) { + "HSL" | "HSB" | "LCH" | "OKLCH" => { + let Some(spec) = Polar::of(name) else { return }; + // The values last shown or typed stay while the colour is theirs: a chroma lowered to + // fit sRGB, or a hue on a grey, isn't read back from the colour on the next frame. + let kept_id = Id::unique("cp_polar"); + let rgba = c.to_rgba8(); + let vals = ui + .data(|d| d.get_temp::<(&'static str, [f64; 3], [u8; 4])>(kept_id)) + .filter(|(m, _, k)| *m == name && *k == rgba) + .map_or_else(|| spec.values(c), |(_, v, _)| v); + let mut kept = vals; + for (k, (v, f)) in vals.iter().zip(spec.fields()).enumerate() { + let w = if !wide { + 31.0 + } else if f.decimals > 0 { + 30.0 + } else { + 27.0 + }; + if let Some((x, end)) = num(ui, f.id, *v, w, f.decimals, f.step) { done = end; let mut v3 = vals; if let Some(slot) = v3.get_mut(k) { - *slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) }; + *slot = if f.wraps { x.rem_euclid(360.0) } else { x.clamp(0.0, f.max) }; } - let (h, a, b) = (v3[0] as f32, v3[1] as f32 / 100.0, v3[2] as f32 / 100.0); - let col = if hsl { figcraft_color::Hsl { h, s: a, l: b }.to_color() } else { Hsb { h, s: a, b }.to_color() }; - out = Some(col.with_alpha(c.a)); + let col = spec.color(v3).with_alpha(c.a); + kept = v3; + out = Some(col); } } + let shown = out.unwrap_or(c).to_rgba8(); + ui.data_mut(|d| d.insert_temp(kept_id, (name, kept, shown))); } "CSS" => { if let Some(text) = widgets::text_field(ui, Id::unique("cp_css"), &c.to_css(), 152.0, "") { @@ -321,7 +330,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } if MODELS.get(model) != Some(&"CSS") { let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round()); - let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { + let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", if wide { 40.0 } else { 44.0 }, 1.0) { Some(widgets::FieldEdit::Set(v)) => Some((v, true)), Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)), None => None, @@ -335,6 +344,93 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { out.map(|c| (c, done)) } +/// One field of a [`Polar`] model: its id, decimals shown, scrub/arrow step and range (0..=max, +/// or a hue that wraps at 360). +struct PolarField { + id: &'static str, + decimals: i32, + step: f64, + max: f64, + wraps: bool, +} + +const fn field(id: &'static str, decimals: i32, step: f64, max: f64) -> PolarField { + PolarField { id, decimals, step, max, wraps: false } +} + +const fn hue(id: &'static str) -> PolarField { + PolarField { id, decimals: 0, step: 1.0, max: 360.0, wraps: true } +} + +/// The three-field models: HSL, HSB, LCH and OKLCH, shown in their CSS units (percentages for +/// saturation and lightness, OKLCH lightness in percent). +#[derive(Clone, Copy)] +enum Polar { + Hsl, + Hsb, + Lch, + Oklch, +} + +impl Polar { + fn of(name: &str) -> Option { + Some(match name { + "HSL" => Self::Hsl, + "HSB" => Self::Hsb, + "LCH" => Self::Lch, + "OKLCH" => Self::Oklch, + _ => return None, + }) + } + + const HSL_FIELDS: [PolarField; 3] = [hue("hsl_h"), field("hsl_s", 0, 1.0, 100.0), field("hsl_l", 0, 1.0, 100.0)]; + const HSB_FIELDS: [PolarField; 3] = [hue("hsb_h"), field("hsb_s", 0, 1.0, 100.0), field("hsb_b", 0, 1.0, 100.0)]; + const LCH_FIELDS: [PolarField; 3] = [field("lch_l", 0, 1.0, 100.0), field("lch_c", 0, 1.0, 230.0), hue("lch_h")]; + const OKLCH_FIELDS: [PolarField; 3] = [field("oklch_l", 0, 1.0, 100.0), field("oklch_c", 2, 0.01, 0.5), hue("oklch_h")]; + + const fn fields(self) -> &'static [PolarField; 3] { + match self { + Self::Hsl => &Self::HSL_FIELDS, + Self::Hsb => &Self::HSB_FIELDS, + Self::Lch => &Self::LCH_FIELDS, + Self::Oklch => &Self::OKLCH_FIELDS, + } + } + + fn values(self, c: Color) -> [f64; 3] { + let v = match self { + Self::Hsl => { + let x = figcraft_color::Hsl::from_color(c); + [x.h, x.s * 100.0, x.l * 100.0] + } + Self::Hsb => { + let x = Hsb::from_color(c); + [x.h, x.s * 100.0, x.b * 100.0] + } + Self::Lch => { + let x = figcraft_color::Lch::from_color(c); + [x.l, x.c, x.h] + } + Self::Oklch => { + let x = figcraft_color::Oklch::from_color(c); + [x.l * 100.0, x.c, x.h] + } + }; + v.map(f64::from) + } + + /// Opaque. + fn color(self, v: [f64; 3]) -> Color { + let [a, b, d] = v.map(|v| v as f32); + match self { + Self::Hsl => figcraft_color::Hsl { h: a, s: b / 100.0, l: d / 100.0 }.to_color(), + Self::Hsb => Hsb { h: a, s: b / 100.0, b: d / 100.0 }.to_color(), + Self::Lch => figcraft_color::Lch { l: a, c: b, h: d }.to_color(), + Self::Oklch => figcraft_color::Oklch { l: a / 100.0, c: b, h: d }.to_color(), + } + } +} + fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color()) } @@ -819,7 +915,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { changed = true; done |= d; } - // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha. + // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha. if let Some((c, d)) = model_fields(ui, nc) { nc = c; hsb = Hsb::from_color(nc); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index cde033116..012dd923c 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -494,6 +494,82 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { /// Asserts scrubbing a prototype delay sets the value once when released and adds one undo /// step. +#[test] +fn lch_and_oklch_picker_fields_edit_lightness_keeping_hue() { + for (model, name, l) in [(4usize, "LCH", "lch_l"), (5, "OKLCH", "oklch_l")] { + let (mut u, a, _) = setup(); + let color = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap(); + u.app.set_props(&[a], json!({"fills": [{"type": "solid", "color": "#0D99FF"}]})); + let before = color(&u); + assert_eq!(before.to_hex(), "0D99FF"); + u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model)); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + for _ in 0..3 { + u.frame(vec![]); + } + let steps = undo_steps(&u); + let from = lead(&u, Id::unique(("cp_f", l))); + drag(&mut u, from, -20.0, Modifiers::NONE); + let after = color(&u); + let hue = |c| figcraft_color::Oklch::from_color(c).h; + assert!(after.luminance() < before.luminance(), "{name}: darker"); + assert!((hue(after) - hue(before)).abs() < 3.0, "{name}: hue kept {} → {}", hue(before), hue(after)); + assert_eq!(undo_steps(&u), steps + 1, "{name}: one undo step"); + } +} + +/// Type `text` into the picker field `id` (`("cp_f", id)`) and press Enter. +fn type_into_picker(u: &mut Ui, id: &str, text: &str) { + let rect = u.ctx.read_response(Id::unique(("cp_f", id)).with("field")).unwrap_or_else(|| panic!("no picker field {id}")).rect; + click(u, rect.center()); + u.frame(vec![Event::Text(text.into())]); + key(u, Key::Enter, Modifiers::NONE); + u.frame(vec![]); +} + +/// A picker on layer `a` in model `model` (an index into the model menu), drawn and settled. +fn open_picker(u: &mut Ui, a: NodeId, model: usize) { + u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model)); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + for _ in 0..3 { + u.frame(vec![]); + } +} + +fn kept_polar(u: &Ui) -> [f64; 3] { + u.ctx.data(|d| d.get_temp::<(&'static str, [f64; 3], [u8; 4])>(Id::unique("cp_polar"))).unwrap().1 +} + +#[test] +fn oklch_keeps_a_hue_typed_on_a_grey() { + let (mut u, a, _) = setup(); + let color = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap(); + u.app.set_props(&[a], json!({"fills": [{"type": "solid", "color": "#808080"}]})); + open_picker(&mut u, a, 5); + type_into_picker(&mut u, "oklch_h", "250"); + u.frame(vec![]); + assert_eq!(kept_polar(&u)[2].round(), 250.0, "the hue stays on a grey"); + type_into_picker(&mut u, "oklch_c", "0.1"); + let ok = figcraft_color::Oklch::from_color(color(&u)); + assert!(ok.c > 0.05 && (ok.h - 250.0).abs() < 3.0, "chroma raised along the typed hue: {ok:?}"); +} + +#[test] +fn lch_keeps_the_typed_chroma_while_the_colour_is_out_of_srgb() { + let (mut u, a, _) = setup(); + let color = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap(); + u.app.set_props(&[a], json!({"fills": [{"type": "solid", "color": "#0D99FF"}]})); + open_picker(&mut u, a, 4); + let hue = kept_polar(&u)[2]; + // Lightness near white: sRGB holds almost no chroma there, so the colour comes out near grey. + type_into_picker(&mut u, "lch_l", "98"); + assert_eq!(kept_polar(&u)[2], hue, "hue kept near white"); + // Back down: the chroma and hue asked for come back instead of a grey or a red. + type_into_picker(&mut u, "lch_l", "60"); + let lch = figcraft_color::Lch::from_color(color(&u)); + assert!(lch.c > 50.0 && (lch.h - hue as f32).abs() < 2.0, "blue again: {lch:?} (hue {hue})"); +} + #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); diff --git a/docs/architecture.md b/docs/architecture.md index 8bd2a183d..065a21326 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ replaced. | Layer | Crate | Lines | Role | |---|---|---|---| | L0 | `figcraft-geom` | ~0.5k | Affine transforms, shape path builders (rounded and smoothed corners, polygons, stars, arcs) on kurbo | -| L0 | `figcraft-color` | ~1.3k | Colours (HSB/HSL, hex/CSS, Display P3), paints (solid, gradients, image), blend modes | +| L0 | `figcraft-color` | ~1.3k | Colours (HSB/HSL, LCH/OKLCH, hex/CSS, Display P3), paints (solid, gradients, image), blend modes | | L1 | `figcraft-doc` | ~8.5k | Document model: pages, node tree in a persistent map (`imbl`, stable ids), properties, components, styles, variables, prototype data, comments, versions. Serde everywhere | | L2 | `figcraft-layout` | ~1.8k | Auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max), constraints, layout grid geometry | | L2 | `figcraft-text` | ~3.1k | Font database (optional embedded craft-fonts + system scan), shaping (harfrust), bidi, line layout, glyph outlines (skrifa), variable fonts | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07a800da7..2604cfc92 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -28,6 +28,7 @@ interaction feel not yet compared. | Multiplayer avatars, Share button | missing | waits for multiplayer | | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | +| Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -176,6 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | +| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged | | 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged | | 2026-10-22 | minor | Vector edit mode: points selected in several layers get the transform box, in the page's axes since the layers can be turned differently; its corners and edges scale them, outside a corner rotates them and inside drags them, each layer's points in its own coordinates and one undo step for all, and a click inside clears every layer's points (`transform_tests`). Numbers unchanged | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |