mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 14:26:49 -06:00
M1: LCH and OKLCH models in the colour picker
The picker's model menu offers LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS, on desktop and web. Lightness, chroma and hue fields; an edit outside sRGB lowers chroma and keeps lightness and hue (CSS gamut mapping). The CSS field and Color::parse_css read lch() and oklch().
This commit is contained in:
+2
-1
@@ -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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (colour picker LCH and OKLCH models; community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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
|
||||
@@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 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 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 mul(m: &[V3; 3], v: V3) -> V3 {
|
||||
let row = |r: &V3| r[0] * v[0] + r[1] * v[1] + r[2] * v[2];
|
||||
[row(&m[0]), row(&m[1]), row(&m[2])]
|
||||
}
|
||||
|
||||
fn to_linear(c: f64) -> f64 {
|
||||
let a = c.abs();
|
||||
let l = if a <= 0.04045 { a / 12.92 } else { ((a + 0.055) / 1.055).powf(2.4) };
|
||||
l.copysign(c)
|
||||
}
|
||||
|
||||
fn from_linear(c: f64) -> f64 {
|
||||
let a = c.abs();
|
||||
let e = if a <= 0.0031308 { a * 12.92 } else { 1.055 * a.powf(1.0 / 2.4) - 0.055 };
|
||||
e.copysign(c)
|
||||
}
|
||||
|
||||
fn linear_rgb(c: Color) -> V3 {
|
||||
[to_linear(f64::from(c.r)), to_linear(f64::from(c.g)), to_linear(f64::from(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 lab = |c: f64| {
|
||||
let (s, co) = h.to_radians().sin_cos();
|
||||
[l, c * co, c * s]
|
||||
};
|
||||
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));
|
||||
}
|
||||
let ch = |v: f64| from_linear(v.clamp(0.0, 1.0)) as f32;
|
||||
Color::new(ch(rgb[0]), ch(rgb[1]), ch(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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());
|
||||
}
|
||||
+33
-1
@@ -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
|
||||
@@ -146,7 +148,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<Self> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.starts_with('#') {
|
||||
@@ -173,6 +176,24 @@ impl Color {
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("oklch(").and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner);
|
||||
// Percentages: 100% lightness is 1, 100% chroma is 0.4 (CSS Color 4).
|
||||
let l = css_number(parts.first()?, 1.0)?;
|
||||
let c = css_number(parts.get(1)?, 0.4)?;
|
||||
let h = parts.get(2)?.trim_end_matches("deg").parse::<f32>().ok()?;
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
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 parts = css_args(inner);
|
||||
// Percentages: 100% lightness is 100, 100% chroma is 150 (CSS Color 4).
|
||||
let l = css_number(parts.first()?, 100.0)?;
|
||||
let c = css_number(parts.get(1)?, 150.0)?;
|
||||
let h = parts.get(2)?.trim_end_matches("deg").parse::<f32>().ok()?;
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
return Some(Lch { l, c, h }.to_color().with_alpha(alpha));
|
||||
}
|
||||
if t == "transparent" {
|
||||
return Some(Color::TRANSPARENT);
|
||||
}
|
||||
@@ -213,6 +234,15 @@ fn css_args(inner: &str) -> Vec<String> {
|
||||
inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// A CSS number, or a percentage of `full`; finite only.
|
||||
fn css_number(p: &str, full: f32) -> Option<f32> {
|
||||
let v = match p.strip_suffix('%') {
|
||||
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
|
||||
None => p.parse::<f32>().ok()?,
|
||||
};
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
fn parse_alpha(a: &str) -> Option<f32> {
|
||||
if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }
|
||||
}
|
||||
@@ -628,6 +658,8 @@ impl Paint {
|
||||
#[cfg(test)]
|
||||
mod gradient_tests;
|
||||
#[cfg(test)]
|
||||
mod lch_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_tests;
|
||||
|
||||
@@ -236,7 +236,7 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec<Color> {
|
||||
}
|
||||
|
||||
/// 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::new(("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::new(("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,25 +284,55 @@ 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)
|
||||
"HSL" | "HSB" | "LCH" | "OKLCH" => {
|
||||
// Shown as: hue/lightness/chroma in their CSS units; OKLCH lightness as a percent.
|
||||
let vals: [f64; 3] = match name {
|
||||
"HSL" => {
|
||||
let x = figcraft_color::Hsl::from_color(c);
|
||||
[f64::from(x.h), f64::from(x.s) * 100.0, f64::from(x.l) * 100.0]
|
||||
}
|
||||
"LCH" => {
|
||||
let x = figcraft_color::Lch::from_color(c);
|
||||
[f64::from(x.l), f64::from(x.c), f64::from(x.h)]
|
||||
}
|
||||
"OKLCH" => {
|
||||
let x = figcraft_color::Oklch::from_color(c);
|
||||
[f64::from(x.l) * 100.0, f64::from(x.c), f64::from(x.h)]
|
||||
}
|
||||
_ => {
|
||||
let x = Hsb::from_color(c);
|
||||
[f64::from(x.h), f64::from(x.s) * 100.0, f64::from(x.b) * 100.0]
|
||||
}
|
||||
};
|
||||
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) {
|
||||
let lch = matches!(name, "LCH" | "OKLCH");
|
||||
// (field id, decimals, step, max) per field; the hue wraps.
|
||||
let fields: [(&str, i32, f64, f64); 3] = match name {
|
||||
"LCH" => [("l", 0, 1.0, 100.0), ("c", 0, 1.0, 230.0), ("h", 0, 1.0, 360.0)],
|
||||
"OKLCH" => [("l", 0, 1.0, 100.0), ("ok_c", 2, 0.01, 0.5), ("h", 0, 1.0, 360.0)],
|
||||
_ => [("h", 0, 1.0, 360.0), ("s", 0, 1.0, 100.0), ("l", 0, 1.0, 100.0)],
|
||||
};
|
||||
let hue = if lch { 2 } else { 0 };
|
||||
for (k, (v, (id, decimals, step, max))) in vals.iter().zip(fields).enumerate() {
|
||||
let w = if !wide {
|
||||
31.0
|
||||
} else if decimals > 0 {
|
||||
30.0
|
||||
} else {
|
||||
27.0
|
||||
};
|
||||
if let Some((x, end)) = num(ui, id, *v, w, decimals, 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 k == hue { x.rem_euclid(360.0) } else { x.clamp(0.0, 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() };
|
||||
let [a, b, d] = v3.map(|v| v as f32);
|
||||
let col = match name {
|
||||
"HSL" => figcraft_color::Hsl { h: a, s: b / 100.0, l: d / 100.0 }.to_color(),
|
||||
"LCH" => figcraft_color::Lch { l: a, c: b, h: d }.to_color(),
|
||||
"OKLCH" => figcraft_color::Oklch { l: a / 100.0, c: b, h: d }.to_color(),
|
||||
_ => Hsb { h: a, s: b / 100.0, b: d / 100.0 }.to_color(),
|
||||
};
|
||||
out = Some(col.with_alpha(c.a));
|
||||
}
|
||||
}
|
||||
@@ -321,7 +350,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::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) {
|
||||
let edit = match widgets::number_field(ui, Id::new("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,
|
||||
@@ -791,7 +820,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);
|
||||
|
||||
@@ -438,6 +438,30 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
|
||||
assert_eq!(undo_steps(&u), steps + 2, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lch_and_oklch_picker_fields_edit_lightness_keeping_hue() {
|
||||
for (model, name) in [(4usize, "LCH"), (5, "OKLCH")] {
|
||||
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::new("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::new(("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");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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-18 · **Change:** minor (colour picker LCH and OKLCH models; 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); CSS reads `lch()` and `oklch()` (`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 |
|
||||
|
||||
@@ -172,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel |
|
||||
| 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |
|
||||
|
||||
Reference in New Issue
Block a user