UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#02da96, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).
craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
clap derive types replace the hand-written parser: typed values, usage
errors (exit 2) before any work, help from the same definitions.
Syntax changes: a repeated option is a usage error (was: last wins; the
automation roots still repeat); a value starting with `-` needs `=`; a
positional starting with `-` needs `--`; `--o` and the `version`
subcommand are gone (`--version` stays); `<sub> --help` prints that
subcommand's help; usage errors are clap's text; `--format` is checked
before the file is opened.
The settings folder comes from craft-settings' rules: FIGCRAFT_CONFIG_DIR,
a `portable` file next to the executable (config/ beside it), then the
platform folder every earlier version used. The Windows portable zip ships
the marker, and a portable folder without preferences starts with a copy
of the platform folder's.
These scripts write no spaces between words, so a paragraph was one word
that only broke where it overflowed, mid-word (พัฒนาซอ|ฟต์แวร์).
breaks::word_breaks finds the word starts inside each run of them with
ICU4X's line segmenter (icu_segmenter 2.3, matching the icu_* crates in
the lock), and break_lines may break there when the next glyph starts a
new cluster. Where a run meets other text the UAX #14 subset decides as
before; a word wider than its box keeps #1400's emergency break.
Desktop uses the segmenter's dictionaries; the browser uses its LSTM
models (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB web
gate can't take). ICU4X breaks between stray marks are filtered through
safe_emergency_break, so junk input never splits a cluster.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6DyK6i9Nvvsp3tp77UP
A missing family the suite's font store has (Google Fonts, Noto) gets a
Download button. The first one asks the user to allow downloading fonts,
then craft-libs' craft-font-store installs pinned, checksum-verified files
into the Craft apps' shared font folder on a thread of its own. The font
scan reads that folder; once installed the text fits the real font.
font.download / font.downloads drive the store as its agent: refused while
downloading is off, never able to turn it on. Desktop only.
The browser build's wasm was 25,982,540 bytes, past the gate raised for 0.12.1. It is now
23,915,213 bytes (22.8 MiB), 1.2 MiB under the restored 24 MiB gate:
- Catalog rows keep their plural forms behind a reference: Text is two words, not room for
four strings, across ~52,000 baked-in rows (-454 KB).
- The browser build compiles with wasm SIMD (apps/figcraft-web/.cargo/config.toml, wasm-opt
--enable-simd), so egui's glyph rasterizer leaves out its scalar SIMD fallback (-1.6 MB).
The plugin workspace is built from the repository root and stays without SIMD.
- Revert the gate raise (cb227c97); the hosting guide names the WebAssembly SIMD requirement.
winit's default macOS app menu ends the process through AppKit's
`terminate:` at once, so ⌘Q and Quit from the menu bar never asked about
unsaved changes (the recovery copies were all that survived).
- Install craft-libs' app menu (craft-menu, same rev as the other
craft-libs pins; macOS only). Its Quit sends the main window a close
request, the same path as the window's close button, so
windows::on_close_request cancels the close and the unsaved-changes
question asks first. Failure to install keeps winit's menu and logs it.
- windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer pins
the contract the menu relies on: a close request with unsaved changes
sends CancelClose and asks; Don't Save then closes.
- Like the close button it closes the main window only: with several
windows open, each quit closes one (documented).
- docs/hardware-parity.md macOS row, status line and revision history.
- Cargo.lock: craft-menu, craft-quit, craft-keymap, muda and their deps;
cargo's own minimal resolution also moves a few windows-sys/getrandom
edges to versions already in the lock.
The 0.12.0 release's Web job failed at 25,319,327 bytes against the 24 MiB (25,165,824-byte) gate. opt-level z, fat LTO and wasm-opt -Oz are already in place and extra wasm-opt passes save only ~20 KB, so the gate moves to 25,690,112 bytes, still under Cloudflare's 25 MiB per-file cap. The wasm grew ~2 MB in three days (egui 0.37, new features); bringing it back down is follow-up work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ui-egui): font family menu keeps its search field and grows back after a filter clears
Repin craft-libs to the shared dropdown (craft-ui), move the font family menu to
craft_ui::combo::Menu with the search in a fixed header, and use craft_ui::ComboBox
for the credits sort. clippy bans egui::ComboBox.
* chore(deps): craft-libs 60d60d48 (dropdown fixes)
* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)
* chore(deps): craft-libs 92d392cf (#387 merged)
* fix(ui-egui): craft-ui on every target (wasm uses the shared ComboBox too)
class_name runs layer names through deunicode (new dependency: pure Rust
tables, zero deps, BSD-3-Clause, the slug crate's base) before its ASCII
filter, so Korean, Cyrillic, Latin-diacritic and CJK names become
readable ASCII classes in HTML/CSS/React/Leptos output, component roots
and data-* variant attributes: 로그인 화면 → rogeuin-hwamyeon, кнопка →
knopka, über → uber, 按钮 → an-niu (before, every one came out layer-,
layer--2, ...). Symbols-only names take the node's kind (.text, .frame)
instead of layer-, the 40-character cut lands on a word boundary, and a
leading digit keeps the layer- prefix. SwiftUI/Compose type names are
unchanged (their own sanitizer); ASCII-only names are byte-identical.
html_codegen_tests updated and extended.
Two gaps CodeRabbit found in the guard:
- `same_file` compared paths, plain and resolved. A second hard link to
the file is a different path to the same data, so `convert`,
`run --out` and Save As to the link passed the guard and truncated
the file. It now asks the file system (same-file: device and inode on
Unix, volume and file index on Windows). same-file was already in the
lock through walkdir.
- `run --in X --out X` checked the tab the script left active. A script
that ends on `file.duplicate` or `file.new` leaves a tab with no count
of images left in X, so X was written over. The count is now taken
when X is opened and checked against `--out` whatever tab is active;
the active tab's own file is still checked as before.
The script check is a separate function so it can be tested without a
file past the 4 GiB budget.
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.
Part of tracking issue storytold/craft-libs#319🤖 Generated with Codebuff
Co-authored-by: Codebuff <noreply@codebuff.com>
* feat: startup update prompt modal and direct self-updating
The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status window. The check at start is opt-in for the desktop app, an install holds back while documents are unsaved, and the Windows update helper runs before argument parsing.
Ref: storytold/craft-libs#319
* fix(updates): re-add the update hooks to the 0.37 frame loop
* fix(updates): address review feedback
Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.
* fix(updates): show the prompt on Home and presenter frames and close the application, not one window
* fix(updates): add the update strings to the English catalog
* E21: require release font coverage and stabilize capture deadline tests
* E21: separate capture, IME and field-save regression phases
* M0: correct the migration qualification reference
* M-egui037: document input lifecycle and UI regression contracts
* M0: correct egui 0.37 status docs and stabilize join-code regression
---------
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
The 0.11.1 release's Web job failed: the wasm was 25,248,590 bytes, over packaging/web/package.sh's 25,165,824-byte limit. opt-level z brings it to 23,306,645 bytes (measured locally with the release's craft-fonts ref, wasm-bindgen 0.2.129 and wasm-opt -Oz).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Dev profile: line tables for workspace crates, no debug info for dependencies
* Tests: one integration-test binary per crate with several test files
* xtask ci: nextest, doctests, tests without examples, --keep-going; add xtask test
Checked by importing synthetic files into the incumbent (Drafts > Import,
black-box) and against a "Save local copy" of a synthetic document.
- Images are named and referenced by the SHA-1 of their bytes. With
SHA-256 names, any file with an image failed to import ("Internal error
during import").
- Component sets hold their property definitions, variant properties
included (stateGroupPropertyValueOrders); each variant has its own copy
of each definition (parentPropDefId) and its variantPropSpecs, and its
layers and instances name that copy. Definitions and values also travel
as varValue; text values carry their lines; layers get componentPropRefs
next to the parameter consumption map. Before, sets came in without
variant properties and with their other properties unbound.
- An axis without a maximum size is written as infinite: 0 collapsed
auto layout frames with only a maximum width.
- Compound weights are spelt the way font families name their faces
("SemiBold"; "Semi Bold" in Inter): text in Libre Franklin Semi Bold was
a missing font there and wasn't drawn.
- The kiwi encoder writes infinite floats.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
Replaces the two hand-rolled Howard Hinnant days-from-civil copies with chrono
as a workspace dependency:
- crates/collab/src/message.rs: utc_date (YYYY-MM-DD HH:MM UTC)
- crates/ui-egui/src/home_recents.rs: date (Mon D, YYYY, UTC; keeps the
translated month names and trf formatting)
Output is unchanged. Neither function had a test, so each gains one pinning the
exact strings the old code produced (leap days included). chrono uses only std,
so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.
Follow-up to storytold/cadcraft#1785
* feat(window): adopt shared craft-window for Linux decorations
Replace FigCraft's bespoke Wayland xdg-decoration probe
(apps/figcraft/src/linux_window.rs) with the shared craft-window crate
from craft-libs, so every Craft app agrees on who draws the window frame
per desktop.
The decision is now made once, before the viewport is built, by
craft_window::Choice::probe(Prefix::new("FIGCRAFT")) and mapped onto
FigCraft's existing TitleBar enum:
Frame::Desktop => TitleBar::Native (TitleBar::Inset on macOS, unchanged)
Frame::App(_) => TitleBar::Custom
This drops the old runtime send_viewport_cmd(Decorations(..)) switch and
the linux_window probe. macOS keeps its inset traffic lights and Windows
keeps the system frame exactly as before. The only behaviour change: on
KDE and other Wayland compositors with server-side decorations (Sway,
Hyprland, COSMIC) FigCraft now keeps the native frame instead of always
drawing its own custom title bar, matching the rest of the suite.
NOT MERGEABLE YET: craft-window is pinned to the unmerged feature branch
from storytold/craft-libs#217 and must be repointed to a merged rev
before this can land.
* fix(window): preserve Windows titlebar; pin craft-window to merged rev
- Keep FigCraft's own custom title bar on Windows. craft-window returns
Frame::Desktop on Windows, and the mapper was turning that into
TitleBar::Native, flipping Windows from its existing undecorated custom
bar to the system frame. Map Frame::Desktop to Custom on Windows (as
macOS maps to Inset); only Linux/BSD Frame::Desktop becomes Native. This
keeps macOS and Windows unchanged; the sole change stays KDE/Wayland.
- Repoint craft-window from the (now-merged) feature branch to the squash
commit on storytold/craft-libs (craft-libs#217), and commit Cargo.lock.
Verified: cargo build -p figcraft and cargo test -p figcraft (53 tests)
pass against the merged dependency; rustfmt clean.
A new L4 crate that writes layers as an Android Vector Drawable (XML):
shapes, booleans and text as baked path data, solid and gradient fills
(linear, radial and sweep, exact for any handle frame via a group and a
2x2 SVD), native strokes where Android draws them the same and filled
stroke outlines otherwise, clip frames and masks as <clip-path> groups.
What a drawable can't hold is governed by Compat (bestEffort / exact /
ignore): layer opacity over overlapping shapes, even-odd fills and clips
(rewritten to non-zero via pathops), diamond gradients, alpha masks;
effects, image and pattern fills and blend modes are left out with a
warning. Output is pretty-printed, numbers are finite and short, names
(android:name) are optional, escaped and unique.
A document carries things the app acts on for whoever opens it. Three of them were
taken as given.
Prototype Open link and Dev Mode dev resources both reached the platform's opener
with any scheme. In the web build the opener is window.open, so a saved document
could run script in the app's own origin; on the desktop any scheme went to the
system's handler. Both now pass through the new engine module `doc_urls`, which
allows http, https and mailto and nothing else. It is an allow-list on purpose: a
list of known-bad schemes is outgrown by every handler a platform adds.
A figcraft:// invite link joined the session at once. The system hands the app
whatever such link was followed, which may come from a page or a message, so the
Join dialog now opens with the link filled in and the person confirms.
A library link stored in a document was loaded from wherever it pointed. Only a
path on this computer is followed now, so opening someone else's file cannot make
the app contact their server (which on Windows would also send credentials).
Tests: each change has one that fails without it, verified by reverting. The
prototype test fires through an afterDelay trigger, since no click is needed, and
keeps a positive case so a refusal cannot pass vacuously.
A behaviour suite for the layers panel and the Assets panel written from the public documentation
(table rows with one-line derivations; rows the documentation is silent on say so and pin today's
behaviour), and the fixes it found.
- Engine suite `crates/engine/src/panels_behaviour_tests.rs` (6 tests, about 50 rows): reordering
and moving layers into frames, groups and auto layout, refused drops, renaming several layers,
show/hide and lock rules, pages and dividers, Assets folders, hidden-from-publishing names, drops.
- Panel suite `crates/ui-egui/src/layers_behaviour_tests.rs` (5 tests, driven through egui
headless): click / Shift range / Cmd-Ctrl toggle, Alt-click expand, Collapse layers, sweeping
across eyes and padlocks, the Rename layers dialog, dragging pages, dividers.
Fixes:
- Several layers dropped together in the layers panel land together at the drop point (layers
from apart split around it before).
- Collapse layers (Alt+L) keeps the rows leading to the selection open (it closed everything).
- The eye and padlock no longer vanish when the pointer reaches them (the row lost its hover to
them, so clicks could miss); dragging across eyes or padlocks sets every row passed in one undo
step. Layers inside a locked frame show a faint padlock.
- Several layers selected + Cmd/Ctrl+R / F2 / Rename open a Rename layers dialog (Match, Rename to,
Current name / Number up / Number down buttons, start value, preview) instead of renaming the top
row only.
- `object.batchRename`: `$n` counts up and `$N` down (they were swapped), `$nnn` pads, `start`
sets where numbers start/stop, `regex: true` matches a regular expression with `$1`..`$9`,
`` $` `` and `$'`, and codes work in the replacement too. Returns the new names.
- Pages: dragged up and down the list to reorder; a blank page named with a leading hyphen is a
divider (drawn as a line, never opened, holds no layers); duplicates are named "Copy of ...";
the last page that isn't a divider can't be deleted. `page.list` reports `divider`.
- Assets panel: components filed in folders by page, the frames holding them and the slash parts
of their names (folders fold away; open while searching), tiles show the last name part;
`component.list` gives each component's `folder` and `label` for agents.
- Publishing: a leading `.` or `_` keeps a component (style, collection) out of a library even
after it was published, and removing the prefix brings it back (the previous publish's choice
won before).
The engine gains the `regex` crate (already in the lock file through env_logger) for the
documented regular-expression matching.
## What
**Images are colour managed from their own profile into the file's colour space.** Until now every placed image was taken as sRGB. Now an image is converted once, when it is decoded (cached by image hash, filters and file profile, as before), from the space it says it is in into the file's working space (sRGB or Display P3), so a wide-gamut or tagged photo looks right on the canvas and in exports.
- **Where the profile comes from**: the embedded ICC profile of PNG (`iCCP`), JPG (APP2), WebP (`ICCP`) and TIFF (ICC tag); a PNG without one uses its `sRGB` chunk, or its `gAMA` (+ `cHRM`) chunks: `gAMA` 1/2.2 with sRGB (or no) primaries means sRGB, which is what PNG writers put next to an `sRGB` chunk; any other gamma/primaries become a matrix/curve profile.
- **Conversion**: moxcms, a pure-Rust colour management crate the `image` crate already builds (now a direct dependency of `figcraft-render`, same version and features: nothing new compiles). It runs matrix/TRC profiles (v2 and v4: `curv` gamma and tables, `para` curves, chromatic adaptation), LUT-based RGB profiles and grey (`kTRC`) profiles, so sRGB, Display P3, Adobe RGB, ProPhoto and camera/phone profiles all work.
- **Fallback**: no profile, a profile that isn't RGB or grey (CMYK, Lab), a damaged one, one over 4 MB or one moxcms can't build a transform for: the image is taken as sRGB exactly as before (same pixels, same filter order).
- **Exports**: the original file stays in the document. PNG/JPG/WebP exports carry the converted pixels tagged with the file's profile (unchanged mechanism); PDF exports still embed the original file, with its ICC profile; SVG exports embed the original file.
- **UI**: the image fill's details in the paint picker show *Color profile: Adobe RGB (1998)* (the profile's own name), or *Unknown — treated as sRGB*; strings through `tr()` with `en.toml` / `es.toml` entries. No new command ids.
- **Agents**: `image.place` / `file.placeImage` report `imageProfile` (the name, or `null` when taken as sRGB); `docs/mcp.md` updated.
Untrusted input: profiles over 4 MB are ignored before parsing, moxcms's own caps on curve and LUT sizes apply, colorants must be finite and a transform must build before a profile is used; anything else falls back to sRGB without panicking. The parser is swept with 600 mutated profiles (byte flips, truncation, absurd tag counts and offsets) through the whole decode path.
## Verified
- **Desktop app** over the control channel (own target dir, separate `APPDATA`, stopped by PID): placed a synthetic 200×200 PNG with an embedded Adobe RGB (1998) profile built by a script (v2, `curv` gamma 563/256, published D50 colorants) next to the same pixels without a profile; the screenshot shows the tagged one more saturated (as it should be: its values mean wider-gamut colours), the untagged one unchanged. The paint picker reads *Color profile: Adobe RGB (1998)* for the first and *Color profile: Unknown — treated as sRGB* for the second; `image.place` returned `"imageProfile": "Adobe RGB (1998)"` and `null`.
- **MCP**: `figcraft-cli mcp` (headless) placed both images, exported each layer to PNG in an sRGB file and in a Display P3 file, and read the pixels back: the tagged image's four patches match the published Adobe RGB → XYZ → sRGB / Display P3 matrices exactly (sRGB: (0,201,67), (230,79,56), (0,90,174), (129,129,129); P3: (0,197,84), (213,90,67), (14,88,168), (129,129,129)); the untagged image exports its original values in the sRGB file and the usual sRGB → P3 values in the P3 file; the P3 exports carry the ICC profile.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm).
## Tests
- `render` (`image_profile_tests`, 9 tests, every ICC profile built in code from published constants; no profile files): Adobe RGB PNG into sRGB and Display P3 against the published matrices (alpha kept, untagged pixels untouched); the profile read from PNG, JPG, WebP and TIFF; `curv` table and `para` curves; a Display P3 profile staying put in a P3 file and matching the file conversion in an sRGB one; grey `kTRC`; CMYK, junk, truncated and oversized profiles falling back to sRGB; PNG `sRGB` / `gAMA` / `cHRM` (sRGB-equivalent, linear, Adobe primaries, nonsense values); profile names cached and cleaned of control characters; the junk sweep.
## Not in this PR
- PNG `cICP` without `iCCP`, and GIF/BMP profiles, are ignored (taken as sRGB); FigCraft's own P3 PNG exports carry `iCCP` too, so they re-import correctly.
- The `image` crate drops the ICC tag of very small TIFFs (16×16 lost it, 64×64 kept it in testing); real photos are far larger.
- A PNG that has only `gAMA`/`cHRM` (no ICC) is embedded as is in PDF exports, where viewers take it as sRGB; the canvas and pixel exports convert it.
- Image adjustments on a profiled image apply to the converted values (on an untagged image they still apply before the sRGB → P3 conversion, as before).
- Progress docs: area 5 (Paint) 87%/66% → 90%/67%, 4–7 h → 3–6 h; feature breadth, ready for real work and full parity hours unchanged at this rounding (target-app-parity); PNG, JPG, WebP and TIFF rows in file-format-parity.