* Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil / civil_from_days / days_from_civil copies (the 146_097 / 719_468 magic numbers) across the logging timestamp, text-variable dates, EPUB dcterms:modified, PDF metadata dates and PPTX core.xml dates with chrono, added as a workspace dependency (default-features = false, std only, so wasm is unaffected). - apps/designcraft logging `timestamp` (private civil_from_days deleted). - doc text variables: `civil` and `days_from_civil` use chrono::NaiveDate; `format_date` and `parse_iso_date` keep their own format/parse logic and still call these helpers. - epub `iso8601`, pdf `civil`, pptx `civil`: UTC datetime via chrono::DateTime::from_timestamp. Output is byte-identical and pinned by the existing date, ISO-date, EPUB, image, EPS and logging tests. The OOXML/EPUB dcterms timestamps keep their exact strings. Each converted helper keeps a proleptic-Gregorian fallback for dates outside chrono's NaiveDate range (and for the serial/overflow dates parse_iso_date can hand days_from_civil), so no input changes or panics. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on a clock outside chrono's range; keep 5-digit years unprefixed logging.rs timestamp(): chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp and read such a clock, or a pre-epoch clock, as the epoch like the old saturating code did. Years past 9999 are written with {:04} like before, because %Y adds a '+'. Tests cover both cases. epub iso8601(): write the year with {:04} instead of %Y, matching the old {year:04} output, so years past 9999 are not prefixed with '+'. Test added. AGENTS.md: the "Time goes through chrono" rule now notes the narrow fallback for dates outside chrono::NaiveDate's range, which crates/doc/src/vars.rs retains (Howard Hinnant's algorithm). * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies doc/vars.rs kept hand-rolled Hinnant `civil_fallback`/`days_from_civil_fallback` copies for dates outside chrono's ±262143-year range, and AGENTS.md carved a "narrow fallback" exception for them, which left calendar arithmetic outside chrono after all. Replace both copies with the Gregorian 400-year cycle (146_097 days): chrono converts the day/year within one cycle and whole cycles are added back via i128 (so i64::MIN/MAX can't overflow). `civil` is reached by the system clock through `format_date(now(), ..)`, so it must stay exact; `days_from_civil` only ever sees a 4-digit parsed year via `parse_iso_date`, but it uses the same cycle form for consistency. Both reproduce the pre-chrono proleptic Gregorian result, so outputs are unchanged (verified against the old formula over wide ranges and at the i64 extremes). New test pins dates past chrono's range on both sides of the epoch, through the public `format_date` and round-tripping `days_from_civil`/`civil`, with values from the old formula. Removed the AGENTS.md "narrow fallback" exception. epub/pdf/pptx `civil`/`iso8601` are left as-is: the PR already dropped their hand-rolled copies for pure chrono, and their remaining epoch (`unwrap_or_default`) fallback only triggers for a metadata/clock timestamp past year 262143 -- the live-clock/file-mtime case FredApps accepted, with outputs that clamp the year anyway (pptx 0..9999, PDF DateTime). No hand-rolled copy remains there to replace. Tested: cargo test -p designcraft-doc; fmt --check; clippy --all-targets -D warnings. * Carry an overflowing day into the next month again in parse_iso_date parse_iso_date accepts any day 1..=31, and the old days_from_civil carried a day past the end of its month into the next one (2019-02-30 is 2019-03-02). from_ymd_opt rejects such a day, and unwrap_or(0) turned it into 1970-01-01. chrono now places the 1st of the month and the day is added on, which restores the old normalization. Tested through days_from_civil and parse_iso_date. * time: preserve old out-of-range date conversion (no epoch fallback) `DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant past chrono's year range to 1970-01-01, changing output the old hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain with a cycle-based helper that routes the calendar through chrono (the calendar repeats every 400 years = 146_097 days, so month/day/time come from chrono and only the year is carried, keeping every i64 exact), then apply each format's existing year clamp afterwards. - apps/designcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch. - crates/epub/src/lib.rs: `iso8601` via `utc_parts`. - crates/pdf/src/export.rs: `civil` cycle-based; `date_time` keeps its 0..=9999 year clamp. - crates/pptx/src/package.rs: `civil` cycle-based, keeps its 0..=9999 year clamp on the year only (month/day/time keep the real instant). Pinned old outputs in tests (through the public helpers): logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch) epub 10_000_000_000_000 -> 318857-05-20T17:46:40Z -10_000_000_000_000 -> -314918-08-13T06:13:20Z i64::MAX -> 292277026596-12-04T15:30:07Z i64::MIN -> -292277022657-01-27T08:29:52Z pdf civil(10_000_000_000_000) -> (318857,5,20,17,46,40) pptx civil(10_000_000_000_000) -> (9999,5,20,17,46,40) (year clamped) * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule 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. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does.
9.0 KiB
DesignCraft — instructions for agents
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: ../vectorcraft (Illustrator-class), ../photocraft (Photoshop), ../pdfcraft (Acrobat), ../filmcraft (Premiere), ../lightcraft (Lightroom).
Start every session here
- Read
plan/STATUS.md(current milestone, next task), then the task inplan/execution-plan.mdand the relevantplan/architecture.mdsection. Behaviour reference:plan/indesign/*.md(11-observed-ui.mdholds measured observations of the running app). - Follow the autonomous operation protocol (
plan/execution-plan.md§7). Don't stop to ask unless §7 lists the decision as the user's.
plan/ is gitignored (local only).
Never crash
People trust DesignCraft with their layouts; a crash loses their work. This outranks feature work: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: craftrules/standards/never-crash.md.
- No panics in non-test code: no
unwrap(),expect(),panic!,unreachable!,todo!,unimplemented!; nounsafe(unsafe_code = "forbid"). The one exception is a provably infallible literal (a constant regex,write!to aString):#[allow(clippy::expect_used)]+.expect("why it cannot fail"). - Errors are
Result<T, E>through the crate's error type and?(ok_or(..)?,let … else,if let,map_errfor context). An unfinished feature returns an "unsupported" error. Don'tunwrap_or_default()where a silent default would corrupt a document; return an error. - Input-derived numbers are hostile (files, commands, MCP/control params, settings):
get()instead of[i]/[a..b], slice strings at char boundaries, checked/saturating arithmetic for lengths and offsets, no division by zero or NaN casts, cap input-sized allocations. - Bound recursion (seen-sets or depth limits for nested or cyclic documents). Locks:
lock().unwrap_or_else(PoisonError::into_inner)or an error; thread joins areResults. - Last-resort guard: escaped panics during command execution and file import/export become an error, never a lost document. It's a safety net, not a licence.
- Prove it: every crash fix lands with a small synthetic regression test that panicked before the fix.
- Clean crates carry
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]; new crates start with it. Tests mayunwrap(clippy.toml).
Non-negotiables
- Clean-room. InDesign is installed on the dev machine and may be observed black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under
plan/indesign/screenshots/(never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…). - Assets: no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC (except the ArtCraft trademarks in
docs/brand/, underdocs/brand/LICENSE-brand.txt) and has a row inASSETS.md(cargo xtask assetsenforces it). Prefer art generated in code. - Fonts live in
storytold/craft-fonts, never in this repo: don't commit new font files (the Latin UI/document fonts already inassets/fonts/stay). It is an optional build input, never aCargo.tomldependency:CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo buildmakescrates/fonts/build.rsembed its fonts asdesigncraft_fonts::CRAFT_FONTS(Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset,CRAFT_FONTSis empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it. Standard: craftrulesstandards/fonts.md. - Shared test corpora. Real-file test oracles (Photoshop-authored PSDs, etc.) live in
storytold/photocraft-corpus, explained in craftrulesstandards/test-corpora.md. Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does withcargo xtask corpus. - Everything is a command. User-visible behaviour = a command in
crates/engine/src/cmd/*(id, label, menu path, shortcut, params doc,enabled,run) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live incrates/ui-egui/src/menus.rs(UI_COMMANDS). The control channel and MCP reach all of them. - Layering is enforced by
cargo xtask layers. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable. - The UI is thin: panels read engine state and act through
app.run(id, params). Colours come fromtheme::Tokens. - Time goes through
chrono(workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - Rust only (no handwritten JS/TS). Never break wasm (
cargo xtask wasm). - Quality gates before every commit:
cargo xtask ci(fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (M2.1: type tool caret navigation).
Running and looking at the app
cargo run --release -p designcraft -- --sample --control 7979(sample magazine + control channel).- Drive it: JSON lines on
127.0.0.1:7979, e.g.{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text"}}}then{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}. Methods:crates/ui-egui/src/control.rs, docs:docs/control-protocol.md. - For UI work, look at the result (take
ui.screenshot, read the PNG) and compare withplan/indesign/02-ui-ux.md/11-observed-ui.md. If no frame is presented (screen locked) useui.renderordesigncraft-cli run --sample --all-pages DIR. - Headless:
designcraft-cli run --sample --cmd 'frame.create={"rect":[0,0,100,100]}' --export out.png. - Shell gotcha:
mv/cpare aliased interactive here — use/bin/mv -f//bin/cp -f. - Parallel agents: separate
CARGO_TARGET_DIRper agent; edit only the crates you own; delete your target dir when done (disk).
Roadmap
ROADMAP.md (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' standards/progress-docs.md. Details: docs/target-app-parity.md (assessment), docs/gaps.md (ranked work list: pick from the top), docs/roadmap.md (milestones, current focus), docs/parity-checklist.md (presence checklist, cargo xtask parity), and the typography, file-format, UI, hardware and localization parity docs in docs/. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from
contributors/contributors.json(commit stats; generated, never hand-edit) andcontributors/people.toml(names people chose for themselves). Seedocs/contributors.md. - Agents working for a contributor: when you prepare a PR, check whether your human's GitHub
username has a
[people.<username>]entry incontributors/people.toml. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (sync_github_name = true). If yes, add only their own entry (copy the template at the top of the file, or runpython3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name) and include it in their PR, committed as them. If no, change nothing: they are credited as@usernameanyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit
contributors.json. - Maintainers refresh the stats with
python3 ../../craftrules/scripts/contributors.py .(it also re-verifies who wrote eachpeople.tomlentry;--checkonly verifies).