NC Stylo · Docs

Development

Last updated: 2026-10-23 · Change: Tests: cargo xtask test, one integration-test binary per crate, the dev profile's debug info. Last updated: 2026-10-21 · Change: the interface faces as stylo-ui-egui's interface-fonts feature, and theme::install_fonts for an embedder that draws the panels itself. Last updated: 2026-10-21 · Change: leaving out the bundled fonts with the egui panels (stylo-ui-egui's and stylo-mcp's bundled-fonts features). Last updated: 2026-10-14 · Change: leaving out the bundled fonts (bundled-fonts feature). Last updated: 2026-10-14 · Change: document-font registration for all explicitly supplied faces.

Web build

apps/stylo-web runs the same StyloApp in the browser through eframe's web runner. The renderer is wgpu: WebGPU where the browser has it, WebGL2 otherwise. It is Rust only; the only JavaScript is the glue wasm-bindgen generates.

brew install trunk                 # or: cargo install trunk --locked
rustup target add wasm32-unknown-unknown
cd apps/stylo-web
trunk build --release              # writes ../../dist/web (index.html, .js glue, .wasm)
trunk serve --release              # dev server on http://127.0.0.1:8766

What the web build needs: trunk, the wasm32-unknown-unknown target and cargo, nothing else (no POSIX shell, so it works the same on Windows). Optionally, CRAFT_FONTS_DIR set to an absolute craft-fonts checkout adds the fonts the site serves beside the wasm: Trunk's post_build hook runs cargo xtask web-fonts, which copies them into dist/web/fonts/ (it does nothing without CRAFT_FONTS_DIR, and skips with a warning any listed face an older checkout lacks). See Arabic fonts and the web build.

Any static file server works for dist/web, for example python3 -m http.server 8766 inside that directory. The release .wasm is about 17.5 MB, or 7.1 MB gzipped, so serve it with compression.

URL flag: ?webgl forces the WebGL2 backend.

How the web shell (apps/stylo-web/src/web.rs) differs from desktop:

Desktop graphics processor

The canvas is rasterized on the CPU (stylo-render, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (apps/stylo/src/gpu.rs, eframe's native_adapter_selector) and logs every adapter it found and the one it uses.

Logs

The desktop app writes its log records to standard error and to logs/stylo.log next to the preferences (Linux $XDG_CONFIG_HOME/stylo/logs/, by default ~/.config/stylo/logs/; macOS ~/Library/Application Support/Stylo/logs/; Windows %APPDATA%\Stylo\logs\). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: the graphics adapter in use, a lost graphics device, panics the engine's guard recovered from and clipboard formats that couldn't be made land there. Each launch moves the previous log to stylo.1.log (and that one to stylo.2.log), so the log of a run that crashed survives the next start. The file stops growing at 16 MiB. --version writes no file, and runs with STYLO_NO_PREFS log to standard error only.

By default Stylo's own crates log at info and everything else at warn. RUST_LOG replaces that with env_logger-style directives, for example RUST_LOG=debug, RUST_LOG=warn,stylo_render=trace or RUST_LOG=info,wgpu_core=warn; a directive ending in * covers every target starting with it (stylo*=debug). The logger is apps/stylo/src/logging.rs. stylo-cli mcp has its own logger, which sends records to the MCP client (docs/mcp.md).

Environment variables (desktop app)

Variable Effect
STYLO_CONTROL_PORT Same as --control <port>, which overrides it; a value that isn't a port from 1 to 65535 stops the app
STYLO_IN_WINDOW_MENUS Any value but empty or 0: same as --in-window-menus (see macOS: the menu bar)
STYLO_NO_PREFS No preferences read or written, no default Data Recovery folder and no log file, and no saved PDF presets or loaded ICC profiles kept (agents' test runs; also for stylo-cli)
STYLO_CONFIG_DIR The folder for the preferences and the folders next to them (PDF Presets, Color Profiles, …), for the app and stylo-cli (see docs/control-protocol.md)
WGPU_POWER_PREF Graphics adapter: low, high or none (see Desktop graphics processor)
WGPU_ADAPTER_NAME Graphics adapter by (part of) its name, any case (see Desktop graphics processor)
WGPU_BACKEND Graphics backends: vulkan, dx12, metal, gl
STYLO_GPU_SKIP Set by the app when it starts again without a graphics adapter that failed (see Desktop graphics processor)
RUST_LOG Log levels for standard error and the log file (see Logs)

macOS: the menu bar

On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search icon and the workspace switcher next to the traffic lights, centred on the bar by apps/stylo/src/mac_window.rs). --in-window-menus or STYLO_IN_WINDOW_MENUS=1 keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). ui.inspect says which with nativeMenuBar.

Linux: Wayland and X11

On GNOME/Wayland, enable Preferences › User Interface › System Title Bar and restart to use winit's font-free Adwaita window controls (wayland-csd-adwaita-notitle); the existing custom title bar remains the Linux default.

The window runs natively on Wayland (eframe's wayland feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see Desktop graphics processor (#502). The system clipboard (apps/stylo/src/clipboard.rs) is arboard with its wayland-data-control feature:

Fonts: craft-fonts (optional build input)

Font files are never committed to this repository. Fonts shared by the NC apps live in storytold/craft-fonts; the rules are in craftrules standards/fonts.md. To add a font, add it there. (The Latin fonts in assets/fonts/ predate the rule and stay.)

Stylo builds, tests and runs without craft-fonts. To embed its Japanese fonts (BIZ UDPGothic for UI text, Shippori Mincho and BIZ UDMincho for document text), point the CRAFT_FONTS_DIR build option at a checkout:

git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p stylo
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci   # also runs the Japanese-glyph tests

Leaving out the bundled fonts (embedders)

The Latin fonts in assets/fonts/ are compiled into stylo-text by its bundled-fonts feature (3.4 MB of the binary). It is on by default, and stylo-engine's default bundled-fonts feature turns it on, so the apps and anything built on the engine with its default features have them. An embedder that brings every font itself at run time (FontDb::add_font, say a web app serving its own font catalogue) depends on the engine with default-features = false and doesn't ship them twice. Its font database starts without them: on the web and without craft-fonts it starts empty, and text has no glyphs until a font is added.

An embedder that also hosts the egui panels depends on stylo-ui-egui with default-features = false as well: its own bundled-fonts feature, on by default, turns the engine's on; stylo-mcp has the same feature. The panels' interface faces (Source Sans 3 Regular and Semibold, Inter Regular and SemiBold, JetBrains Mono, about 2 MB: theme.rs) are stylo-ui-egui's interface-fonts feature, also on by default; without it the panels draw in egui's built-in fonts, which lack some symbols they show (→ ▾ ● ✓), so such an embedder adds a font that has them to each of the families below (ctx.add_font with an InsertFontFamily per family).

The panels and dialogs draw in their own egui families (theme::FONT_UI, FONT_UI_SEMIBOLD, FONT_MONO), and egui panics drawing text in a family no font is bound to. StyloApp::logic binds them on its first frame; an embedder that draws the panels or dialogs without it calls theme::install_fonts once on its context, with or without interface-fonts. It replaces the context's font definitions, so the embedder's own fonts are added after it.

Arabic fonts and the web build

Desktop builds load craft-fonts' Arabic families into the document font database (Noto Sans Arabic first, the fallback for Arabic text; the others are picked by name).

The web build doesn't embed them (wasm size). Instead crates/text/web-fonts.txt lists the faces it fetches from beside the wasm, startup (before the app starts) or background (after):

Try it: cd apps/stylo-web && CRAFT_FONTS_DIR=$PWD/../../../craft-fonts trunk serve. To serve another craft-fonts face on the web, add a line to web-fonts.txt.

Localisation

Strings in code stay English and are the default lookup keys. crates/ui-egui/src/i18n maps them to display text at render time from one catalog per language (i18n/<code>.tsv; the format is documented in the header of zh-hant.tsv). Command ids, menu paths used for logic, ui.menu.list, the control channel, the CLI and MCP always use the English ids and labels, so agents and scripts never see translated text.

Languages shipped: English (en, the source), Traditional Chinese (zh-hant, complete, in the vocabulary used in Taiwan; zh-TW, zh-HK, zh-MO and zh-Hant-* locales all resolve to it), Simplified Chinese (zh-hans, complete, in the vocabulary used in mainland China; zh-CN, zh-SG, zh-Hans-* and a bare zh resolve to it, so the two scripts never mix), Japanese (ja, complete), Korean (ko, complete; every ko-* locale resolves to it; a count takes no plural, and Hangul is drawn with an installed system font), Vietnamese (vi, complete; every vi-* locale resolves to it; a count takes no plural, so plurals have one form), Finnish (fi, complete; every fi-* locale resolves to it; one/other plurals with the partitive after numbers other than 1), German (de, complete; de-DE, de-AT, de-CH and every other de-* locale resolve to it), Spanish (es, complete, in neutral international Spanish; every es-* locale such as es-ES, es-MX, es-AR or es-419 resolves to it), French (fr, complete; fr-FR, fr-BE, fr-CA, fr-CH and every other fr-* locale resolve to it), Italian (it, complete; it-IT, it-CH and every other it-* locale resolve to it), Russian (ru, complete; every ru-* locale such as ru-RU, ru-BY or ru-KZ resolves to it), Ukrainian (uk, complete; uk-UA, uk_UA.UTF-8 and every other uk-* locale resolve to it; integer plurals use one/few/many), Hungarian (hu, complete; hu-HU and every other hu-* locale resolve to it; a count takes the singular, so plurals have one form), Polish (pl, complete; pl-PL and every other pl-* locale resolve to it; plurals use one/few/many, with 21, 31 … taking many), Swedish (sv, complete; sv-SE, sv-FI and every other sv-* locale resolve to it), Georgian (ka, complete; ka-GE and ka_GE.UTF-8 resolve to it; one count-neutral plural form), Czech (cs, every menu label) and Brazilian Portuguese (pt-br, complete; pt-BR, pt_BR.UTF-8 and other tags that resolve to pt-br through candidates). Untranslated text falls back to English until its rows are added.

Cursors

crates/tools/src/cursors.toml lists every Cursor value: when it shows, in plain behaviour terms, and where its hotspot is, so whoever draws the cursors knows what each one is for without reading the tools (CLAUDE.md › Adding a cursor). cargo xtask cursors (part of cargo xtask ci) warns about a Cursor value with no entry, an entry with no Cursor value, a repeated id or a missing field; it never fails the build. A cursor not drawn yet borrows another's look (Cursor::stand_in).

Vendor names gate

cargo xtask brands (part of cargo xtask ci) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), Cargo.toml descriptions and packaging files. Comments, test files and #[cfg(test)] modules are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, or a name another project defines, such as a shared MIME type, carries a brand-ok comment. In the Linux desktop entry, whose comments take whole lines, the comment goes on the # line just above it; MARK_ABOVE in xtask/src/brands.rs lists the files that accept a mark there.

Tests

Robustness: Stylo never crashes

A crash takes the user's unsaved work with it, and much of what the app reads is untrusted: SVG, PDF, .ai and .stylo files, pasted data, MCP and control-channel messages, command parameters. Shipped code therefore never panics. Anything that can fail returns Result (or Option), the caller handles or propagates it, and the user sees an error message.

Enforced by lints

Cargo.toml denies these clippy lints for the whole workspace, and cargo xtask ci runs clippy with -D warnings. The gate targets the current stable Rust (rust-version in Cargo.toml is only the minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed in the code:

Banned in shipped code Use instead
x.unwrap(), x.expect("…") x?, x.ok_or(err)?, let Some(v) = x else { return … }, if let, unwrap_or / unwrap_or_default / unwrap_or_else
v.last().unwrap(), v.pop().unwrap() if let Some(l) = v.last_mut(), let Some((last, rest)) = v.split_last() else { … }
panic!, unreachable!() in a match arm return an error (EngineError::Other, bad(C, "…")) or a sensible default
todo!, unimplemented! don't merge unfinished paths; return an error that says what isn't supported

clippy.toml allows unwrap, expect and panic inside #[test] and #[cfg(test)] code. Integration tests (tests/it/main.rs and the test files that are binaries of their own), examples and the testkit crate opt out with #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]. Tests use panic!("…") instead of unreachable!().

Not caught by lints: avoid implicit panics too

Don't silence the lint by discarding errors. let _ = … and .ok() are for failures that really don't matter, with a comment saying why.

The safety net

stylo_engine::guard::catch_panic wraps every entry point:

The net exists for bugs, not as a substitute for Result. On wasm a panic aborts the app, so the web build has no net at all.

Fuzzing

Untrusted input has property tests that must never panic:

CI runs a few dozen cases each. Before touching an importer, run a deeper search, e.g. PROPTEST_CASES=20000 cargo test --release -p stylo-engine --test import_fuzz. When it finds a panic, fix the code and add the input as a regular test.

Bidirectional type

Point, area and path type lay out Hebrew and Arabic with the Unicode bidirectional algorithm (unicode-bidi, in crates/text/src/layout.rs). Each paragraph's base direction is its ParaStyle::direction (Paragraph panel › Left-to-Right / Right-to-Left Paragraph Direction, shown with Preferences › Type › Show Indic Options; text.setFormat {direction}), else its first strong character. Text is shaped in logical order, right-to-left runs right to left (shape.rs splits segments by bidi level and script), lines are broken in logical order and then each line's whole shaping clusters are put in visual order. Stored text, undo and style ranges stay logical; the caret, selection, hit testing and arrow keys follow the visual order. A paragraph with nothing right to left in it skips the algorithm, so plain text pays nothing for it.

Alignment buttons are physical (Align Left is left in either direction). New type (text.create, the Type tools) is aligned Justify::Auto, the start of each paragraph's direction: Hebrew grows to the left of a point type's anchor. Imported text keeps its own alignment, and SVG import pins the direction SVG implies (left to right unless direction: rtl).

Vertical type keeps its logical order down the column. SVG export outlines text with right-to-left lines (with a warning) until it is written with direction/unicode-bidi; PDF and raster exports use the laid-out glyphs. No font is bundled for these scripts: the installed fonts' fallback covers them (the web build needs a font with that coverage).

Verified PngSuite corpus

cargo xtask corpus --download fetches the 175 original PngSuite PNGs and their redistribution license/README from the pinned lunapaint mirror. Its README identifies png/ as the original 2017-07-19 files; converted BMP/JSON files are excluded. Each file's SHA-256 and exact byte length are committed in xtask/pngsuite.sha256.

Only HTTPS downloads and redirects are allowed. The manifest admits at most 256 flat filenames, 1 MiB per file and 16 MiB total; current PNG data totals 114,649 bytes. Curl limits each download to its expected length, with a 10-second connect timeout and 60-second transfer timeout. Network fetching requires curl 8.4 or newer: earlier versions do not bound unknown-length responses (curl documentation). Verified caches remain usable without invoking curl. Reads are independently bounded. Files are staged under temporary filenames, checked, then renamed into place; failed or substituted downloads never replace an existing oracle. The fetch stops on unexpected directories/symlinks or an existing staging file rather than overwriting them. Verified cached files need no network access. The corpus remains gitignored and optional.

The original site's plain HTTP archive is no longer fetched or extracted. To refresh the suite, review the mirror commit, original-file list, redistribution license, sizes and hashes together; do not replace expected hashes automatically in response to a download mismatch.

Highlighted-text Eyedropper (2026-10-21)

Switching from Type to Eyedropper preserves the highlighted character range and its overlay. Sampling applies enabled font and paint attributes to that range; Shift samples color only. Returning to Type retains the range; deselection and document changes clear stale ranges. Character styles cannot represent object opacity or blend modes. With Transparency enabled, sampling refuses a non-neutral source or target object before changing art, paint defaults, selection or history. Disable Transparency in Eyedropper Options to sample supported attributes while preserving the target object's transparency. Whole-object sampling remains unchanged. Regression evidence: tests_eyedropper.rs covers Unicode, font/paint, Undo/Redo, tool switches, and atomic rejection of source and target opacity/blend modes; text_range_and_brush_sampling_options_coexist covers upstream brush-key sampling. Scores and estimates unchanged.

Drag artwork between document tabs (2026-10-22)

Hold a Selection-tool move over another visible document tab for 400 ms to activate it, then release over its canvas to copy the selected artwork. The original grab point is kept across different zoom levels and view rotations. The source preview is rolled back before the switch; source artwork, source history and the internal clipboard are preserved. Returning to the source tab resumes a move (Alt copies). Escape or release outside the canvas cancels. Changing the source revision or unexpectedly changing/closing the destination cancels a stale transfer; focus loss, touch cancellation and multitouch cancel too. Moving tabs under a stationary pointer does not activate further tabs until the pointer moves. The close icon does not activate a tab during a drag. Overflow-menu hover activation and cross-window dragging remain outside this change. The cited regressions assert the internal clipboard; they do not exercise the native system-clipboard adapter. Tab activation resets the Selection tool through Session::set_active; gesture regressions verify it is idle after switching, dropping and cancelling a transfer.

edit.dropSelection {sourceDocument, ids, grab, at} uses document-scoped ids (sourceDocument is document.inspect’s session-unique uid), validates 1–10000 ids before editing, targets the current editable insertion context (Paste Remembers Layers does not reroute a drag into a matching source layer), and uses the existing lossless paste resource pipeline: groups, editable paths/type, images, symbols and patterns travel together. Same-name conflicting swatches are added under unique names to preserve source colours; pattern fills/strokes translate with the dropped artwork to preserve their alignment. The target receives one Undo/Redo step. Invalid or closed sources fail before editing. Existing artwork-to-panel drags retain their original behavior.

Evidence: tests_document_drag drives actual tab-strip/canvas pointer frames; tests_clipboard covers resource rendering, id collisions, swatch conflicts, grab-point placement, locked-target refusal, excessive ids, malformed numeric parameters and atomic invalid-input refusal. A private Linux/Xvfb native build also placed an original logo group onto a flyer through the command API; its screenshot was inspected, source preservation and Undo/Redo checked. Physical mouse hover on the desktop is not covered by that native smoke test. Public behavior reference: Illustrator public duplication documentation. The tab-hover workflow follows the contributor’s requested behavior. All test artwork is original geometry/text generated locally. The new command and validation messages are translated in all 24 language catalogs, including Turkish. Scores and hour estimates are unchanged.

Revision history

Date Change Summary
2026-10-23 minor Tests: cargo xtask test (nextest, then doctests), one integration-test binary per crate and the files that keep their own, the dev profile's debug info.
2026-10-21 minor Fonts › Leaving out the bundled fonts: stylo-ui-egui's interface-fonts feature, and theme::install_fonts for an embedder that draws the panels without StyloApp::logic.
2026-10-21 minor M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged.
2026-10-21 minor Fonts › Leaving out the bundled fonts: stylo-ui-egui's and stylo-mcp's bundled-fonts features, and the engine declared without default features.
2026-10-14 minor Document the bundled-fonts feature of stylo-text and stylo-engine (Fonts › Leaving out the bundled fonts).
2026-10-14 minor Document registration of every explicitly supplied craft-fonts face after the existing script-specific prefix; empty input and web selection remain unchanged.
2026-10-13 minor Document pinned PngSuite files, integrity checks and download budgets (#1729).

Tab-drag status (2026-10-22): updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged.

Date Change Tab-drag history
2026-10-22 minor Verify Selection-tool reset after tab activation, drop and cancellation; narrow clipboard evidence to the tested internal clipboard and correct documentation grammar; behavior, scores and estimates unchanged.
2026-10-22 minor Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged.
2026-10-22 minor Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged.
2026-10-22 minor Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged