Standalone pieces (front-end and backend, no core changes)

A piece is a reusable Day widget. Day ships built-in pieces (button, slider, list, …), but anyone can publish a piece as an independent crate that adds both its cross-platform front-end (Rust) and its per-toolkit native backend (Objective-C via objc2, C++ shims, Android Java, …), with no edits to any core Day crate. day-piece-searchfield is the reference implementation.

Scaffold a new piece with day new. Don’t hand-assemble the crate: day new piece <name> generates a ready-to-build project (remote Day deps by default; --local <path> for a local Day checkout). With no --toolkits it emits a composite piece (front-end only); with --toolkits appkit,gtk,qt,uikit,mdc,xaml (any subset) it emits a native piece with a renderer per backend plus the C++/Java/Swift glue each one needs. Either kind comes with demo/, a one-page app that shows the piece and runs its walkthrough (--no-demo skips it). The companion day new part <name> scaffolds a headless part. For full walkthroughs see the tutorials: composite piece, native piece, and part.

Extensibility rests on two mechanisms:

  1. Renderers register link-time into each backend’s RENDERERS slice (via linkme), so a backend dispatches an unknown kind to the piece’s make/update/measure with no registry edits. (web-dom is the one exception: linkme has no wasm32 implementation, so day-dom keeps a runtime registry and the piece calls day_dom::register_renderer from its own constructor. Same three functions, same dispatch, different moment; see media.md.)
  2. Native backend assets (C++ shims, Android Java, Gradle deps) are declared in the crate’s own Cargo.toml / build.rs and folded into the app’s native build automatically. Browser JavaScript can ship in a day_bridge::bridge! arm: the CLI stages and registers its module. Browser DOM access covers element access, events, and cleanup for browser renderers.

1. The front-end (any backend)

use day_core::{BuildCx, Flex, Piece, RNode, with_tree};
use day_reactive::{Signal, bind_seeded};
use day_spec::Event;

pub const KIND: &str = "my.piece.gauge";

pub struct Gauge { /* … + a Signal for two-way binding */ }
impl Piece for Gauge {
    fn build(self, cx: &mut BuildCx) -> RNode {
        let node = cx.leaf(KIND, &props, Flex::default());   // a native leaf of `KIND`
        bind_seeded(seed, move || value.get(), move |v| { with_tree(|t| t.patch(node, patch, false)); });
        cx.on(node, move |ev| { /* native events → write the Signal */ });
        node
    }
}

impl Piece gives you .id()/.a11y()/.frame() for free (blanket Decorate). Props are the full realize payload; a sparse Patch enum carries changes.

Those modifiers return Decorated<YourPiece> rather than erasing, so a chain keeps your type. To make your own builder methods reachable after one, put #[day::builder(GaugeBuilder)] on the inherent impl block that holds them; the attribute generates the GaugeBuilder trait, its impl for Gauge, and the forwarding impl for Decorated<Gauge>, the pattern every built-in piece follows, written up in docs/api-style.md “Typed builders and erasure”:

use day::prelude::*;   // `Decorated` and `Piece` must be in scope for the generated impls

#[day::builder(GaugeBuilder)]
impl Gauge {
    /// Tick marks around the dial.
    pub fn ticks(mut self, n: usize) -> Self { self.ticks = n; self }
}

Every pub fn in the block that takes self by value and returns Self becomes a trait method; the rest (&self accessors, non-pub helpers) are left out. A generic piece names its parameters in the attribute (#[day::builder(GaugeBuilder<T>)]). A hand-written *Builder trait, or a map_inner forward, is not accepted in a piece crate.

Skip any method whose name Decorate already defines (id, background, scale, …): the inherent method on Decorated wins resolution, so a forwarded twin would never be called.

2. Per-backend renderers: the renderer! macro

Each backend module registers its native renderer into that backend’s RENDERERS slice, the same slice the built-ins use, so no Day edit is needed. Declare the per-toolkit glue modules with one line: day_pieces::glue_modules!(appkit, gtk, qt, uikit, mdc, xaml) expands to the feature-and-target-gated mod block binding each lib-<toolkit>.rs (list only the toolkits you implement; a non-standard gate, like webview’s Linux-only GTK, stays a hand-written block beside it). Then write typed make/update (no &dyn Any downcast) and one macro line; day_pieces::renderer! expands to the linkme registration + the props/ patch downcast:

#[cfg(all(feature = "appkit", target_os = "macos"))]
mod appkit_impl {
    use super::*;
    fn make(backend: &mut AppKit, props: &MyProps, id: NodeId) -> Retained<NSView> { … }
    fn update(backend: &mut AppKit, h: &Retained<NSView>, patch: &MyPatch) { … }

    day_pieces::renderer!(day_appkit::RENDERERS, AppKit,
        kind: KIND, props: MyProps, patch: MyPatch, make: make, update: update);
}

Add measure: f for custom sizing: measure: day_pieces::fill_measure for a growing leaf (a web view, a canvas), or omit it for the backend’s default. A patchless piece (configured once, e.g. Lottie) drops patch:/update:: renderer!(day_uikit::RENDERERS, Uikit, kind: KIND, props: MyProps, make: make). Do the same for day_gtk::RENDERERS, day_qt::RENDERERS, day_uikit::RENDERERS, day_android::RENDERERS, day_xaml::RENDERERS. Each backend is behind a cargo feature that pulls in that toolkit crate; the app enables my-piece/<backend> alongside day/<backend>.

Reporting events back. A renderer calls day_<backend>::emit(node, event). Beyond the fixed Event variants, a piece defines its own event with Event::custom("my:tag", text) (in-process); its cx.on reads it. Across a native boundary (JNI/C-ABI) the tag can’t be a &'static str, so it’s empty and the payload rides in num/text: on Android the shim calls DayBridge.nativeOnEvent(id, 12, num, text) (kind 12 = the open Custom channel). day-piece-webview reports its URL this way.

3. Native backend assets

A piece often needs native code the Rust FFI alone can’t express. Day gives each toolkit a local extension path so that code lives in the piece crate:

Data assets a piece ships ([package.metadata.day.piece].assets)

A piece that carries files rather than code — a web view’s inline site, a model, a shader, a sample — declares the directories holding them:

[package.metadata.day.piece]
backends = ["appkit", "gtk", "qt", "xaml", "arkui", "dom"]
assets = ["web"]        # directories, relative to the crate root

day build stages each one into the app’s bundle under the crate name, beside the app’s own resource/assets/, on every target: web/index.html in day-piece-lottie becomes day-piece-lottie/index.html, which the piece then resolves by that name with resource(…), AssetDir, or a web view’s inline site. The namespace is the crate’s and is not configurable, so two pieces cannot collide with each other and neither can collide with a name the app chose.

The app does nothing: it depends on the piece, and the files arrive. A dev day launch stages them under build/day/assets/ and points DAY_PIECE_ASSET_ROOT there, because the app’s own assets are read where they lie and nothing may be written into the app’s source tree.

day-piece-lottie is the worked example: on the six backends with no native Lottie player it ships lottie-web and a host page this way, and composes day-piece-webview to show them.

Crates that wrap a piece: galleries of pages, pieces composing pieces

day build switches on a piece’s backend by adding <crate>/<backend> to the app’s features, and cargo accepts that only for the app’s direct dependencies. So it is added for those alone. A crate that sits between the app and a piece (a piece built on another, or a library of ready-made pages that plays a piece) declares the same backends and forwards each one, and the app reaches the piece through it:

[package.metadata.day.piece]
backends = ["uikit", "mdc", "appkit", "gtk", "qt", "xaml", "arkui", "dom"]
assets = ["animations"]   # optional: files the wrapper itself ships

[features]
appkit = ["day-piece-lottie/appkit"]
uikit = ["day-piece-lottie/uikit"]
# … one line per backend

day-piece-lottie forwards to day-piece-webview this way. The example galleries are the other case: day-piece-charts-gallery and day-piece-lottie-gallery hold their demos’ pages as libraries, so the demo app is a thin shell around them and Day-Showcase mounts the same pages. A gallery like that brings everything its pages need. Its strings are a private catalog (localization.md “Private catalogs”) that resolves in any host. Its files are data assets, staged under its crate name. Its backends are forwarded as above. The host adds one dependency and copies nothing.

C++ shims: Qt & XAML (build.rs)

The piece carries its own src/lib-qt-shim.cpp / src/lib-xaml-shim.cpp and compiles them in build.rs (gated on the feature). Qt widgets are plain C++ objects and the handle is a raw QWidget*, so a Qt shim is self-contained. XAML handles are a private boxed type owned by day-xaml-sys, so the piece boxes its XAML element through the exported day_xaml_box / day_xaml_unbox functions (a stable WinRT COM-ABI). Both reuse the sys crate’s generic measure (day_qt_size_hint / day_xaml_measure). See pieces/day-piece-searchfield/{build.rs,src/lib-qt-shim.cpp,src/lib-xaml-shim.cpp}.

Android Java + Gradle deps ([package.metadata.day.android])

The piece carries its own Java/Kotlin under a crate dir and declares it in Cargo.toml:

[package.metadata.day.android]
java = ["platform/android/java"]                                        # → Gradle java srcDirs (a dir, or one .java/.kt file)
res = ["platform/android/res"]                                          # → Gradle res srcDirs (optional)
gradle-dependencies = ["com.google.android.material:material:1.11.0"]   # → app dependencies { }
gradle-repositories = ["https://jitpack.io"]                  # → extra Maven repos (optional)
permissions = ["android.permission.INTERNET"]                 # → <uses-permission> in the manifest
proguard = ["platform/android/proguard-rules.pro"]                     # → R8 keep rules (see below)
manifest-components = ["platform/android/components.xml"]              # → <receiver>/<service> (see below)
# Rare compatibility exception; active only when this package's Cargo feature is enabled.
disable-native-heap-pointer-tagging = { feature = "servo" }

day build (for android-mdc) runs cargo metadata, walks the app’s dependency closure, collects every piece’s contributions, and writes build/day/android/day-pieces.json. Day’s Gradle plugins read that file generically (a loop, so per-piece edits are never needed) and add the Java dirs, res dirs, dependencies, and repos.

Day’s Gradle plugins. An app’s Gradle scripts stay small. settings.gradle.kts includes build/day/android/gradle-plugin and applies dev.daybrite.day.settings, which adds the repositories. app/build.gradle.kts applies dev.daybrite.day.android, which applies com.android.application and configures the module from build/day/android/: Day.toml identity, the SDK levels, the day-android shim and piece sources, libraries, the manifest overlay, R8 rules, and release signing. The plugins are Java sources in toolkits/day-android/gradle-plugin, built on AGP 9.4, which needs Android Studio 2026.1.4 or newer to sync. Raise that version only to one a current Android Studio release supports, and update the Android Studio prerequisite on the website’s system requirements and android-mdc pages with it. day build, day prepare, and day open stage them from the day-android crate the app resolves, so an app always builds with the plugin that matches its Java shim. The app’s own android {} and dependencies {} blocks run after the plugin, so a value set there overrides Day’s. A change to Day’s Android build goes in the plugin: every app picks it up on its next day build, while a scaffold change reaches only new apps.

A single Java file. A java entry may name one .java or .kt file instead of a directory, so a piece or part keeps its Android code beside its Rust (java = ["src/DaySearch.java"], the layout day new piece and day new part scaffold; --java-in-src=false keeps a platform/android/java/ tree). Gradle compiles source roots, so day build links each such file into build/day/android/piece-java/<package path>/, the directory its package line names, and adds that one root to the Java dirs. The link keeps the file’s name, and javac requires a public top-level class to live in a file of the same name, so a .java file is named after its class. A host that cannot create symlinks (Windows without the privilege) gets a copy instead, rewritten when the source changes.

Piece resources. res dirs compile into the app’s resource table, so a piece can ship the styles or drawables its Java needs (e.g. a theme overlay for a dialog). The app’s R package differs per app, so the piece’s Java resolves its own resources by name at runtime: ctx.getResources().getIdentifier("SomeStyleName", "style", ctx.getPackageName()). Prefix names with the piece to avoid collisions (resource names are one flat namespace per app). day-piece-datetime/android/res is the reference.

Manifest components. A part whose Java half is a BroadcastReceiver or Service needs it declared in the manifest, or Android never instantiates it. manifest-components names files holding only the elements that belong inside <application> (day build adds the <manifest> and <application> wrapper), and every class must be fully qualified, since the overlay merges into an app whose package the part cannot know:

Both gradle-dependencies and gradle-repositories accept plain strings or { name = "...", feature = "servo" } entries, checked against the declaring package’s resolved Cargo features. Use these gates for engine-only Java libraries and repositories.

An engine that encodes native pointers into JavaScript values may require disable-native-heap-pointer-tagging = { feature = "servo" } (a boolean is also accepted). The CLI checks the declaring package’s resolved features before emitting android:allowNativeHeapPointerTagging="false". This is process-wide, including other libraries in the app, and is a compatibility workaround rather than a memory-safety fix. Leave it absent unless the engine requires it. Android may remove this escape hatch on future hardware. Native-only builds keep the platform default.

After building each Android ABI, the CLI inspects the app ELF’s DT_NEEDED entries and ships the selected NDK’s libc++_shared.so when required. No runtime is added when the app does not link it; missing required runtime files fail the build before packaging.

<!-- platform/android/components.xml -->
<receiver android:name="dev.daybrite.day.notify.DayNotifyAlarmReceiver"
          android:exported="false" />

They merge into the same day-pieces-manifest.xml overlay the permissions use. A declared file that is missing is a hard build error, not a warning, because a dropped receiver produces an APK that installs, runs, and never delivers. A scaffold generated before this key existed gates the overlay on the permission list being non-empty; day build warns, with the one-line fix, when a part contributes components but no permissions.

Manifest permissions. A piece that needs a permission (a web view needs INTERNET) can’t reach the app’s AndroidManifest.xml, so day build also writes the collected permissions into a generated overlay manifest (build/day/android/day-pieces-manifest.xml). Day’s Gradle plugin registers that overlay with each app variant, and AGP’s manifest merger folds the <uses-permission> entries into the app manifest (deduping against any the app already declares). So a WebView-using app needs no manual manifest edit: the piece declares the permission and it shows up in the merged manifest. day-piece-webview is the reference. (A piece can only add a permission; it never removes or narrows the app’s own.)

Release minification (R8/ProGuard). A day build --profile release (and day pack) minifies with R8: it shrinks unused code and renames classes and methods. Native (Rust) code reaches Java by name, so whatever it calls has to survive under that name, and so does anything a library looks up reflectively (WorkManager instantiating a Worker from its class-name string, Room looking up a <Database>_Impl). A renamed class breaks the lookup, and the release APK installs and then fails at the first call.

Three sources of keep rules cover it.

  • Bindings keep what Rust calls. A piece or part reaches its Java through a java_class! binding, and each binding records a keep rule for exactly the class and members it binds. day build reads those rules from the app’s native library into day-jni-keep.pro. A first-party or third-party piece that binds its Java needs no rules of its own.
  • The framework keeps natives and leaves the rest to R8. day-android’s proguard-rules.pro keeps every class with native methods (Java’s way into Rust). Manifest components (activities, services, providers, receivers) are kept by the rules AGP generates from the merged manifest. R8’s optimizations stay off (proguard-dontoptimize.pro) unless the app sets [android] optimize = true.
  • Reflection keeps itself. An app’s or piece’s Java that something reaches reflectively, and anything a dependency reaches that its own consumer rules miss, goes in a proguard-rules.pro listed in proguard = [...]. day build collects these with the others.
# platform/android/proguard-rules.pro: what a library looks up by name.
-keep class * extends androidx.room.RoomDatabase { *; }

Day’s Gradle plugin reads dayProguardFile and proguardFiles from day-pieces.json and applies them in the release build type. pieces/day-piece-searchfield (a binding) and App Fair’s platform/android/proguard-rules.pro (reflection, app side) are the references.

The piece’s Java uses only day-android’s public surface: DayBridge.ctx (the Context) and DayBridge.nativeOnEvent(id, kind, num, str) (the event trampoline, kind per §14.2, 4 = selection). The Rust side binds its Java class with day_android::java_class! and makes its View with day_android::realize, which falls back to the visible placeholder when the factory throws (docs/android-jni.md). pieces/day-piece-searchfield/src/lib-android.rs is the reference, with src/DaySearch.java beside it.

iOS Swift shims + SwiftPM packages ([package.metadata.day.ios])

Many iOS libraries (and any Swift class with a non-@objc API) can’t be driven from Rust directly, and they ship as SwiftPM packages. A piece declares both in its Cargo.toml: the Swift shim it carries, and the packages it needs.

[package.metadata.day.ios]
swift = ["platform/ios/swift"]                                         # dirs of Swift shim sources
swift-packages = [                                           # SwiftPM package dependencies
  { url = "https://github.com/airbnb/lottie-ios", from = "4.5.0", products = ["Lottie"] },
]
frameworks = ["WebKit"]                                      # system frameworks to link
libraries = ["sqlite3", { name = "c++", feature = "servo" }]    # system libraries

libraries links system libraries through SwiftPM’s .linkedLibrary. A string always applies; an entry with name and feature applies only when Cargo resolves that feature on the declaring package. Build-flavor features participate in contribution discovery as well as compilation. This keeps an optional engine’s C++/zlib link requirements out of native-only builds. The same fields apply to [package.metadata.day.macos]. Names contain ASCII letters, digits, _, +, or -; paths and linker command fragments are rejected.

frameworks links system frameworks via the generated package’s linkerSettings. A piece that drives a class from an unlinked framework (e.g. a hand-rolled WKWebView) declares it here instead of dlopening or hand-#[link]ing (which doesn’t survive the cargo-staticlib → xcode link). day-piece-webview uses frameworks = ["WebKit"]. The same loss applies to a dependency’s #[link]: a crate that reads the system proxy settings (reqwest’s default client, through system-configuration) needs frameworks = ["SystemConfiguration"] on both [package.metadata.day.macos] and .ios, or the link fails with undefined _SCDynamicStoreCopyProxies symbols. On Android the counterpart is a dependency on native-tls/openssl-sys: no Android image carries OpenSSL, so the app adds openssl = { version = "0.10", features = ["vendored"] } under [target.'cfg(target_os = "android")'.dependencies] and builds it from source.

{from, exact, branch, revision} map to the matching SwiftPM version requirement; products are the library products to link. Xcode is not script-driven like Gradle, so day build (ios-uikit) instead generates a local SwiftPM package at build/day/ios/DayPieces. Its Package.swift lists every piece’s swift-packages as dependencies and compiles every piece’s staged Swift shims (each under a per-crate subfolder). The app’s generated (or optionally checked-in) .xcodeproj depends on that one local package (the iOS analog of the Gradle host scaffold: a XCLocalSwiftPackageReference + a product dependency in a Frameworks phase). So adding an iOS piece is pure Cargo.toml data; no .xcodeproj edits are needed.

Two further keys are shared with the macOS table below (docs/swiftui.md covers them in full):

  • A swift-packages entry may be local ({ path = "swiftui", products = ["MyViews"] }, relative to the declaring crate). The package’s transitive SwiftPM dependencies come along, and its public SwiftUI views are scanned and exported: generated hosting glue on this side, generated typed constructors (crate::swiftui::MyView(…)) on the Rust side. products defaults to the directory name.
  • platform = "16.0" raises the generated package’s minimum OS (the max across contributions wins). On iOS the CLI conveys it as an IPHONEOS_DEPLOYMENT_TARGET command-line setting, which reaches the app and package targets without editing the scaffold; ⌘R builds in Xcode need the deployment target raised in DayApp.xcconfig.

macOS Swift ([package.metadata.day.macos])

The macos-appkit leg uses the same table shape: swift, swift-packages (remote or local), frameworks, platform. day build generates build/day/macos/DayPieces from the table and the selected macOS host project’s project.xcproj references it (Xcode 27+); xcodebuild compiles and links the package with the Runner, the same shape as iOS (the Swift runtime resolves against the OS dylibs). day-piece-swiftui declares the same shim dir under both tables, one DaySwiftUI.swift with #if os(...) arms.

HarmonyOS ArkTS components ([package.metadata.day.ohos])

Some HarmonyOS components exist only in ArkTS: the ArkUI C node API (arkui/native_node.h) stops at the container kinds, so there is no way to construct a declarative Web (or Map) from native code at all. A piece wrapping one carries its own ArkTS the way an Android piece carries Java.

[package.metadata.day.ohos]
ets = ["platform/harmony/ets"]        # dirs of ArkTS sources; each needs an Index.ets exporting `dayPiece`

Each declared dir must contain an Index.ets exporting a DayPieceModule:

import { DayPieceModule } from '../DayPiece';           // generated next to the staged dirs

export const dayPiece: DayPieceModule = {
  kind: 'day.piece.webview',                            // matches the Rust KIND
  make: (ui, id, props) => frameNode | undefined,       // build it; undefined declines the kind
  update: (id, cmd, arg) => {},                         // the piece's own command vocabulary
  dispose: (id) => {},                                  // Day disposed the node — release it
  settle: () => Promise.resolve()                       // optional: before a dayscript screenshot
};

settle is for a component that renders outside ArkUI’s own frame, which the screenshot checkpoint (docs/window-image.md) cannot see: a web view’s renderer paints on its own thread and can trail the DOM a script just changed. The capture waits for every module’s settle before its ArkUI render checkpoint, so the hook resolves once the pending rendering has committed, and caps its own wait (a hidden page paints nothing): a capture may be late, never stuck. The cap must be a timer, not a deadline checked between replies, because an engine stuck in page script never replies at all; the capture also caps the whole settle at three seconds, so a module that misses this still cannot hold every later screenshot. The web view piece arms four animation frames in the page (enough to put the commit through the compositor’s pipeline), lets any CSS transition run out, and polls for the result.

The Rust-side counterpart, for a piece that presents frames through a Day canvas on any toolkit, is a capture hold: day_spec::capture::register_hold(fn() -> bool), answering whether every frame the host owes is on screen, which screenshot and sample_pixel wait on before the toolkit’s checkpoint (docs/window-image.md).

The same table carries the raw HarmonyOS permissions a crate needs, the counterpart of Android’s [package.metadata.day.android] permissions. It is for the system_grant ones, which no prompt covers and no platform-neutral [package.metadata.day.permissions] name maps to:

[package.metadata.day.ohos]
permissions = ["ohos.permission.GET_NETWORK_INFO"]   # joined into module.json5 at build

day build -t harmony-arkui stages every piece’s dirs under entry/src/main/ets/daypieces/<crate>/ (gitignored) and generates two files beside them: DayPiece.ets (the interface above) and DayPieces.ets, whose registerDayPieces(uiContext, builtins) hands the native shim one factory, command sink, and disposer for all pieces. The framework’s host page (staged beside them from the day-arkui crate, docs/harmonyos.md) calls it once, before start(), so adding an ArkTS piece is pure Cargo.toml data, like the iOS leg, and the shim never grows a case per piece. An ArkTS component reports with nativeEntry.pieceEvent(id, text, num?, kind?): with no kind the message arrives as the piece’s own Event::Custom; with one (1 TextChanged, 2 ToggleChanged, 4 SelectionChanged, 17 Submitted) as the Day event a native control would send, so a stand-in control needs no event translation in Rust. day-arkui also sends every ArkTS piece its laid-out size as the command day.frame "<w>,<h>" (vp) whenever it changes: a component built in a BuilderNode resolves percentages against the window, so one that fills its frame sizes itself from that. builtins are the framework’s own ArkTS-only components, which ride the same channel: the host passes the menu-style picker’s Select (docs/picker.md), and an app’s piece of the same kind wins.

On the Rust side the renderer is the thinnest of all the backends, because there is no native widget to build. day_arkui::piece::make returns the ArkTS component’s FrameNode as an ordinary handle:

fn make(_b: &mut ArkUi, p: &WebProps, id: NodeId) -> AHandle { piece::make(KIND, id, &p.url) }
fn update(_b: &mut ArkUi, h: &AHandle, patch: &WebPatch) { piece::update(h, "load", url) }

Events come back through the shim’s pieceEvent(id, text) as Event::Custom, the same open channel (§8.2) the Android bridge uses, payload only. The bridge enforces two rules. A declined make yields Day’s placeholder leaf rather than a null handle (a null would take the whole parent’s layout down), and release routes an ArkTS-owned node to dispose instead of disposing it natively.

Sizing. Day owns layout and sets each node’s position + size through the C API, so the ArkTS component must not size itself with percentages: a BuilderNode is built detached, where '100%' resolves against the whole window and the component covers the page.

The Swift shim exposes a flat C ABI (@_cdecl) that the piece’s Rust calls (mirroring the Android Java shim); it imports the SwiftPM product and returns a native UIView that Rust wraps via Retained::from_raw. See platform/ios/swift/DayLottie.swift and src/lib-uikit.rs in day-piece-lottie.

Web scripts ([package.metadata.day.web])

A crate’s web half is usually Rust over day-dom, or a daybridge js arm. When it needs hand-written JavaScript the page must load before the app starts (a worker, a synchronous channel, wasm imports under the crate’s names), the crate ships the files and declares them:

[package.metadata.day.web]
modules = ["web/day-sql.js"]        # ES modules the host page imports
files = ["web/day-sql-worker.js"]   # staged beside them, loaded by the modules

day build -t web-dom stages them under dist/modules/<crate>/ for every crate the wasm build links. A module exports register(rt), which returns wasm imports, and optionally start(rt, module), which the page awaits before the app’s entry point. docs/web.md has the contract; day-sqlite-worker is the reference, and scripts/ci/web-shim-abi.py checks that a crate’s modules supply every import its Rust declares.

The Android bridging contract

These are the guarantees a part or piece can rely on when its Rust calls its Java sidecar (all provided by day-android, all exercised in production by day-part-http). A daybridge arm rides these same guarantees (its generated JNI wrapper is written against them), so this list is what that generated code does on your behalf, and what to know when you write the call by hand:

  • Any thread may call. day_android::with_env(|env| …) attaches the calling thread to the JVM (and detaches scoped attachments). Blocking Java work runs on the caller’s thread; keep it off the UI thread, exactly like any other blocking Rust.
  • App classes resolve from any thread. A java_class! binding loads its class through the app ClassLoader cached at startup, so a Rust-spawned worker resolves your sidecar class even though a bare JNI FindClass there only sees system classes.
  • Calls clear their own exceptions. Inside day_android::java(|env| …) a thrown Java exception is described to logcat, cleared, and returned as None (docs/android-jni.md).
  • Post to the UI thread with DayBridge.main.post(...) on the Java side; on the Rust side, capture a day_reactive::Setter (docs/focus.md, DESIGN §4.5) rather than touching UI state from a worker.
  • Bulk payloads cross as one byte[] envelope: [status i32 BE][meta-len i32 BE] ["k\nv\n…" meta][payload]; a negative status is your error sentinel with the message riding the meta block. Build it with DayEnvelope.pack/error in Java and parse it with day_android::envelope::Envelope in Rust; the two encode identically and Rust unit tests pin the format. There is one JNI copy each way and no per-field JNI traffic.
  • Piece-defined events ride the K_CUSTOM kind (DayBridge.nativeOnEvent(id, DayBridge.K_CUSTOM, num, text) → Event::Custom): the tag can’t cross JNI, so the piece reads the raw num/text payload. The full kind table is day_spec::bridge::BridgeKind; parity tests keep the Java constants in step.

4. Cargo wiring

[features]
appkit = ["dep:day-appkit", …]
gtk = ["dep:day-gtk", "dep:gtk4"]
qt = ["dep:day-qt"]                 # + a build.rs that compiles src/lib-qt-shim.cpp
uikit = ["dep:day-uikit", …]
mdc = ["dep:day-android"]        # + [package.metadata.day.android]
xaml = ["dep:day-xaml", "dep:day-xaml-sys"]   # + build.rs compiles src/lib-xaml-shim.cpp

The app mirrors each: my-piece/<backend> in the matching feature. Adding a piece needs no changes to day, the toolkit crates, the CLI, or the Gradle scaffold.

Dependency layering. The extension graph stays acyclic by rule: pieces may depend on parts (day-piece-remote-image → day-part-http is the shipped example) and on core crates; parts must not depend on day-pieces or any day-piece-*; tweaks may depend on day-pieces (the built-ins they configure) but not on any satellite day-piece-* or day-part-*. A workspace test (crates/day-cli/tests/layering.rs) enforces this over cargo metadata, so a violating dependency fails cargo test.

5. Container pieces (hosting a Day child)

A piece is not limited to leaves: it can be a container whose native view hosts a Day-built subtree. day-core mounts children by handle, not by kind (the tree walks to the nearest native ancestor and calls Toolkit::insert(ancestor_handle, child_handle, index) without consulting the ancestor’s kind), so a piece-realized node is a valid insertion parent on every backend. The recipe (established by pieces/day-piece-pullrefresh, the reference container piece; see docs/pullrefresh.md):

let node = cx.native(
    KIND,
    &MyProps { /* … */ },
    Rc::new(day_core::FrameLayout { width: None, height: None }), // child fills the container
    Flex { grow_w: true, grow_h: true, ..Default::default() },
    day_core::Boundary::Yes,
);
cx.under(node, |cx| { let _ = child.build(cx); });               // mount the Day child inside
  • Supply a layout (FrameLayout/PassThrough, or your own day_core::Layout). A container node measures/places through its layout, not through the renderer’s measure fn.
  • Your per-backend make must return a container-capable native view: any NSView/UIView, any QWidget, any ArkUI FrameNode, but on GTK a gtk4::Fixed-backed view, on Android a ViewGroup, and on XAML a Panel, or the generic insert silently drops the child. (Native wrappers like Android’s SwipeRefreshLayout are ViewGroups.)
  • Events still flow through the single sink (Event::Custom for piece-defined ones) and commands through with_tree(|t| t.patch(node, …)), identical to leaf pieces.

External toolkits: registering a platform-toolkit pair (experimental)

A toolkit implemented in a separate repository registers its platform-toolkit pair by declaring it in the toolkit crate’s Cargo.toml:

[package.metadata.day.toolkit]
target = "netbsd-wxwidgets"    # <os>-<toolkit>; the toolkit half names the app's cargo feature
host = "any"                   # optional: restrict to "macos" | "linux" | "windows"
label = "wxWidgets"            # optional: pickers and error listings
doctor = "wx-config --version" # optional: `day doctor` probe (command + space-separated args)

The CLI resolves -p <name> against the builtin catalog first, then against declarations found in the app’s dependency graph (via cargo metadata --all-features, since the toolkit crate sits behind the very feature its declaration names). A declared target inherits the desktop pipeline. That is the only kind accepted today, because a new pipeline kind (another mobile OS) means new build/launch/pack code in the CLI, which cannot come from a crate.

The app wires the toolkit the same way it would a builtin, plus one entry call:

# the app's Cargo.toml
[features]
wxwidgets = ["dep:day-toolkit-wx"]          # feature = the toolkit half of the target name

[dependencies]
day-toolkit-wx = { git = "…", optional = true }
// the app's main.rs — the toolkit's entry wraps `day::launch_external`, the cfg-free
// launcher that starts the dayscript engine exactly as the builtin launchers do.
#[cfg(feature = "wxwidgets")]
day_toolkit_wx::launch(options, root);

With targets = ["netbsd-wxwidgets"] in Day.toml, the target behaves like a builtin desktop target: day launch (build + run + log streaming), --script walkthroughs, day drive, day stop/relaunch, the session registry, day doctor (running the declared probe), day metadata (the catalog entry carries external: true and the declaring crate), and day lint.

What a declared target does not get:

  • day pack — packaging formats are per-OS CLI code; the guard says so explicitly.
  • day new / day project add-target — scaffolding stays builtin; the toolkit crate documents its own project shape.
  • The pieces’ native renderers: no in-repo piece ships a wxwidgets feature arm, and neither does the external day-piece-webview, so extension-piece kinds render Day’s visible ⟨kind⟩ placeholders unless the external ecosystem ships renderer crates for its backend (the Registry/renderer! registration path is the same one in-repo pieces use). The built-in vocabulary is the backend’s own realize: cover what you support and placeholder the rest; the assert_no_placeholders allow-lists record which kinds still render a placeholder on each backend.

Reference

pieces/day-piece-searchfield implements all of the above: a native search input on six backends, its own Qt + XAML C++ shims, and its own Android Java. It’s verified on AppKit / GTK / Qt / iOS / Android and CI-built on XAML. Use it as a template. Its layout keeps the shared front-end and each toolkit backend in a separate file:

pieces/day-piece-searchfield/
├── Cargo.toml               # features + [package.metadata.day.android]
├── build.rs                 # compiles lib-qt-shim.cpp / lib-xaml-shim.cpp per feature
└── src/
    ├── lib.rs               # front-end (the `Piece`) + `day_pieces::glue_modules!(…)`
    ├── lib-appkit.rs        # one file per toolkit renderer …
    ├── lib-gtk.rs
    ├── lib-qt.rs            (+ lib-qt-shim.cpp)
    ├── lib-uikit.rs
    ├── lib-android.rs      (+ DaySearch.java)
    ├── lib-xaml.rs        (+ lib-xaml-shim.cpp)
    ├── lib-qt-shim.cpp
    └── lib-xaml-shim.cpp

lib.rs declares the backends with one day_pieces::glue_modules!(appkit, gtk, qt, uikit, mdc, xaml) line (§2), so every lib-<toolkit>.rs is compiled only for its feature+target and the whole native surface for a toolkit lives in one place.

day-piece-webview is a second reference, in its own repository since 2026-09 (its docs/webview.md covers the piece): a heavier native backend (an embedded browser) that additionally contributes an Android permission, hand-rolls the iOS WKWebView (linking WebKit.framework through the frameworks key above), and returns the proposal from measure so a growing leaf fills on Android. It follows the external-repository rules described under day-piece-lottie below, and its demo/ app’s dayscript is its on-device test. The JavaScript evaluation seam it registers with stays here, in webview-eval.md.

day-piece-lottie is a third reference, and the first piece to live in a separate repository: an iOS/Android-only piece that pulls an external native package on each platform, the lottie-ios SwiftPM package (via the [package.metadata.day.ios] mechanism above) and com.airbnb.android:lottie (Gradle). Its Swift and Java shims each wrap a LottieAnimationView behind a flat C ABI / static method. It is also the reference for an external repository: bare canonical git = "https://github.com/daybrite/day.git" dependencies with no ref (so the consuming app’s Cargo.lock picks one day revision for the whole graph), a [package.metadata.day] compat = "0.5" line naming the day minor it was tested against, a demo/ app whose dayscript is the on-device test, and a headless model module tested on the host. In this tree, scripts/ci/scaffold-check.sh keeps swift-packages exercised on the ios-uikit leg with a fixture piece that pulls swift-collections.

day-part-speech is the reference for the other mechanism, and the first part to live in a separate repository: a headless part whose every platform implementation is a bridge arm — Swift, Java, ArkTS, JavaScript, C++, and C — inline in one src/lib.rs beside the Rust declaration they share. Its docs/speech.md walks through the arms. day-piece-camera is a fourth: a native camera on the three mobile toolkits — a Swift shim that reports back through a C function pointer, a Java class over CameraX that is its own LifecycleOwner, and a HarmonyOS arm where the ArkTS half only hosts an XComponent surface and a C shim over the NDK camera kit (compiled by the crate’s own build.rs) owns the session. It is also the first piece to declare [package.metadata.day.permissions] uses = ["camera"], and the app supplies the reason.

As an external repository it follows the same rules as day-piece-lottie above: bare canonical day dependencies, a compat line, and a demo/ app whose dayscript is the on-device test.

parts/day-part-battery (see battery.md) is a fourth reference, the first part: a headless crate with no UI Piece at all. Where pieces/ holds UI-library extensions (each registers a renderer), parts/ is the non-UI counterpart: capability crates (day-part-*) that extend Day apps with platform services. It shows the backend-contribution mechanism accommodates non-UI capabilities: it contributes Android Java (for BatteryManager, now through a bridge arm) but registers nothing into any RENDERERS slice, and selects its per-OS impl by #[cfg(target_os)] rather than a toolkit feature. Any Rust code can depend on it and call day_part_battery::status().

Named piece operations

A piece can expose operations without making Day depend on its crate or native backend. Register a handler with day_core::register_piece_operation(kind, name, handler) and call it through day_core::piece_operation(node, name, input, done). Names should include a namespace, such as example.document.search; the piece defines the textual input and result formats.

The registry is keyed by both piece kind and operation name. Different pieces and different operations coexist; re-registering the same pair replaces its handler. Dispatch looks up the live node’s kind and releases the registry and tree borrows before invoking the handler. It returns false for a missing node or operation, without calling the completion callback. A handler that accepts a request must complete it with a result or error, either immediately or later. Callers must keep the event loop running while awaiting asynchronous results.

Dayscript’s existing web_eval command is an adapter for the external webview’s day.webview.eval operation. The core registry does not interpret that name or its payload.

Flatpak dependencies

A dependency can declare what it needs of an app’s Flatpak: a base its native library requires, and sandbox permissions it uses at run time. Both live in [package.metadata.day.flatpak], and either may be given alone.

Bases

[[package.metadata.day.flatpak.bases]]
toolkit = "qt"
library-prefix = "libExampleEngine"
id = "org.example.Engine.BaseApp"
# version = "1"  # Omit to follow the target runtime version.

day pack reads declarations from the app’s resolved dependency graph, including the app itself, with the toolkit and discovered piece features enabled. It selects declarations for the target toolkit whose library-prefix matches a direct ELF DT_NEEDED entry. The prefix is literal, not a glob. A known non-match omits the base; an unreadable or unsupported binary retains the declared requirement so a failed probe cannot silently drop a runtime dependency.

Flatpak supports one base. Identical base IDs and resolved versions coalesce; conflicting requirements stop packaging with an error. Malformed declarations also fail packaging. If version is omitted, it follows the selected runtime version, including DAY_KDE_RUNTIME or DAY_GNOME_RUNTIME overrides. Engine IDs, prefixes, and version pins belong to the declaring crate; Day only resolves and renders the declarations.

Sandbox permissions

Every Day app’s Flatpak has a display, the GPU and the network: --share=ipc, --socket=fallback-x11, --socket=wayland, --device=dri and --share=network. A dependency that reaches further declares the finish-args it needs:

[package.metadata.day.flatpak]
finish-args = ["--socket=pulseaudio"]

That is day-part-sound’s declaration: its Linux engine reaches the sound server through libpulse, and a sandbox has no route to that server without the socket (sound.md).

day pack collects the entries from the same dependency graph as the bases and adds them to the manifest after the defaults, sorted and without repeats, so the manifest is the same on every build. An app that does not depend on the crate does not get the permission. Each entry is one --option[=value] word as flatpak build-finish spells it; anything else stops packaging with an error. A permission widens what the app can touch for every user who installs it, so declare the narrowest one that works.

When running Day’s full lint script against an external crate under development, set DAY_LINT_LOCAL_CHECKOUTS to its checkout path. Multiple paths are separated by newlines. The script adds them to Showcase’s local patch table alongside Day, so its Clippy checks use the same sources as your local app builds.

AppImage environment

A dependency can set an environment variable in one toolkit’s AppImage launcher:

[[package.metadata.day.appimage.env]]
toolkit = "qt"
name = "QT_MEDIA_BACKEND"
value = "ffmpeg"

An AppImage runs against the libraries bundled inside it, and a bundled library can need a setting that the build machine did not. day pack reads declarations from the app’s resolved dependency graph, including the app itself, the same way it reads Flatpak bases. It writes each declaration for the target toolkit into the AppImage’s AppRun. At launch, the variable is set only when the user’s environment leaves it unset or empty, so the user’s own setting wins. The value is literal; the launcher expands nothing in it.

A name uses letters, digits, and _, and must not start with a digit or with DAY_, which the launcher uses for its own paths. Identical declarations coalesce. Two crates giving one variable different values stop packaging with an error, and so does a malformed declaration. The Flatpak launcher does not apply these defaults, because a Flatpak takes its toolkit from the runtime.

day-piece-media is the reference. linuxdeploy bundles libgstreamer but no GStreamer element plugins, and the bundled library looks for plugins only beside itself. Qt Multimedia’s GStreamer backend then finds no elements and crashes, so the crate selects Qt’s FFmpeg backend, whose codec libraries linuxdeploy does bundle.

Apple build dependency tools

Apple Cargo builds use a sanitized PATH to avoid inheriting Xcode’s SDK compiler paths. Dependencies that need additional tools can set DAY_BUILD_TOOL_PATH to a colon-separated list of directories. These follow the selected Rust toolchain and Cargo bin directory and precede /usr/bin, /bin, /usr/sbin, and /sbin. The macOS build forwards this value to its Xcode script callback. For a direct Xcode build, set it as a user build setting.

For example, a Homebrew Python used by a dependency’s code generator:

DAY_BUILD_TOOL_PATH="$(brew --prefix python@3.12)/libexec/bin" day build -t macos-appkit

This is explicit build configuration; Day does not install tools or inherit arbitrary compiler directories from an IDE’s PATH.

Apple contributions can also declare linker-options, with the same string or { name, feature } forms as libraries. Each entry is one argument to the native linker, such as { name = "-no_fixup_chains", feature = "servo" } for SpiderMonkey’s tagged static pointers. Options must start with - and contain only ASCII letters, digits, _, and -. A macOS flavor’s MACOSX_DEPLOYMENT_TARGET environment entry also reaches Xcode as a build setting, so Rust and the final app agree on the flavor’s minimum OS version.

Derived Apple hosts link through day/dayapp-link.rsp, generated beside the Rust archive. By default, it passes -force_load for the complete archive, preserving the established linking behavior. The default path does not inspect archive symbols.

An Apple contribution can opt into experimental selective archive loading:

[package.metadata.day.macos]
selective-archive-loading = { feature = "servo" }

The feature belongs to the declaring Cargo package. Only packages in the app’s resolved dependency closure participate. An omitted or false setting keeps the default; true opts in unconditionally. Any enabled contribution selects this policy for the complete app archive on that Apple platform. Build-flavor features participate in the decision.

In selective mode, Day reads Mach-O symbols and retains linkme sections, global static constructor entries, and unmangled FFI exports from Rust object files. Other archive members are selected through normal references. This lets the experimental Servo integration link without force-loading overlapping bundled LZ4 implementations. Symbol retention uses section, object-file, and mangling conventions; compatibility with arbitrary native libraries, LTO, and compiler versions is not established. A successful link alone does not prove that overlapping library implementations are interchangeable.

Selective loading currently requires a single architecture. Day rejects universal inputs before symbol scanning: combining roots from different slices can require an x86_64-only symbol in the arm64 link, or vice versa. Architecture-specific response files and universal integration tests are required before that mode can support universal builds. Default -force_load builds continue to support universal archives.

Persistent host projects can retain their existing -force_load setting for default builds. To honor selective-loading metadata, replace it with @"$(BUILT_PRODUCTS_DIR)/day/dayapp-link.rsp" in OTHER_LDFLAGS and declare that response file as an output of the Rust build phase. The generated file selects the policy on each build, so disabling the feature restores -force_load without leaving selective symbol flags behind.