55 Commits
Author SHA1 Message Date
FredAppsandCursor f1ae1ecacb Merge main into web-host
Keep both craft-web-api and craft-web-host in the workspace list, the
README and the lockfile. Point the host README at the command API that
landed in #413, and the command API README at these host messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 10:29:52 +01:00
Helen Kwok a94d849df2 web-api: one window.<app> command API for the web builds 2026-11-05 13:31:10 +08:00
Helen Kwok da0b274a28 craft-web-host: the host messages as one contract and one bridge
The messages between a Craft app's web build and the page that hosts it
(ready, host, open, save, saved, dirty, failed, handoff, handoff-done),
as proposed on #358. The contract part (message kinds, the version, the
?host=parent / data-host="page" switch, file-name clean-up) builds
everywhere and is tested natively; the web module (wasm32) is the bridge
an app's web shell calls: same-origin parent only, files as bytes or a
Blob without copying, saves as a transferred ArrayBuffer, dirty repeats
suppressed. Compatible with the bridges shipped before it: optional
fields stay optional, and a host treats an unknown ready.version as 1.
2026-11-05 13:30:14 +08:00
alehelandClaude Opus 5.5 550732201c craft-audio: FLAC encoder
FilmCraft's FLAC encoder behind `flac-encode`, kept over SoundCraft's:
linear prediction, stereo decorrelation and the STREAMINFO MD5 give
0.1-37% smaller files on 12 test signals, and the output is bit-exact
through `flac` and ffmpeg. It is 3-5x slower than SoundCraft's, still
about 56x real time.

`lossless-encode` is now `pcm-encode` plus `flac-encode`, and
`EncodeFormat::Flac` writes it through `encode` and `FileEncoder`.
With only one of the two features, the other's formats are refused as
`Unsupported`.

The tests that use FLAC files as decode fixtures (truncation,
corruption, metadata bounds, zero-frame files, container preflight)
come with it, as do the FLAC oracle tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:35:44 +01:00
Aleksander HelgakerandClaude Opus 5.5 38740b43a9 craft-audio: core types, WAV/AIFF read and write, peaks, symphonia (#441)
* craft-audio: core types, WAV/AIFF read and write, peaks, symphonia

The new crate without any codec module: what every later codec plugs
into.

- Shared types: `AudioBuffer`, `AudioInfo`, `ChannelLayout`/`Speaker`,
  `AudioError`, `EncodeOptions` (built with `new` and `with_*`),
  `EncodeFormat`, `BitDepth`, `BwfInfo`, `SampleFormat`.
- WAV/BWF/RF64 and AIFF/AIFF-C reading and writing from SoundCraft's
  `soundcraft-audio-io`, kept over EffectCraft's and FilmCraft's
  smaller writers (only it has BWF, dither, any speaker mask and
  streaming without a known length). `encode` and `FileEncoder` behind
  `pcm-encode`; `lossless-encode` and `encode` turn it on.
- `decode`, `probe`, `tracks` and the streaming, seekable `Decoder`
  (feature `decode-native`). The native path reads WAV, AIFF, and PCM in
  MP4/MOV and Matroska through craft-media's demuxers, packet by packet,
  with edit lists and `CodecDelay`/`DiscardPadding` applied.
  `TrackDecoder` decodes PCM; each codec PR adds its codec to it.
- symphonia 0.6 (MPL-2.0, used unmodified) behind the `symphonia`
  feature, part of `decode`: the fallback for every format with no
  native decoder yet. Its size fields are checked before it reads, and
  its panics are caught where they unwind.
- `peaks`: the min/max/RMS pyramid for waveform drawing.

Workspace: craft-media is a workspace dependency without default
features, so an encode-only craft-audio does not build the MP4
demuxer, and `symphonia-bundle-flac` builds without overflow checks in
dev profiles (its 32-bit LPC restore wraps on valid input).

CI: `audio.yml`, with clippy per feature, rustdoc, WASM, MSRV, and the
tests with ffmpeg oracles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: review fixes (CodeRabbit on #441)

- Reading: AIFF that declares frames without an SSND chunk is Malformed; a Matroska PCM track's
  length counts from its first frame; seek targets saturate; an empty FLAC has one track; a
  failed header read is an Io error; deinterleave allocates fallibly; concealed silence is capped
  like a decoded packet, and symphonia's uses the damaged packet's own duration when known.
- Peaks sum squares in f64, so loud float samples give a finite RMS.
- Writing: .aifc maps to AIFF; BWF text is cut at a character boundary; a WAV byte rate past
  32 bits is an error; the FileEncoder docs say nothing may follow the sink's position.
- README: the Limits list says which codecs go through symphonia and which MP4 edit lists apply.
- Tests for each, alias and dither coverage, and a FLAC case in the routing oracle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: review fixes, second round (CodeRabbit on #441)

- Every header read is checked: `Native::open`, `native::tracks` and the no-symphonia error read
  it with `head_checked`. craft-media demuxer errors that are I/O failures (not a file ending
  early) are returned as `AudioError::Io` instead of sending the file to the other path.
- A Matroska track's presented length runs from its first frame plus `CodecDelay` (unclamped,
  in timestamp ticks with the delay rounded to one) to its end.
- symphonia's concealment allocates fallibly, and a damaged first packet is concealed when the
  demuxer gives its duration. `deinterleave` reserves its channel list fallibly too.
- `TrackDecoder::conceal` documents its cap at the decode limit.
- Tests: an I/O failure past the first 64 KiB, codec delay with the first block at -20, 0 and
  50 ms (checked with `probe`), a seek back after an overshoot, and the loud-sample RMS value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: TrackDecoder::conceal returns a Result

Concealment allocates fallibly and fails with TooLarge when the silence would pass the 2^30-sample
decode limit, instead of returning less than the packet's length. The native path, the README,
the packets example and the doc example pass the error on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: review fixes, third round (CodeRabbit on #441)

- Concealed silence is held to the decode limit inside pcm::silence, so symphonia's path checks
  frames × channels too, and symphonia's decoded audio is copied into fallibly allocated planes.
- TrackDecoder::reset forgets the last packet's length.
- Without symphonia, a damaged MP4, Matroska or Ogg file (truncated, invalid, no moov) is
  Malformed instead of Unsupported; a feature the demuxer does not read still names the build.
- Tests: an MP4 with no moov in a native-only build, conceal after a reset and past the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: review fixes, fourth round (CodeRabbit on #441)

- A file the native demuxers find damaged is Malformed with symphonia too, when symphonia calls
  it unsupported; before, an ftyp-only MP4 was Unsupported in symphonia builds.
- symphonia's decoded blocks are held to the 2^30-sample decode limit before allocating.
- Symph::seek forgets the last packet's length, so a lost packet after a seek is not concealed
  with the length of one before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: review fixes, fifth round (CodeRabbit on #441)

- The damaged-file test covers Matroska as well as MP4, and fails when either branch of
  native::damage is removed.
- native::damage leaves out Ogg: ogg::open reports no damage (it skips damaged pages and calls a
  file with none it can read not Ogg), and symphonia reports damaged Ogg as malformed itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: run the ffmpeg oracle tests on ubuntu-26.04

ubuntu-latest's apt ships ffmpeg 6.1.1, which writes MP2 and E-AC-3 in
Matroska with ends a few milliseconds early, so the routing tests on the
later codec PRs cannot pass on it. ubuntu-26.04 ships ffmpeg 8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:25:57 +01:00
Aleksander HelgakerandClaude Opus 5.5 6865897f14 craft-media: add Ogg demuxer (#440)
* craft-media: Ogg demuxer

FilmCraft's `filmcraft-ogg` as `craft_media::ogg`, behind the opt-in
`ogg` feature: pages (CRC-32 checked, damage skipped by searching for
the next `OggS`), logical streams, packets across pages, and the Ogg
Opus, Vorbis and FLAC mappings. `OpusTiming` gives each Opus packet's
start, the pre-skip and the final page's end trimming.
`Stream::codec_params()` returns `CodecParams` like the other
containers; for FLAC each stream keeps its own header count, and an
unstated count is bounded (it ends at the first audio frame, or at
32 MiB with a warning). `ogg::ByteSource` is `craft_media::ByteSource`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-media: Ogg review fixes: bounded recovery, lost pages, chained links, caps

From the review of the Ogg demuxer (storytold/craft-libs#440):

- The damage scan charges the positions it examines, not a whole 1 MiB read,
  reads 64 KiB at a time, and says when the 64 MiB budget runs out. A file
  with many damaged pages lost its tail without a warning.
- A page whose lacing claims more bytes than the file has left is skipped
  and the scan resumes, instead of ending the parse.
- A gap in a stream's page sequence numbers drops the packet the lost page
  continued; its two ends were joined into one corrupted packet.
- A beginning-of-stream page for a serial already seen starts a new stream,
  so a chained file whose links reuse a serial no longer hands the second
  link's headers to the decoder as audio.
- The packets kept from a file are capped by its size (as Matroska caps
  frames), so zero lacing values cannot make the packet lists many times the
  file. FLAC metadata under an unstated header count must be a block of the
  length its header states, and at most 1024 are kept.
- A packet over 4 GiB is dropped instead of overflowing its size.
- OpusTiming saturates, so hostile granule positions cannot overflow, and a
  clip whose only audio page is also the last is trimmed at the end
  (RFC 7845 §4.5) instead of at the start.
- Speex streams read extra_headers (offset 68) for their header count.
  codec_params gives Theora its Xiph-laced headers and Speex its header,
  rate and channels; the FLAC extradata flags exactly the last block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-media: Ogg damage recovery charges every page it reads and hashes

From CodeRabbit's review of 2308071: a page that fails its CRC was read and
hashed in full (up to about 64 KB) before the parser stepped one byte on,
and the damage scan read 64 KiB per call however near the next page was.
Neither was charged to the recovery budget, so a file packed with fake page
headers could cost gigabytes of reading and hashing.

- A failed page's bytes are charged to the recovery budget; opening stops
  with a warning when it is spent.
- find_capture starts with a 256-byte read and doubles it up to 64 KiB.
- Warnings about damaged pages are capped at 1024, with a closing count of
  the rest.
- Filing a completed packet as header or audio moves into Stream::add_packet,
  keeping open under the cognitive-complexity limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-media: Ogg recovery budget in proportion to the file; stray BOS; header caps

- Damage recovery charges every byte it reads again (a high-water mark), against a budget of
  twice the file's length plus 1 MiB, instead of a fixed 64 MiB that cut off large damaged
  files. A page's body is read without reading its header again, so a page is not charged twice.
- A beginning-of-stream page that carries on the page sequence of a stream with no
  end-of-stream page yet is a stray flag: a warning, and the stream is not split. A link that
  starts again before its end-of-stream page warns.
- Header packet counts a file states (FLAC, Speex) are capped like the unstated FLAC count.
- Reserved FLAC metadata block types (7-126) are metadata blocks.
- Theora codec_params carries the picture size; the extradata table lists Theora and Speex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-media: Ogg header reads checked before they are made; scan span on 32-bit

- A header packet past the size cap is checked by its stated size and not read, and a stream's
  first packet is read once (only its first 80 bytes when it is too large to be a header).
- MAX_HEADER_BYTES is 64 MiB, room for two of the largest FLAC metadata blocks with their block
  headers and the rest of the headers.
- find_capture clamps the scan span in u64 before casting, so a span of 2^32 bytes cannot become
  0 on 32-bit targets.
- The Recovery doc says which reads it charges, and what header packet reads add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:06:14 -05:00
.CooCoo fa1a657476 Add craft-doc: read PhotoCraft, VectorCraft and EffectCraft documents without the apps (#404)
* Add craft-doc: read PhotoCraft, VectorCraft and EffectCraft documents without the apps

A new crate, craft-doc, reads the native files of three apps from bytes:
PhotoCraft .pcraft (a ZIP bundle), VectorCraft .vectorcraft (JSON, plain
or gzip, with the version 4 data after the JSON) and EffectCraft .ecproj
(JSON).

- sniff tells which app wrote a file from its first bytes.
- info gives the format version, the writer the file names, and whether
  the version is one the crate reads.
- renditions returns VectorCraft's preview and PDF and PhotoCraft's
  thumbnail and composite, when the file has them.
- outline returns PhotoCraft's canvas and layer tree, VectorCraft's
  artboards and top-level layers, and EffectCraft's compositions with
  their layers.

A file whose version is newer than the crate reads is refused with
Error::TooNew. Limits bound the JSON, each entry, the total bytes
decompressed and the JSON nesting, and each is checked before the bytes
it covers are allocated. ZIP entries are read by a small STORE and
DEFLATE reader in the crate. The crate depends on serde, serde_json,
thiserror and flate2 and builds for wasm32-unknown-unknown.

The tests build every document they read: JSON written by hand, PNG,
ZIP and gzip bytes assembled in the test. doc.yml runs fmt, clippy, the
tests and a wasm32 check.

* ci: do not keep the checkout token in the doc workflow

The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.

* craft-doc: read each format's envelope through its format crate

craft-doc no longer keeps its own copies of the envelope types and
constants. It reads them with craft-pcraft-format (`MANIFEST`,
`FORMAT_VERSION`, `THUMB`, `COMPOSITE`, `check_manifest`),
craft-vectorcraft-format (`Head`, `Image`, `sniff`, `check`, `VERSION`,
`BLOB_MAGIC`, base64) and craft-ecproj-format (`Head::read`,
`SCHEMA_VERSION`). Its own base64 module is removed. A `.pcraft`
manifest is still read in one pass without building a
`serde_json::Value`, with the checks `read_manifest` applies. The
outlines are still read with craft-doc's read-only views of each
document, and the public API (`sniff`, `info`, `renditions`, `outline`,
`Limits`) does not change.

These inputs are now read as the format crates read them:

- A `.pcraft` manifest without `generator`, or with a `thumbnail` other
  than `thumb.png` or a `composite` other than `composite/preview.png`,
  is malformed for `renditions` and `outline`; `info` still reports its
  version. A manifest with more than 4,194,304 JSON items is refused
  with `LimitExceeded`.
- A `.vectorcraft` preview or PDF member without `mime` gives no
  rendition.
- `sniff` takes `format` only from a top-level member, so
  `{"meta":{"format":"vectorcraft"}}` is not a VectorCraft file.

tests/envelopes.rs writes a file of each format with the crates' types
and reads it through every call, and checks the inputs above.
doc.yml also runs when one of the three crates changes.

* craft-doc: sniff reads only top-level members of an EffectCraft project

`sniff` took any JSON object whose first member is `savedBy` for an
EffectCraft project when `"schema"` appeared anywhere in its first
bytes, so `{"savedBy":"1.0","metadata":{"schema":1}}` was claimed. It
now checks the top-level member names only, one at a time, without
keeping them.

* craft-doc: bound list elements with max_items; no rendition without a MIME type

Limits gains max_items (default 1,048,576): EffectCraft project items and
composition layers, VectorCraft artboards and layers, and PhotoCraft layers
at every level are counted before each one is read, and a file that lists
more gives Error::LimitExceeded. A list of empty objects within max_json no
longer expands into gigabytes of layers.

A VectorCraft preview or pdf member with an empty mime gives no rendition, as
one without a mime does; a preview may be PNG or JPEG. An EffectCraft
composition without a duration or a frame rate gets no timing instead of a
duration of 0.
2026-11-02 16:29:51 -05:00
Dylan M. Taylor e654924da4 Merge pull request #495 from benandez/p6-coderabbit
craft-proof: fixes from CodeRabbit on the ported code
2026-11-02 15:50:14 -05:00
Dylan M. Taylor fcf3be172e Merge pull request #482 from benandez/feat/craft-proof
craft-proof: WordCraft's proofing library, shared (spelling, grammar, hyphenation, Hunspell)
2026-11-02 15:48:03 -05:00
.CooCoo b8f7d93b00 Add craft-pcraft-format: the envelope of PhotoCraft's .pcraft bundle (#407)
* Add craft-pcraft-format: the envelope of PhotoCraft's .pcraft bundle

The manifest around the document, with the document as a type parameter
(Manifest<D>), TileRef, the bundle's entry paths and hash check,
FORMAT_VERSION, the manifest decode limits with the nesting and item
scan, and the migration runner as a generic step list. Messages and
serialized field order are PhotoCraft's, so its saved manifests stay
byte-identical when it switches to these types.

Part of #59.

* ci: do not keep the checkout token in the pcraft-format workflow

The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.

* pcraft-format: build no entry name from an invalid hash

tile_path and blob_path return None unless the hash is 64 lowercase hex
digits, so a hash read from a file such as "../x" never becomes an entry
name. TileRef::path gives a tile's entry the same way.

The docs note that MAX_GROUP_DEPTH is a copy of PhotoCraft's constant,
and that check_manifest also refuses a manifest larger than
max_manifest_bytes, which PhotoCraft's check before a save does not.

* ci: pin actions/checkout by commit and Rust to 1.95 in the pcraft-format workflow

* pcraft-format: read_manifest is the one reader of a manifest

read_manifest(json, limits, steps, target) checks the JSON against the
limits, refuses a format_version newer than target, runs the migration
steps, and reads the document as D and the other envelope members.
read_manifest_with reads the document with the caller's function
instead. Manifest no longer implements Deserialize.

Error::TooNew holds the manifest's generator, so a message can name the
writer; its message is unchanged. TooNew, Manifest and TileRef are
#[non_exhaustive], and Manifest::new and TileRef::new build them.

TILES_DIR, BLOBS_DIR, COMPOSITE_DIR and OBJECT_SUFFIX name the bundle's
directories and the object suffix. The docs of max_blob_bytes and
max_total_bytes say the caller enforces them.

* pcraft-format: read_manifest reports errors in serde's struct order

read_manifest reads the envelope members and the document in the order
the parsed object holds them, and reports a missing member after the
members that are present. A manifest with several faults gives the same
error as a derived Deserialize of the whole manifest, as PhotoCraft
reads it today.

* pcraft-format: document that a missing or invalid format_version is Corrupt

read_manifest's docs and the README say that a missing or invalid
format_version gives Error::Corrupt from migrate, and that a missing
generator or document, or any other mistyped member, gives Error::Json.
A test covers a string and a fractional format_version.

* pcraft-format: read_manifest refuses preview names other than its own

A manifest's `thumbnail` and `composite` named any entry the reader then
opened, while tile entries go through `is_valid_hash`. `read_manifest`
now gives `Error::Corrupt` for a `thumbnail` other than `thumb.png` or a
`composite` other than `composite/preview.png`, the only names PhotoCraft
writes. The check runs after the members are read, so JSON errors keep
their order.

The reader's doc now says a missing or invalid `format_version` is
`Error::Corrupt`, from `migrate`, not `Error::Json`.
2026-11-01 23:31:04 -05:00
.CooCoo 14d5dbb50c Add craft-vectorcraft-format: the .vectorcraft envelope, with the document as a type parameter (#408)
* Add craft-vectorcraft-format: the .vectorcraft envelope, with the document as a type parameter

File<D>, Head and Image hold the members VectorCraft writes, in its
order, with its skip rules: serde_json output of a File<D> is the JSON
VectorCraft writes, compact or pretty. Tail places entries after the
JSON from version 4 (INLINE_MAX bytes or more, after BLOB_MAGIC), and
split_tail and Image::bytes read them back. The crate also has the
version and name constants, check() for the format name and newer
versions, sniff() for plain and gzip files (the top-level "format"
member must be vectorcraft or drawcraft), and standard base64.

No dependency on any app; builds for wasm32-unknown-unknown.

* ci: do not keep the checkout token in the vectorcraft-format workflow

The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.

* vectorcraft-format: limit the bytes copied from the data after the JSON

Entries::bytes reads a file's entries and returns Error::TooBig once the
blob ranges it has copied add up to more than the data after the JSON
holds, so entries that name the same bytes again and again are refused,
as VectorCraft's reader refuses them.

A generator, preview or pdf of the wrong shape now reads as None in File
and Head, as VectorCraft opens such a file. File::tail gives a Tail for
the file's own version.

* ci: pin actions/checkout by commit and Rust to 1.95 in the vectorcraft-format workflow

* vectorcraft-format: document that base64 decode ignores the bits before the padding

decode reads a group such as Zh== like Zg==, and Zm9= like Zm8=, as
VectorCraft's own base64_decode does. The doc comment says so, and a
test pins both cases to the canonical bytes.

* vectorcraft-format: entries naming the same range share one buffer

VectorCraft's reader keeps the bytes of each [offset, length] it has
read and gives a repeated entry the same buffer without counting it
again (crates/format/src/unpack.rs). Entries::bytes refused such a
file once the repeats passed the size of the data after the JSON. It
now returns Arc<[u8]>, caches each range, and counts it once.
Overlapping ranges that differ are still copied and counted each time.

* vectorcraft-format: a refused entry does not count against the copy limit

Entries::bytes added a refused entry's length to the bytes copied, so a later entry that fit was refused too. It now counts an entry only once it is read.

* vectorcraft-format: Contents reads the document without keeping the preview and PDF

File<D> checks the shape of generator, preview and pdf by holding each
value in memory, and keeps the preview and PDF base64 it reads. Contents<D>
reads format, version, profiles, document and images, and skips the other
three as the JSON is read. It accepts and refuses the same files as File<D>.

* vectorcraft-format: Contents reads a repeated generator, preview or pdf

VectorCraft opens a file whose generator, preview or pdf member is repeated, since its reader does not declare those members, and refuses a repeated format, version, profiles, document or images. Contents now leaves the three members undeclared, so serde skips every copy of them, and still refuses the other repeated members. File and Head are unchanged.

* vectorcraft-format: sniff decodes JSON escapes in the format member

serde_json reads a file whose format key or value is written with escapes, such as \u0066ormat, as a VectorCraft file, and sniff compared the raw bytes and refused it. sniff now decodes the key and the value before comparing them.

* vectorcraft-format: File::read and Contents::read check the version

File::read and Contents::read are the readers of a file's JSON. Both
refuse another format or a version newer than VERSION after the JSON
reads, with ReadError::Envelope holding the Error (Format or TooNew), and
return ReadError::Json otherwise. File and Contents no longer implement
Deserialize, so a reader cannot skip the check.

Both read one implementation and treat repeated members the same way:
a repeated generator, preview or pdf is accepted, and File keeps the last
value, as serde_json reads a repeated key into a Value. A repeated
format, version, document, images or profiles is refused. A top-level
array holds format, version, document, images and profiles in that
order, as VectorCraft reads it; Contents' fields follow that order.

- Image::bytes returns None for an entry of more than MAX_ENTRY (1 GiB,
  VectorCraft's limit on one entry of a compressed file), and
  Image::bytes_at_most takes another limit. Entries keeps no per-entry
  limit, as VectorCraft opens larger entries in uncompressed files.
- Entries::blob reads a range; Entries returns Arc<Vec<u8>>.
- marker_at finds BLOB_MAGIC where the writer puts it in a file's first
  bytes; split_tail uses it.
- File, Contents, Head, Image, Part and ReadError are #[non_exhaustive].
2026-11-01 19:57:07 -05:00
.CooCoo ca1b5e2edb Add craft-ecproj-format: the envelope of EffectCraft project files (#409)
* Add craft-ecproj-format: the envelope of EffectCraft project files

An .ecproj file is one JSON object: savedBy, then the saved view's
members, then the project's members, written with two-space indentation.
The crate defines that envelope with the view and the project as type
parameters, so the project model stays in EffectCraft:

- SCHEMA_VERSION (2) and check_schema, which refuses a newer schema
  with EffectCraft's own message.
- File { saved_by, view, project }, serialized with both parts
  flattened, and File::to_json.
- Head, which reads savedBy and schema from a JSON object, and
  saved_by, which reads savedBy alone.

The tests write synthetic projects and compare the text with JSON
written by hand in the shape EffectCraft's writer gives. The workflow
runs fmt, clippy, the tests and a wasm32-unknown-unknown check.

* ci: do not keep the checkout token in the ecproj-format workflow

The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.

* ecproj-format: write compact files, read savedBy as EffectCraft opens it

EffectCraft's Save Compact Project Files setting (effectcraft 07639b5f)
writes .ecproj files on one line with serde_json::to_string.
File::to_json_compact writes the same text, and the docs say the crate
writes and reads both forms.

Head now reads every file EffectCraft's Project::from_json opens: a
savedBy that is not a string, or appears more than once, reads as None,
and a project written as a JSON array reads with its first element as
the schema. saved_by also gives None for a repeated savedBy, as
EffectCraft's saved_by does. File's docs say the crate does not check
values and that serde_json writes NaN and infinity as null.

* ecproj-format: skip a non-string savedBy without keeping it

Head::read and saved_by read savedBy into a serde_json::Value before
dropping any value that is not a string, so a wide or deeply nested
savedBy was held in memory, and one nested past serde_json's recursion
limit made Head::read fail. A non-string savedBy, and every repeated
savedBy, is now skipped element by element with IgnoredAny, as other
members are. It still reads as None.

* ecproj-format: Head::read refuses a newer schema and reads objects only

Head::read is the one reader of a project file's envelope. It returns
ReadError: Json when the text is not one JSON object with a u32 schema
(the array form included), or TooNew when the schema is newer than
SCHEMA_VERSION, holding the file's savedBy so a message can name the
version that wrote it. TooNew reads as before. saved_by() is removed,
and Head no longer implements Deserialize.

TooNew, ReadError and ReadError::TooNew are #[non_exhaustive].
2026-11-01 19:56:26 -05:00
Martin BallandClaude Opus 5.5 d2d5c737a6 CP6.13: craft-libs#482/#485/#488 review — NOTICE carries the embedded Portuguese patterns' BSD notice; README: the usage example defines text, the eight-candidate limit, what the default build embeds; en_words writes under crates/proof
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 00:27:37 +00:00
Martin BallandClaude Opus 5.5 b758ba6943 CP1.2: craft-proof embeds Language data from CRAFT_I18N_DIR; works without it
- Package renamed wordcraft-proof -> craft-proof; [workspace.dependencies]
  entry; public API unchanged.
- build.rs embeds craft-i18n language-data/ (en-us, de, pl, pt, it, ru)
  instead of include_str!/include_bytes! of WordCraft's assets/: warns when
  CRAFT_I18N_DIR is unset, errors under CRAFT_I18N_REQUIRED=1.
- No data: English spelling marks and suggests nothing, hyphenation that
  needs data is off, grammar and rule-based hyphenation work. no_data.rs runs
  every public entry point without data; data-pinning tests are ignored then
  (cfg craft_proof_data).
- es timing bound 75 -> 150 ms: craft-libs tests run at opt-level 0.
- proof.yml (fmt, clippy -D warnings, tests with and without data, release
  guard, wasm32), README, CODEOWNERS for crates/proof.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 10:02:10 +00:00
Dylan M. Taylor fea36c4309 Merge branch 'main' into clean-room-crw-format-decode 2026-10-28 22:10:33 -04:00
aiongg 1a11a48978 craft-dialogs: native file and message dialogs that never block the event loop (#412)
* Add craft-dialogs: native file and message dialogs that never block the event loop

Requests queue and show at the end of the frame on the main thread; answers come
back with the app's continuation on a later frame. One dialog at a time, a drop
guard and a panic guard so a dialog never stays open, and an override for tests.

On macOS, Open and Folder show as a free-standing NSOpenPanel
(beginWithCompletionHandler, which rfd can't show), Save and message boxes as
sheets through rfd's async dialogs, never falling back to runModal. Filter
extensions match in any letter case on Linux.

* craft-dialogs: Dialogs::raise brings the open macOS panel to the front

* CI: dialogs workflow (Linux clippy and tests for craft-dialogs)

* craft-dialogs: per-instance macOS panel, parent window check, override panic guard, CI lints macOS code

* craft-dialogs: raise only a visible panel; Open and Folder need a running app
2026-10-28 16:49:37 -04:00
RWoldbergandClaude Fable 5.1 a0c8a08020 craft-crw: Canon CRW (CIFF) raw decoder, moved from LightCraft with its clean-room record
The clean-room Canon CRW decoder written in LightCraft (crates/raw/src/vendor/crw.rs,
crw_tables.rs, branch clean-room-crw-format-decode, revision 70c755e4) becomes the shared
crate craft-crw: the CIFF heap container, the entropy coding derived by black-box analysis
(frozen code tables for selectors 0-2), sensor black and white levels, as-shot white
balance, orientation and the make/model/time records. Public types are the crate's own
(Error, Limits, Cfa, Rect, BlackLevel, Header, Image); the capture time is handed on as
Unix seconds so no calendar code lives here; LightCraft's shared plateau estimate is
copied as saturation_plateau. Algorithm, tables and thresholds are unchanged.

Tests: the ported synthetic round-trip, table-structure and hostile-input tests; a
proptest mutation sweep (tests/robust.rs); and tests/samples.rs, which pins the eleven
CC0 raw.pixls.us sample files (tests/samples.json: URL, SHA-256, licence) to the SHA-256
of the verified decode plus levels, white balance and metadata, so the port is proven
byte-identical to the LightCraft decode without redistributing the reference TIFFs.
scripts/fetch-crw-samples.py downloads the files outside the checkout; CRAFT_CRW_SAMPLES
and CRAFT_CRW_ORACLE point the tests at them. The crate builds for wasm32.

The whole derivation record (starting assumptions, plan with the maintainer's decisions,
source log, container and payload notes, derivation log, frozen format, verification,
colour/levels notes, private-corpus and chart notes, scripts, logs and the frozen table
JSON) moves verbatim to crates/crw/clean-room/ with a README that indexes it and maps the
LightCraft paths it cites; tools/gen_tables.py regenerates src/tables.rs from the JSON,
byte for byte.

Repository: .github/workflows/crw.yml (fmt, clippy, tests on Linux, Windows and macOS;
wasm check; the sample-file job with cached downloads), README and ATTRIBUTION rows,
NOTICE source provenance, docs/clean-room.md (the clean-room standard a format crate
follows here), and AGENTS.md expanded with the start-of-session steps, the new-crate
checklist, clean-room, assets and test rules; CLAUDE.md points at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 08:20:01 -06:00
aiongg 8250aa62ae Merge branch 'lang-2-ui' into lang-3-fonts 2026-10-26 03:14:35 +00:00
aiongg ee4103fe78 Merge pull request #337 from aiongg/lang-1-text
craft-text: hyphenation, line-break segmentation, bidi and line breaking
2026-10-25 23:08:26 -04:00
aiongg 9f89fe8f6d Add craft-menu: one menu definition, native on macOS and an egui bar on Windows, Linux and web (#66)
* Add craft-menu: Mac menu-bar layout and native-menu driver

A platform-free menu model (built directly or from flat rows), the macOS
layout (app menu, Window, Help, system-key clash resolution, translatable
labels), and NativeMenu, which feeds a backend's key equivalents to egui as
key presses, runs Minimize and craft-quit's Quit, and re-syncs only when the
app's state changes. Generalised from PhotoCraft's native_menu.rs.

* craft-menu: macOS menu bar through muda

* craft-menu: clipboard keys, layout-assigned shortcuts, Hide Others and atomic rebuilds

* craft-menu: menu key equivalents run while a text field has the keys

* craft-menu: commands as CommandRef, item kinds, craft-keymap chords

Items and rows run a craft_keymap::CommandRef and take a craft_keymap::Chord
shortcut; crates/menu/src/chord.rs is gone. Native ids are the command's key,
made unique with #2, #3 when a command is shown twice. Item.checked becomes
ItemKind { Plain, Check, Radio } (radio shows as a check item on macOS).
The built-in items' commands are Builtin::{Hide, HideOthers, Minimize, Quit}.
MenuRole gains Edit, View and Other; tag_submenu finds its menu by role.
While text editing, the keys left to the field are the clipboard keys and
craft_keymap::text_editing_chords. Adds the Platforms, Document and Gate types
(data only) and MenuBar::documents.

* craft-menu: Gate::allows, the one rule for what may run from the menus

* craft-menu: Windows, Linux and web layouts, documents in Window, keymap adapter

layout(bar, Target, options) dispatches to mac_layout, desktop_layout or
web_layout. Every layout drops what the target's Platforms leave out and lists
MenuBar::documents as radio items last in the Window menu (ItemRole::Document
is gone). Windows and Linux put About in Help, Settings in Edit as
Preferences and Quit in File (Exit on Windows); the web layout has no Quit and
takes the browser's own shortcuts off the items, reporting clashes. tidy drops
empty submenus.

apply_keymap sets shortcuts and toggle check items from a craft-keymap Keymap;
apply_state sets enabled and checked per frame.

* craft-menu: the egui-drawn menu bar for Windows, Linux and the web

* craft-menu: gate, command hook and one muda handler for the native menu

- NativeMenu::set_gate: app items greyed in place while modal or recording a
  shortcut; take_clicks drops clicks the gate forbids when they are taken.
  While recording, key equivalents reach egui as plain key presses.
- NativeMenu::on_command: a hook called from the menu event handler for each
  allowed app click; take_clicks still returns every command.
- Dispatch, shared between the driver and the backend's handler
  (Backend::attach replaces Backend::set_text_editing).
- Only Cmd or Ctrl chords become key equivalents; other shortcuts are not
  shown natively (building an attributed title needs unsafe objc2 calls).
- mac: one muda event handler per process, routed to the latest install;
  mac::install_app_menu ports craft-quit's minimal app menu onto it.
- craft_quit::install_app_menu is deprecated in favor of
  craft_menu::mac::install_app_menu.

* craft-menu: the bar takes Target, README section and NOTICE credit

* craft-menu: bar tests beside the layout and native ones; only a click outside closes the menus

* craft-menu: clippy (complex types, Copy clones, elided lifetimes)

* craft-menu: keyboard access to the egui bar

A tap on Alt alone or F10 focuses the bar, Alt+letter opens a menu and
a letter in an open menu runs or opens the row with that mnemonic, as
in a Windows menu bar. Mnemonics are assigned per level from the shown
labels, underlined only while the keyboard drives the bar, and a label
without an ASCII letter shows its English letter after it ("文件(F)")
through the optional english hook. Preferred letters come from
MenuBarUi::mnemonics. A Mac target has none, and a dialog or the
shortcut recorder keeps the keys. Ported from PhotoCraft's menu_keys.rs
and menu_keys_tests.rs.

* craft-menu: one installer or the other; sort mnemonics before checking uniqueness

* craft-menu: egui 0.37

* craft-menu: text fields keep their Edit items; web names by the browser's system; sync per frame; stable check kinds; handled hook clicks

* craft-menu: sync reports a clash change on an unchanged bar
2026-10-25 21:59:22 -04:00
aiongg 06a4ec194a craft-font-catalog, craft-font-store and craft-font-manager: installed fonts, the font store and the Font Manager panel 2026-10-25 19:09:33 +00:00
aiongg acf6f79739 craft-text: hyphenation, line-break segmentation, bidi and line breaking 2026-10-25 19:08:20 +00:00
aiongg 6354ef2728 jpeg: README and crate list entry 2026-10-24 20:43:51 +00:00
aiongg 4d646b3cbd Merge #164: Add craft-keymap: shared keyboard bindings and shortcut-set import 2026-10-24 02:29:52 +00:00
aiongg b94e7d8b87 Merge remote-tracking branch 'upstream/main' into craft-keymap
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	README.md
2026-10-23 05:02:48 +00:00
aiongg 8b471e88c2 Merge remote-tracking branch 'upstream/main' into fonts-build 2026-10-23 04:59:39 +00:00
skylar taylor-barrick b1401e33b1 Share browser download resource ownership (#226)
Add opt-in browser download helpers that own detached links and object URLs, clean up failed or abandoned preparation, and register delayed URL cleanup before issuing a click. Applications retain filename, MIME type, pacing, and user-activation policy.

Include browser lifecycle and failure-path tests, public API documentation, and Sonar test-source classification. All 32 PR checks passed; targeted WASM Clippy and formatting passed in local validation.
2026-10-22 20:49:41 +02:00
skylar taylor-barrickandskylartaylor ffe7f8fce8 Share Linux and BSD desktop appearance observation (#225)
* platform: share desktop appearance observation

* Align appearance target guards and document watcher contracts

* Replace appearance wildcard imports with explicit dependencies

---------

Co-authored-by: skylartaylor <skylartaylor@users.noreply.github.com>
2026-10-22 10:13:09 -04:00
skylar taylor-barrickandskylartaylor b9c1a1105e Add craft-platform with shared native dialog lifecycle helpers (#224)
* platform: add native dialog lifecycle services

* Document dialog outcomes and lifecycle contracts

---------

Co-authored-by: skylartaylor <skylartaylor@users.noreply.github.com>
2026-10-22 10:13:08 -04:00
Dylan M. Taylor 20b2c1a1eb ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:51 -04:00
aiongg b37040cb93 Add craft-fonts-build: embed craft-fonts faces from an app's build.rs 2026-10-21 06:16:08 +00:00
aiongg 07047b7221 Add craft-media: shared MP4/MOV, Matroska and bitstream code from FilmCraft (#182)
* media: add craft-media with bitstream and isobmff from FilmCraft

Moves filmcraft-bitstream and filmcraft-isobmff (storytold/filmcraft@6c3948a)
into one crate with a feature per module. The public API matches FilmCraft's so
its crates can become re-exports. Tests, oracle tests and fuzzing come along;
the ffmpeg discovery helpers replace filmcraft-testkit.

Out-of-contract inputs no longer panic: bit widths above 32, a zero NAL length
prefix width, and PCM frame sizes that overflow u32. Adds
BitReader::read_ue_plus from DeckCraft's copy.

* media: public ISO BMFF box reader (isobmff::boxes)

Make the demuxer's box iterator and big-endian reader public as
isobmff::boxes::{BoxIter, BoxRef, BoxHeader, Reader}, for walkers of
boxes the demuxer does not model (CR3 uuid boxes, HEIF meta items,
JPEG 2000). BoxIter::within keeps offsets relative to the outer slice,
and allow_truncated yields a cut last box. The fuzz test walks every
box of each mutated file.

* media: matroska feature from FilmCraft's filmcraft-matroska

Port storytold/filmcraft@6c3948a crates/matroska as craft_media::matroska
behind the opt-in feature matroska, with the same public API so FilmCraft
can make filmcraft-matroska a re-export. Its unit and ffprobe oracle tests
come with it, on craft-media's test helpers.

From DeckCraft's copy: cluster and block timestamps no longer overflow;
they saturate at i64::MAX. MuxingApp and the default WritingApp name
craft-media. New tests: block additions and mutation fuzzing. The oracle
skips the duration of a track's last packet when ffmpeg 8 shortened it.

* media: MOV PCM uses lpcm v2 above 16.16 rates and for unsigned 16-bit; signed 8-bit as twos

Version 0 sound descriptions store the rate as 16.16 fixed point, so 88.2 kHz
and higher were written as 0 Hz. 8-bit signed PCM was labelled raw (unsigned)
and 16-bit unsigned as twos/sowt (signed). The reader also took raw under a
sound handler for uncompressed video.

* media: stop the top-level box scan at a box that runs past the end of the file

A 64-bit box size near u64::MAX saturated the scan offset, and the next
offset + 8 overflowed.

* media: one sample budget for the whole ISO BMFF file

The sample cap and the file-length bound on samples without table entries
(constant-size stsz, trun without per-sample fields) applied per track or
per trun, so repeating a small trak or trun multiplied the allocation.
Both are now shared by all tracks and fragments.

* media: set_sample_entry rejects entries that change the track's media type or PCM frame size

The handler, stsz constant size and PCM timescale check are fixed at add_track;
a replacement that changed them produced a corrupt file. A bad track index
returns NoSuchTrack like the other writer methods.

* media: saturate the sample end in MkvFile::duration_ns

Cluster timestamps saturate at i64::MAX, so pts + duration could overflow
for files without Info/Duration.

* media: checked add for the Matroska cue cluster position

CueClusterPosition comes from the file; an add past u64::MAX panicked in
debug builds and wrapped in release. Such a cue now falls back to the index.

* media: cap the Matroska TimestampScale at 2^32 and saturate tick conversions

A scale near 2^64 overflowed u128/i128 in the per-lace split and in
Track::ticks_to_ns. A BlockDuration near u64::MAX also overflowed the tick
difference at the default scale. Larger scales are ignored like a zero scale,
and tick and nanosecond values outside i64 saturate.

* media: Matroska fixture generation no longer truncates the shared subtitle inputs

Parallel tests generating subs.mkv or webvtt.webm could read in.srt or in.vtt
while another thread rewrote it, and cache a fixture with missing packets.

* media: rustfmt parse_moof signature

* ci: add craft-media workflow covering feature sets, WASM and ffmpeg oracles

The lint job runs fmt, clippy for each feature set (none, bitstream,
isobmff, matroska, all) and a wasm32 check. The test job runs the
all-features tests with Ubuntu's ffmpeg and CRAFT_MEDIA_REQUIRE_ORACLES=1,
installing ffmpeg while the tests compile.

* media: Matroska lacing keeps timestamp gaps

A laced frame's timestamp is implied by the block's and the track's
DefaultDuration, so lacing collapsed gaps in the input. A frame now joins
a lace only when that implied tick equals its own, and tracks without a
DefaultDuration do not lace. The laced remux oracle compares timestamps.

* media: required oracles fail on fixture generator and ffprobe failures

Every path that gave up on an oracle check (missing tool, failed fixture
generator, failed ffprobe run) goes through one helper that prints
SKIPPED, or fails the test with CRAFT_MEDIA_REQUIRE_ORACLES=1. Fixture
generators of both containers share run_generator, which takes the
required flag explicitly so tests can exercise it without changing the
process environment.

* media: use US spelling

* media: a failed ffprobe probe skips the optional oracle check

* media: the Matroska oracle compares the last subtitle cue's duration
2026-10-20 09:07:12 -06:00
aiongg 890660444c keymap: use US spelling 2026-10-20 03:24:56 +00:00
aiongg d6c7b45350 keymap: shared keyboard bindings, scopes, overrides and shortcut-set import
craft-keymap holds the data and logic for keyboard bindings: egui-free
chords with one canonical text form, bindings to commands with params,
several bindings per command, scopes, tool groups, conflicts, overrides
kept in a craft-settings type, an import pipeline for other applications'
shortcut sets, and an optional egui dispatcher. Apps keep their command
catalogue, file readers and UI.
2026-10-19 03:52:23 +00:00
Martin BallandClaude Opus 5.5 c9bcc60832 i18n: README and library listing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9fM3mv1PUyKpk5rdwUQrW
2026-10-18 09:33:21 +01:00
skylar taylor-barrick 82a24b006e Merge pull request #169 from skylartaylor/docpath-crate
Add craft-docpath
2026-10-17 12:00:19 -06:00
aiongg ddc5012437 feat(docpath): add craft-docpath for checking paths stored in documents 2026-10-16 16:01:59 +00:00
Jeremy 90792958fb Merge remote-tracking branch 'upstream/main' into feat/ai-model
# Conflicts:
#	Cargo.toml
#	README.md
2026-10-15 20:07:18 +01:00
Jeremy bd175be54f craft-ai-core: rename from craft-ai-model; lapse each kind of consent on its own; keep models found at a new address
Consent expiry is now per kind of data: renewing text no longer renews an expired image permission. A connection edit keeps models found by a test at the new address, and drops only the ones saved for the old one.
2026-10-15 20:06:57 +01:00
Jeremy ba6ff9187c Add craft-ai-model: the data model and contract for AI in the Craft Apps 2026-10-13 21:53:38 +01:00
skylar taylor-barrick c07b3fada6 feat(pdf): share patched reading and rasterization 2026-10-13 12:52:32 -06:00
skylar taylor-barrick c3487320b2 feat(pdf): add shared guarded writer foundation 2026-10-13 12:29:12 -06:00
+2 dd9e3f1bc3 Add craft-cms: ICC colour management extracted from PhotoCraft
Move PhotoCraft's crates/cms (storytold/photocraft@b3222ee8e7)
into crates/cms as craft-cms: ICC v2/v4 profile parsing and writing, transforms, rendering
intents, black point compensation, soft proofing, gamut checks, synthetic CMYK, 3D LUTs, LUT
files and built-in profiles, with all tests.

The Bradford inverse and the built-in RGB colorant matrices are computed in constants, so the
build rejects a singular matrix and the two expect calls are gone; the values are bit-identical
to the runtime results. Built-in profile descriptions and copyright text keep their PhotoCraft
wording so the profile bytes do not change. rayon joins the workspace dependencies.

Refs #36

Co-authored-by: Alex Gorevski <agorevski@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Elvis33LE <67311580+Elvis33LE@users.noreply.github.com>
Co-authored-by: F4LCON <107556180+itsF4LCON@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com>
Co-authored-by: Shehan Kavishka <kavishkashehan726@gmail.com>
Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: ljkx <177671075+ljkx@users.noreply.github.com>
Co-authored-by: storybored379 <storybored379@gmail.com>
Co-authored-by: wstock <w.stock@yahoo.com>
2026-10-13 01:23:45 +00:00
Alejandro Londono 6006abb757 feat(settings): add shared config storage for Craft apps
Add shared config.toml storage while keeping settings types, defaults and UI policy in each app.

Includes isolated in-memory operation, configurable portable paths, atomic saves with private backups, damaged-file preservation, schema migrations, and documented best-effort handling of external edits.

Validation: native macOS formatting, strict workspace clippy, 172 workspace tests and the craft-settings WASM check. Independent backup-permission and failed external-migration repros fail before the fixes and pass afterward.

Hosted CI: all seven UI and printing jobs pass across macOS, Linux, Windows and UI WASM.

Refs #12
2026-10-12 15:40:50 -06:00
Zach Shallbetter 8df3d87d8b Add craft-atomic: crash-safe atomic write and document replacement (refs #2, fixes #14) 2026-10-12 20:23:17 +02:00
skylar taylor-barrick c68d145c9f docs: list craft-quit in the library catalog 2026-10-12 20:21:37 +02:00
skylar taylor-barrick b700ae753f Add shared printer settings and asynchronous job feedback 2026-10-12 02:32:49 +02:00
skylar taylor-barrick eb99775517 Extract craft-print PDF spooler with Windows size and duplex fixes 2026-10-11 07:07:56 +02:00
skylar taylor-barrick d9077e2c09 UI.7: add shared panel docking transactions and workspace renderer 2026-10-09 22:29:35 -06:00
skylar taylor-barrick 9198fae30e UI.4: share widget-state theme application 2026-10-09 13:14:03 -06:00