Review of #1796: rounding a mark's position to 1/65536 px with `round`
sent the halves of that step away from zero, the asymmetry the PR set
out to remove, one step further down. At a step of 25.5 - 2^-17 px, a
pan by 26 pixels put the first mark a pixel off a full render. Both
roundings now go half up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/format/src/fig/kiwi.rs:615
Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
Co-Authored-By: GPT-6-Luna <noreply@openai.com>
The board's dot grid puts each mark on the device pixel nearest its
page position, measured from the view's top-left. At a zoom of 1.0625 a
step of 24 is 25.5 pixels, so every second mark falls halfway between
two pixels, and a view a whole number of pixels on could put it on the
other one: `round` sends halves away from zero, so a mark left of the
view's origin went left and one right of it went right, and the origin
`(px + offset) / scale` reached the half with another rounding error.
A pan moved those marks by a pixel (up to 123 levels off).
Positions are now rounded to 1/65536 px and then half up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Noise and texture lay their grain cells out in the layer's own
coordinates, so a patch, a band or a pan should draw the grains a full
render draws. At a zoom of 1.7, a 1-unit cell is 1.7 pixels across and
every tenth edge falls on a row of pixel centres; a render whose origin
is a whole number of pixels off reached that edge with another rounding
error and moved the row into the next cell. An edit over a textured
layer patched up to 197 levels off a full render, and a noisy one 224.
Cell positions are now rounded to 1/2^20 of a cell before they're cut
into cells, and texture adds its scatter to the pixel index in whole
pixels instead of flooring it in f32 together with the index.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #1773: the carry-forward now covers red, green and blue in an sRGB mix (converted first in another space, so zero there, per CSS Color 5 section 4.1 on analogous components) as well as the alpha in every mix, and a component missing in both colours stays missing in the result, so an enclosing mix carries it from its other colour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found while testing crates/svg import (no existing issue), each with a test that fails before and passes after. An <image preserveAspectRatio="none"> took ImageScaleMode::Fill, which keeps the picture aspect and crops; a box of a different aspect now takes a whole-picture Crop, which the renderer draws as a stretch (SVG 2 section 8.6). A stroke-dashoffset equal to half the first dash was skipped on every shape because an open line starts half a dash in by itself; a closed outline runs its pattern from its start, so the exemption now applies to open outlines only (SVG 2 section 13.5.6).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up on #1770: the typed colour keeps the page opacity only when the text carries no alpha (not 4 or 8 hex digits); FF0000FF on a translucent page now sets 100%. Test extended.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up on #1766: a backward wall-clock step between the push and the test read made the ordering assertion flaky.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up on #1773: with a none alpha now zero when a colour is rendered on its own (CSS Color 4 section 4.4), color-mix() needs the carry-forward rule of CSS Color 5 section 4.1, else color-mix(in srgb, rgb(255 0 0 / none), blue) came out half transparent instead of opaque purple. The operand reports a missing alpha and mix() fills it from the other colour; both missing stays zero.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up on #1775: the dark rule is the one text uses (on_dark_background); a frame with no fill, or a fill under 50% alpha, is looked through to what is behind it, and the threshold is Color::is_dark (#757575 is dark, #767676 is light).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/variables.rs:267 (move_mode)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/page.rs:132
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/create.rs:~286 (create, the `points` branch using filter_map)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Figma: "a round 3px stroke weight in black, unless you are sketching on a dark canvas or frame". The Pencil always drew black. It now takes the colour from the same dark-background rule new text uses (the nearest ancestor with a mostly opaque solid fill, else the page), and the overlay carries that colour for the stroke in progress so the preview matches. Engine and UI regression tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found while testing crates/geom/src/lib.rs (no existing issue): the builders replaced NaN and infinities with 0 but took any finite size, and kurbo subdivides an arc in proportion to the sixth root of its radius, so ellipse(1e36, ...) built about a million cubics and 1e60 never returned (sizes arrive from files and commands). The sanitizer now also clamps magnitudes to MAX_COORD (1e9, the bound measure::finite_path already uses). Polyline::nearest returns None for a non-finite point instead of Some((NaN, NaN)). Tests cover 1e36, 1e300 and f64::MAX for every builder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found while testing crates/color/src/lib.rs (no existing issue), each with a test that fails before and passes after: rgb() accepted nan/inf/infinity identifiers (Rust float syntax, not CSS numbers); hsl() refused the turn, grad and rad hue units lch() and oklch() already take, and accepted repeated suffixes; none was refused in rgb() and hsl() and read as opaque in a lch()/oklch() alpha, where CSS Color 4 section 4.4 says a missing component is zero; to_css could print an alpha that read back as a different 8-bit value, and printed NaN or negative alphas from a field-built colour. One css_f32 number reader (sign, digits, point, exponent) and a css_fraction helper (number, percentage or none) now serve every CSS number path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Design panel drew the page opacity as a "100 %" label, so it could not be changed from the panel although the model, page.setBackground and the colour picker already handled a translucent page. The label is now a percent number field like the other opacity fields (named Opacity for screen readers), committing page.setBackground; a scrub stays one undo step. The hex field keeps the current opacity unless the typed colour carries one, as the colour picker does. Regression test in field_tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The window lays itself out with zero item spacing, and the header button bar is a child Ui that inherited it, so Import and Export touched. The bar now spaces its buttons 16 px apart, the gap Export has to the Close button glyph.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The event log read the clock through std::time::SystemTime and Instant, which panic on wasm32-unknown-unknown ("time not implemented on this platform"). Every mutating comment.* command appends an event, so the first comment posted in the web build hit it. The log now takes SystemTime, UNIX_EPOCH and Instant from web_time, as the rest of the engine does. A test checks the stamp is a current Unix time read through the same clock.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* text: use craft-font-catalog on Unix
On Linux and BSD distributions that do not follow traditional FHS layout
(such as NixOS and Guix), or systems using custom font configurations,
fonts are not located under `/usr/share/fonts` or `/usr/local/share/fonts`.
Instead, font directories are declared in `/etc/fonts/fonts.conf` and its
included configuration files (such as `/etc/fonts/conf.d`), or under
`$XDG_DATA_DIRS/fonts`.
`figcraft-text` already depends on `craft-font-catalog`, which provides
`craft_font_catalog::font_dirs`:
- Preserves all existing directory sources (`/usr/share/fonts`,
`/usr/local/share/fonts`, and user font directories).
- Adds Flatpak host directories (`/run/host/fonts`), `$XDG_DATA_DIRS`,
and fontconfig directory trees (`/etc/fonts/fonts.conf`).
- Deduplicates the combined list via `craft_font_catalog::dedup_dirs`
using canonical paths, ensuring equivalent directories and symlinks
are not redundantly scanned.
Use `craft_font_catalog::font_dirs` in `system_font_dirs()` on Unix, and
retain `unix_user_font_dirs` under `#[cfg(test)]` for its unit tests.
* text: walk each system font folder once
Fontconfig usually lists folders the scan already has (/usr/share/fonts); the list is deduplicated
so their faces aren't indexed twice (CodeRabbit on #1765).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* text: scan the catalog's full Unix font folder list
fontconfig_dirs alone dropped /usr/share/fonts, ~/.fonts and the XDG folders when no fontconfig
configuration exists; font_dirs keeps them, adds what fontconfig names, and deduplicates, as the
PR description intended. The local user-folder helper it replaces is removed with its tests (the
catalog applies the same XDG rules).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/variables_io.rs:~80-105 (css, non-tailwind `name` closure using css_var_of) with crates/engine/src/dev/codegen/css.rs:41 (css_var_name)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/dev/codegen/css.rs:121 (css_decls, the PaintKind::Gradient arm calling css_gradient at :255)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* Escape backslashes and line breaks in CSS font-family export
Found while testing crates/engine/src/dev/codegen/css.rs:223 (text_decls, the font-family put)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Escape quotes and < in CSS font-family export
A double quote in a family name is escaped instead of turned into a single quote (CodeRabbit on
#1752), and < is written as \3C so a name can't end the generated page's <style> block.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Found while testing crates/ui-egui/src/panels/typography/spacing.rs:166 (parse_line_height, px branch; also :183 and :180 in parse_letter_spacing, px and % branches)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Found while testing crates/ui-egui/src/widgets/field_math.rs:168 (stepped, used by widgets.rs:131 scrubbed)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* Fix code export printing inf for very large finite numbers
Found while testing crates/engine/src/dev/codegen.rs:185 (num)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Keep rem values finite past num6's scaling overflow
num6 had the same x1e6 overflow as num: a huge finite length printed 0rem (CodeRabbit on #1757).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The 20 pt live mark reached 4 pt into the tab's 32 pt close target; it sits 4 pt further left, with
4 pt more room kept for it beside the name. ROADMAP.md's Last updated date is the new row's.
selection.createColorStyle gives the new style the variable bindings of the fill or stroke it is made from, the color and the gradient stops, so linked layers keep following the variables. Targets bound to different variables are refused, and the Styles menu leaves Create style out for them.
selection.applyColorStyle chose the library path only when `library` read as a string, so an
object or array fell through and linked a local style of the same name. Any non-null `library`
now goes to the library lookup, which refuses a non-string before any change.
A created style holds the row's one paint, and linking it replaces the target's whole fill or
stroke list, so a layer with other fills or strokes lost them. selection.createColorStyle now
refuses such targets before any change, and the Styles menu leaves Create style out for those
rows.
A row's Styles menu lists this file's color styles (library copies are no
longer listed as local), then each enabled library's published color
styles under the library's name. Choosing a library style copies it in,
linked and read-only, and applies it to every fill and stroke the row
stands for in one undo step (selection.applyColorStyle with `library`).
A row linked to a style or variable now has the Styles button too, so it
can switch to another style.
Create style on an ordinary row opens a window with the next free
"Color n" name. Create makes a style of the row's whole paint (gradient
stops, opacity and blend mode included) and links it to every target in
one undo step (selection.createColorStyle {targets, name, description?}).
A name another style has, an empty name, or a row whose slots no longer
share one paint is refused, and the window stays open. Text ranges with
fills of their own take the style's value without a link.
vector.simplify and vector.offset rebuild a closed vector from its rounded
outline with the layer's corner smoothing, as the canvas draws it. A vector
whose regions have paints of their own was rebuilt from its sharp points
before; it now keeps its rounded corners. The Penpot export writes a stroked
vector's path with its smoothed corners.
A JPG exported in a profile other than the document's was converted while
still translucent and then composited over white, so translucent fills,
shadows and antialiased edges came out a few levels off what the document
shows. The render is now flattened over white in the document's profile
first. PNG and WebP keep their alpha and are unchanged.
Each export setting has a color profile: Same as file (the default, the
document's profile when it exports), sRGB or Display P3. PNG, JPG and
WebP settings list it in their advanced settings, and export.add,
export.update, file.export, file.exportLayers (for every setting) and
the MCP export tool take it as colorProfile. The rendered pixels are
converted to the chosen profile (out-of-gamut colors clip) and the file
is tagged as before: an sRGB chunk for sRGB PNG, the generated ICC
profile (and cICP in PNG) for Display P3; sRGB JPG and WebP stay
untagged. A setting keeps its choice when its format changes, and
settings saved without one read as Same as file.
SVG, PDF and Android XML show no choice and stay sRGB; PDF has no
output intent yet.
A vector without region paints tested a click against its plain rounded fill while the canvas drew the smoothed corners, so a click between the two could select a layer where no fill is drawn or miss fill that is drawn. The inside test now uses the network as drawn, keeping the even-odd rule.
A vector's corner smoothing was stored, imported and exported, but its
rounded points were drawn as plain circular arcs, and the Design panel
hid the Corner smoothing control for vectors.
A vector with a closed region now draws its rounded points with the
layer's corner smoothing, on the canvas, in hit testing, in PNG, SVG and
PDF exports, and in the fill and stroke geometry of .fig exports. Each
point keeps its own radius; a point without one stays sharp. The corner
uses the profile of smoothed polygon corners, and smoothing is reduced
before the radius when a corner does not fit in half of each segment.
FigCraft's own rules for cases the help page does not describe: only a
point between two straight segments that both bound a closed region is
smoothed; points on open branches and points next to a curved segment
keep the plain arc; concave corners are smoothed like convex ones.
The Design panel shows Corner smoothing and its iOS (60%) button for
vectors with a closed region, and not for open paths.