The Kotlin render projection
fuaran-kt is the native Kotlin surface of the Fuaran UI wire format, over the
Rust reference core. It decodes a canonical tree into sealed Kotlin types for
rendering, and drives a live session whose truth and mutation live in the
corpus-certified Rust core.
That sentence carries a boundary worth stating before anything else, because it decides what this library does and does not owe you.
What this is, and what it is not
This is a render projection, not a conformant host. The Rust reference core owns the canonical codec, the tree-op apply engine, and mutation, exposed through a small C-ABI. The Kotlin side holds a consumer-grade decoder into sealed classes — and no canonical encoder at all.
So the bar this surface is held to is "decodes every node fixture in the shared conformance corpus", never the byte-parity bar. There is no encode leg here to certify, and a byte-parity claim would be a claim about the core.
The one thing in the library that serialises is JsonValue.encode(), a compact
JSON writer used solely to marshal an interaction payload — a SetState value, a
form-field edit — back into the string the session's setState channel takes.
It never re-encodes a node, and it is not a canonical encoder.
It is JVM-first. The sealed model, the decoder, the session and the driver are
pure-JVM modules with no Android dependency and, in the case of fuaran-ui, no
runtime dependency at all. Android is where the Compose renderer and the packaged
native library live, not where the surface begins.
The modules
| Module | What it is | Needs |
|---|---|---|
fuaran-ui | The sealed tree model, the render-projection decoder, FuaranSession, the corpus coverage harness. Hand-rolled JSON reader; zero runtime dependencies. | JVM 17 bytecode, JDK 21 toolchain |
fuaran-core | The JNI shim over the Rust core's C-ABI, plus native packaging. | a built native library |
fuaran-renderer | The Jetpack Compose render floor, the Material tone bridge, and the interaction round trip. Its corpus coverage gate runs headlessly under Robolectric. | Android SDK |
fuaran-driver | The server-driven driver: fetch a tree, apply streamed ops, post interaction events back. Pure JVM. | — |
samples | An Android sample wiring a live session through the interactive renderer. | Android SDK |
FuaranSession lives in fuaran-ui, not in fuaran-core. That is the point
of the layering rather than an accident: fuaran-ui declares the
FuaranNativeBridge interface and owns the confinement; fuaran-core supplies
a concrete JNI implementation of it. A test double implementing that interface
drives the whole surface with no native library in sight. (The module table in
the repo README attributes the session to fuaran-core; the code is the
authority.)
Decoding a tree
One public entry point:
import fuaran.ui.*
val root: Node = decodeNode(json) // json: canonical tree JSON from a session
data class Node(
val id: String,
val kind: NodeKind,
val style: SemanticStyle? = null,
val state: StateBehaviour? = null,
val accessibility: Accessibility? = null,
)
NodeKind is a sealed interface, so when over it is checked at compile time:
val box = root.kind as? Box ?: return // box.role == BoxRole.Dashboard
for (child in box.children) {
when (val k = child.kind) {
is Heading -> println("${k.level} ${k.text}") // 2, LiteralText("Channel performance")
is Markdown -> println(k.text)
else -> {}
}
}
Every closed wire vocabulary is sealed the same way — NodeKind, Binding,
Action, TextSource, BoxLayout, FormFieldKind, CellKind, Shape,
ValueFormat and the rest. When you write an exhaustive when over one, leave
off the else. A new wire kind then arrives as a build error naming your file,
which is the guarantee this design exists to give; an else arm converts that
into a silent runtime fallback you find out about much later.
Two dispatch spines in the library are written that way deliberately and must
stay so: NodeKind.category() and NodeKind.discriminator() in
NodeCategory.kt, and the renderer's own RenderNodeKind.
Two Kotlin names differ from their wire spelling, because List and Map are
taken: the cases are ListNode and MapNode, and discriminator() maps them
back to "List" and "Map".
Failure is typed, never a fallback
class FuaranDecodeException(
val code: String, // INVALID_JSON | MISSING_FIELD | WRONG_TYPE | UNKNOWN_DU_CASE
// | WRONG_NODE_KIND | EMPTY_NODE_ID | LIMIT_EXCEEDED
val path: String, // "$"-rooted, e.g. "$.kind.text"
val detail: String,
) : Exception("$code at $path: $detail")
These are the canonical codes every conformant host reports at the same paths, so
a malformed tree reasons the same way here as anywhere else. An unrecognised
discriminator raises UNKNOWN_DU_CASE — there is no catch-all arm producing a
generic node.
LIMIT_EXCEEDED is deliberately distinct from INVALID_JSON: it means the
document is well formed and merely too large to walk. The reader raises two
exception types rather than one with a flag, precisely so a catch site cannot
lose the distinction by forgetting to read the flag. The limits themselves live
in WireLimits — node depth 24, tree-item depth 24, JSON depth 256, string
1 MiB, array 100 000, nodes 100 000 — and they are protocol numbers, not tuning
knobs. The first two carry the same figure and are declared separately on
purpose: a Tree's whole hierarchy lives inside ONE node, so the node counter
cannot see it at all, and either axis could move without the other.
The decoder is lenient in specific, enumerated ways
Being stricter than the language is an availability defect rather than a safe default, so the decoder accepts what a model's first guess actually looks like:
- A
Staticenvelope wrapped around a plain scalar unwraps before every scalar read — applied in one place rather than site by site. An object that is not a well-formedStaticenvelope passes through and fails normally. Never applied to array or object slots, where the envelope has a second reading. - Enum aliases go through one reader per vocabulary —
"Positive"reads asSuccess,"Danger"/"Negative"asCritical, and so on. - Field aliases are taken as a set:
headingalso acceptstitle.
What it does not do is guess. ImageAspect admits no arbitrary ratio, so
"16/9" is UNKNOWN_DU_CASE rather than a second spelling of the enum case. And
TrendPolarity.Neutral is reserved by the specification and deliberately absent,
so "Neutral" is refused — the absence is why the slot is an enum rather than an
inverted: Boolean.
What the projection deliberately drops
A closure cannot ride the wire, so slots that carry one are modelled as presence
only: ComputedBinding, NowBinding, DispatchAction, CustomValueFormat,
EditableCell and CheckboxCell are objects; StateBehaviour.hasOnError is a
Boolean where the wire had an unobservable handler; a grid column's value
closure is dropped. Host-opaque payloads stay raw JsonValue
(StaticBinding.value, a TransformBinding's source and pipeline,
Custom.props).
There is also no TreeOp decoder at all, and that is the boundary again: the
core owns apply, so a render projection never sees an op.
The safety floor
A decoded tree is untrusted input. It usually arrives from a model, and a
model will happily emit a Link whose href is javascript:….
when (val dest = link.sanitizedHref) { // NOT link.href
is SanitizedUrl.Allowed -> open(dest.url)
is SanitizedUrl.Rejected -> log("refused destination: ${dest.reason}")
SanitizedUrl.Dynamic -> { // a State / Query / Format binding
FuaranUrlPolicy.sanitize(resolvedHref)?.let { open(it) }
}
}
Accessors exist for every slot the wire hands you verbatim: Link.sanitizedHref,
Image.sanitizedSrc, each SrcSetEntry.sanitizedSrc, Media.sanitizedSrc, a
video's sanitizedPoster, each TrackEntry.sanitizedSrc, Embed.sanitizedSrc,
and Action.sanitizedNavigateRoute.
Embed.sanitizedSrc is a stricter floor, not the same one on a different
slot. An embed fetches a document and lets it EXECUTE, where everything else
here is fetch-and-display or navigate-on-a-click, so it accepts https and
nothing else — refusing http and refusing a schemeless reference, both of
which the ordinary floor accepts. On refusal the remedy is its own too: mount
the element with no source at all, never a substitute. A refused track or
poster, by contrast, is DROPPED rather than substituted, and a refused primary
src collapses to the refusal substitute; the three remedies differ because
what an element can do without the slot differs.
Dynamic is a case rather than a null on purpose: "refused" and "not knowable
yet" call for different handling, and a binding's value may not exist until the
core resolves it. That is also why the floor is a public accessor and not a
decode-time filter — a check at decode time would be examining a placeholder, and
the projection stays a faithful view of the wire.
The full policy, the per-slot remedies, and the reason this surface carries no
script-injection sink at all are in the repository README. Read them before
shipping; the media and srcSet slots deserve particular attention because they
are fetched with no user act at all.
Driving a live session
NativeBridge.loadLibrary("fuaran_jni") // or NativeBridge.load("/abs/path/fuaran_jni.dll")
FuaranSession.create(NativeBridge, seedTreeJson).use { session ->
val before = decodeNode(session.treeJson())
session.applyOp(editOpJson)
val after = decodeNode(session.treeJson())
}
Loading the native library is a caller act: the library itself never calls
System.loadLibrary, so which library, from where, and when are all yours to
decide. loadLibrary(name) resolves through java.library.path (packaged and
Android); load(absolutePath) is the desktop and test route.
FuaranSession implements TreeSession:
| Method | |
|---|---|
treeJson() | the current tree as canonical wire JSON — decodeNode it |
projectResolved() | the same tree with scalar Transform bindings folded to their values |
applyOp(opJson) | apply a canonical TreeOp; throws FuaranException on refusal |
setState(key, valueJson) | write a reactive $state.<key> slot |
setFilter(name, valueJson) | write a $filters.<name> slot |
setQuery(name, valueJson) | seed a $queries.<name> result slot |
resolvedRows(nodeId) | the resolved rows of one row-bearing node |
render() | the core's HTML render (a FuaranSession extra, not on the seam) |
close() | free the handle |
projectResolved() is what lets a decode-only surface show computed values
without carrying an evaluator: the core folds the scalar Transform slots and
hands back a tree that is byte-identical to treeJson() everywhere else.
The server-driven transport — bounded, survivable, typed
fuaran-driver's reference HttpUrlTransport reads the NDJSON op stream under
explicit OpStreamBounds: 1 MiB per op line, 64 MiB per body, and an idle budget.
A server-driven client applies whatever the server sends, so this is the one place
it reads an unbounded amount of attacker-influenced input — and the previous
reader used BufferedReader.readLine(), which has no length limit at all, so one
line with no newline in it was an OutOfMemoryError with no diagnosis. A breach
is refused by name: a typed TransportFailure.LINE_CAP_EXCEEDED /
BODY_CAP_EXCEEDED whose message carries the limit.
A quiet stream is the ordinary case, not a failure. The socket read timeout is
now a POLL INTERVAL (streamPollMillis, 5 s) that the reader absorbs and retries;
the real limit on silence is OpStreamBounds.idleBudgetMillis (2 minutes), and
only exhausting it is fatal — as IDLE_BUDGET_EXCEEDED, which names what actually
ran out. Before this, a 30 s read timeout killed a perfectly healthy session
whenever the server had nothing to say for half a minute. The gate proves it
against a fixture that genuinely goes quiet for 35 s, which is why that leg takes
at least that long.
A non-https base URL is refused with a typed INSECURE_SCHEME unless
allowInsecure = true is passed — never a silent downgrade and never a silent
upgrade. Loopback (localhost, 127.0.0.1, ::1) is exempt without the flag,
because requiring a certificate for a development fixture server is how an opt-in
becomes a permanent default.
A transport failure reaching the loop is now Fatal rather than an exception
thrown out of run. The stream is opened lazily inside the sequence, so even a
failure to connect surfaced from inside the iteration and unwound whatever thread
the loop was on. It is terminal wherever it happened: a validator reject is
survivable because the next op is still coming, and a dead transport has no next
op.
postEventApplyingReply applies the server's REPLY OPS through the same
apply-then-project path a streamed op takes, surviving a reject with the last-good
tree. Without it an interaction was a one-way message: a request/response server
could decide a click had changed the tree and had no way to say so. postEventOps
defaults to the EMPTY sequence rather than to the response body — a server
answering {"ok":true} has not implemented a reply channel, and applying that
acknowledgement as a TreeOp would turn every successful event into a reject.
resolvedRows answers in three cases, and the middle one is why:
sealed interface ResolvedRows {
data class Rows(val rows: List<JsonValue>) : ResolvedRows // possibly zero — an EMPTY state
data object NotResolved : ResolvedRows // render LOADING, never an empty table
data object NoRowSource : ResolvedRows // a caller mistake, not a data condition
}
Collapsing NotResolved into an empty list shows "no data" for "not yet" — the
quiet-wrong-looking-right failure this whole tier is built to avoid.
Single-owner confinement, and how it is enforced
The C-ABI session is single-owner: it, and every call taking it, must stay on
one thread for its whole lifetime. FuaranSession enforces that by
construction rather than by documentation. It owns a private single-threaded
executor and routes every native call through it, so the session cannot be
touched concurrently even if the wrapper is shared across threads.
Three consequences worth knowing:
- Calls are synchronous and serialised. Each method submits to the
confinement thread and blocks on the result. Eight threads hammering
treeJson()get eight serialised reads, not a race. fuaran_last_erroris per-thread, so a failingcreateand the read of its error envelope both run on that same executor thread. That is why construction goes through the executor too.- Use after close throws
IllegalStateException, checked before submission.
Teardown is leak-safe on both routes
FuaranSession.create(NativeBridge, json).use { session -> /* … */ } // AutoCloseable
close() is idempotent and frees the handle exactly once. A Cleaner backstop
reclaims a session dropped without a close, and both routes run the free on the
confinement executor — single-owner is honoured even at reclamation. The
cleaner's action holds only the handle, the bridge and the executor, never the
session itself; holding the session would make it uncollectable and the backstop
would never fire.
Prefer use { }. The Cleaner is a backstop, not a plan.
The JNI seam
fuaran-ui declares FuaranNativeBridge, an eleven-method interface where all
text crosses as UTF-8 ByteArray and the session handle is an opaque Long:
sessionNew, lastError, sessionFree, sessionRender, sessionTreeJson,
sessionProjectResolved, sessionResolvedRows, sessionApplyOp,
sessionSetState, sessionSetFilter, sessionSetQuery.
fuaran-core implements it. The native declarations are in a standalone
Java class rather than Kotlin external functions, for a practical reason:
javac -h generates the JNI header directly (JDK 21 ships no javah), and that
class deliberately depends on nothing else so header generation needs only
jni.h. The hand-written C shim implements the
Java_fuaran_core_FuaranNative_* symbols and forwards to the C-ABI.
The shim owns the buffer protocol so you never see it: every text-returning
C-ABI function returns a Rust-owned (ptr, len) pair with no trailing NUL;
the shim copies exactly len bytes into a fresh byte[] and frees the buffer,
so every array that reaches the JVM is JVM-owned.
Rendering with Compose
The Compose render floor is shipped, not a later phase.
@Composable
fun FuaranNode(node: Node, ctx: BindingContext = BindingContext.Empty)
It is a pure projection of the sealed model — no wire JSON is parsed here, decode
ran first. Its when over NodeKind is exhaustive with no else, so a new wire
kind is a compile error until its arm lands. The corpus render-coverage gate
proves it: every node fixture composes headlessly under Robolectric with zero
fallback-arm hits.
Beside it ship the Material 3 tone bridge (FuaranTheme, the tone palettes), the
accessibility projection, the trend-sentiment projection, and BindingContext
for resolving bindings and formatting cell values.
For interaction, FuaranHost wraps a live TreeSession and exposes the
re-projected tree as Compose state:
control interaction → FuaranHost.dispatch / writeBack → TreeSession.applyOp / setState
→ session re-encodes tree_json → decodeNode re-projects → tree state changes
→ Compose recomposes
No wire-JSON handling happens outside the session boundary: the host hands raw op
or value JSON to the session and decodes what comes back. ActionDispatch sorts
a decoded Action into what the session can perform (a SetState with a literal
value) and what it hands back for you to route (Navigate, Call, Notify,
AiTool, clipboard, file read). A Navigate is returned to you rather than
acted on, precisely so the destination decision stays yours.
InteractiveFuaranTree(host, ctx) renders the host's current tree; a rejected op
leaves the last good tree in place and surfaces the failure on lastError.
writeBack runs OFF the main thread, and coalesces per key
FuaranHost.writeBack returns immediately: the session call, projectResolved()
and decodeNode run on a worker and the decoded tree is published back on the
main thread. It used to do all of it inline, on whatever thread the control's
onValueChange was called from — which on Compose is the main thread — so every
keystroke in a bound text field paid a full round trip plus a whole-tree re-decode
before the next frame could compose. On a live JNI session that is worse than it
sounds: those calls hop to the core's own confining executor and BLOCK waiting for
it, so the main thread waits on another thread by construction.
Writes to one key coalesce, latest wins: while a write is in flight, a further edit to that key replaces the queued value rather than adding a round trip. A superseded value is never sent, which is safe precisely because it was superseded — no reader of that slot could have observed it. Coalescing is per key and never across keys: two slots are two facts, and dropping one because the other was edited later would lose an edit rather than an intermediate. There is no timer, so a single considered edit is written at once and only a burst collapses.
writesPending is Compose state, so a host can show progress and a test can wait
on settlement rather than on a sleep. Both executors are constructor parameters —
FuaranHost.DirectExecutor restores the synchronous behaviour, which is what the
write-back tests use so their assertions stay definite rather than becoming
two-second timeouts.
The queue's decisions live in WriteBackQueue.kt, which carries no Compose and no
android.os import so they are asserted in the plain-JVM gate — the same split,
and the same reason, as the accessibility and trend-sentiment projections.
dispatch and applyOp remain synchronous: their contracts return values
(dispatch hands back the host-routed actions), and this phase changed only the
path a keystroke takes.
Number formatting is locale-invariant
formatCellValue and formatDuration pass Locale.ROOT at every site. Without
it String.format follows the JVM's DEFAULT locale, so on a decimal-comma device
GBP 1234.50 rendered as GBP 1234,50 and 12.5% as 12,5%. That is not a
presentation preference: a formatted datum crosses the wire as text and is
compared, keyed and re-parsed downstream — a grid column's tone map is keyed on
the author's raw value — so a decimal comma is a different string that silently
stops matching, on the reader's device and nowhere near the author. The goldens
assert under Locale.GERMANY rather than under whatever the gate box is set to,
because a POSIX-locale gate cannot see the defect at all. Localising a displayed
number belongs to the wire's own Format binding with its declared locale.
BindingContext.resolveFloat is deprecated in favour of resolveDouble: a value
resolved through Float does not come back as the number the author wrote (3.7
becomes 3.700000047683716 in the slot every other reader then sees), and the
renderer narrows at the Compose boundary instead, where the loss costs a pixel.
What is pending — stated plainly
-
That vocabulary residue is CLOSED (Phase 1499). It read: form-field kinds
Color,RatingandTokensunmodelled;WriteToClipboardstill taking a bareStringwhere the corpus had moved to a text source;FileUploadcarrying neither the capture nor the destination slot;Modalcarrying nomodality. All of it is adopted, along with the rest of the vocabulary the corpus had grown around it —Action.Print/Confirm/Focus,Navigate's text-source route and closed target,Binding.Expr,Binding.Local's codec and commit target,Binding.Now's grain,NumberFormat.Sinceand.Duration, theSwitchpredicate cases and timed advance, node-levelvisible,SemanticStyle.direction,DataGrid's export and transfer keys, the print-break controls, and the chart annotation family. Every node fixture decodes and every reject vector refuses with the pinned code at the pinned path.Two of the closures were places this decoder silently ACCEPTED a malformed document rather than failing to model a slot, which is the worse half of the two: a bare string was lifted into a one-element token list, and a raw C0 control character was admitted inside a string. Both are refused now.
Modal.anchoris modelled now too, and it was the last named slot in the vocabulary this surface decoded and then dropped. It is a NodeId — the node aPopoverbelongs to (3.6.11) — and the drop was structurally invisible: two corpus vectors carry it, both decode, and this surface has no canonical encoder to compare bytes against, so a discarded member failed nothing. It surfaced only as a host unable to say which node its popover belonged to.Modal.anchoris carried on the model, refuses a non-string asWRONG_TYPEat.anchor, and is kept even on a blocking modal, where 3.6.11 calls it meaningless — dropping a member because this decoder judged it pointless would silently rewrite the author's document, the argument the inert-trendPolarityclause already makes. The corpus-decode harness carries its ownoverlayAnchorleg for the value, the absence and the refusal.What is NOT adopted is PLACEMENT, and that is 3.6.11's own division rather than a gap. Nothing in the wire names a pixel: no placement token, no offset, no flip strategy. Where a popover is put is the renderer's, and rule 7 states the floor a surface that cannot measure its anchor owes — the surface IN FLOW at the node's own document position, with no positioning of any kind. That is what the Compose arm renders today, so the anchor reaches no placement here. A Compose surface that wanted the real thing needs the host's window metrics and a popup surface, which is an arm rather than a slot; it would read the anchor the decoder now carries. Neither standing render obligation is about this slot.
The RENDER half of that adoption landed separately, and the gap is worth recording. Phase 1499 widened the model and the decoder in
:fuaran-uiand did not carry the widening into:fuaran-renderer, which needs the Android SDK and so is built only in CI — so the paragraph above was true of the decoder and silently false of the renderer, and the only thing that said so was a red:fuaran-renderer:testDebugUnitTestcompile. The three form-field arms and the widenedSwitchcase selection (a literalmatchagainst a RESOLVED selector, awhenpredicate taken on a resolvedtrueonly) are in the floor now. The lesson generalises: a vocabulary claim made from the decoder's side is not a claim about this host until the module that cannot be built on the authoring machine has been run.The three new arms are inert by construction, and each says so at its arm rather than only here:
Tokensrenders its chips in AUTHORED order with theallowFreeTextpolarity shown and not enforced (§3.6.9 obligation 4),Ratingfills pips against the resolved score and prints the resolved LEXEME beside them — deliberately notallowHalf, which governs entry and not display — andColorrenders the swatch its#rrggbbnames beside the hex as authored. None of them wires write-back, because none of them is editable: there is no chip entry, no pressable pip and no picker to return a value from, so awriteBackcall here would be dead code that merely looked like a fix. Making any of them live is renderer feature work, and it is the same line theChoiceFieldandRangeFieldarms already stand on. -
The declared upload ceilings are adopted (Phase 1548).
FileUploadcarriesmaxBytesandmaxFiles— both optional, both positive, both §7.1's signed 32-bit slot — and the positivity floor is applied at DECODE, because the format has no refined-integer type:0and below areWRONG_TYPEat the member's own path, on the same line as anSrcSetEntrywhosewidthis not positive. AmaxFilesbeside"multiple":falseis carried and not refused: §3.6.23 makes it inert rather than malformed, and a host refusing it would reject documents every other host accepts. Each of the corpus's four refusals is asserted beside a CORRECTED TWIN, on the same argument the platform-baseline wave's twin leg records — a reject vector alone cannot tell a decoder that refuses the malformed value from one that refuses the member outright.The RENDER half withholds the values, deliberately. This floor opens no file picker, so it meets no selection: §3.6.23's obligations 1–3 have nothing to act on here, and obligation 5 is vacuous. What it does instead is follow obligation 4's reasoning — a tier that cannot act on a ceiling records only THAT one was declared, never its number — so
uploadCeilingMarkersprojects two value-free booleans and the arm renders a marker line built from those alone. An upload declaring neither ceiling renders exactly as it did before the revision: the marker line is ABSENT, not empty. Both halves are pinned in the platform-neutralUploadCeilingHarness, including the one assertion that can actually fail — that no digit reaches the marker — with the Robolectric leg re-checking it against the composition, where an arm that ignored the projection and interpolated the number into its own caption would still be caught. -
Two declared render obligations remain owed and unanswered (
FileUpload/picker-always-present,Modal/aria-modal-only-when-blocking). Their STATUS is unchanged and their REASON is not: the slots they are about are modelled now, so what remains is that nobody has written the checker. They stay onconformance-residue.txtrather than being exempted, because silence is not a conformant answer.The artefact's twentieth claim arrived with Phase 1548 and is an EXEMPTION, not a third residue line.
FileUpload/ceiling-recorded-never-enforcedis about a marker attribute on the static no-script tier, and this floor emits no attribute bag and no document — the structural test theEmbedandImageexemptions already meet. Recording it as residue instead would have been the wrong answer for the wrong reason: the slots are modelled, so it is not unadopted work. -
Three specification adoption bars are open: contract cards, timed advance on a
Switch, and streamed upload. A host that has not adopted is not thereby exempt — it owes the obligation and has simply not made its answer visible. Note the DECODE half of the last two now lands here; what is unadopted is the RENDER answer, which is the half those bars are about. -
Render obligations: several asserted, several declared exempt with reasons, two owed and unanswered. The gate prints all three groups by name on every run; the repository's
CLAUDE.mdcarries the current table and the rule that decides which group a claim lands in. The exemptions are real and specific: this floor carries no playback engine, no network image loader and no browsing context, so aMedianode renders as a labelled transport tile, anImageas a placeholder box, and anEmbedas a labelled frame tile. Pulling any of those in is not a decision a decoding surface makes on your behalf. -
Chart,MapNode,MountandFragmentRefrender as informational stubs, andSparklinerenders without data. Each has a real dispatch arm — none falls through — but none paints the thing itself yet. The sparkline is a decision rather than a backlog item: the cross-host lowering phase's contract is byte-equality against SVG goldens, and this surface emits no markup to compare, so it declines and pins the placeholder instead — the arm receives noSparklineat all, so the series is unreachable from it by the type system, and a test asserts exactly that. Lowering means passing the kind, and the test goes red on that line. -
A
Treerenders FULLY EXPANDED whateverexpandedStateKeynames, because this floor holds no state store to read the open-row set from. For a tree naming no key that is the specified rendering; for one that does, it is a degradation, stated here rather than papered over with a toggle that writes nothing. Rows state their own accessible names; there is notree/treeitemrole, noaria-level/setsize/posinset, no roving tabindex, no key bindings and no selection. -
The
tooltiptrait decodes and reachesNode.tooltip, and the render floor reports it DROPPED. §3.1 says the hint is a description and must never be projected as a name; Compose has exactly one announcement channel for a node's own text, and writing to it makes the string the name. So the one available projection is the one the specification forbids, and the honest answer is the accessibility projection's existing drop-set discipline — dropped, never refused, and never silently. An embedding app with a description channel of its own can project the slot itself. -
Nothing publishes from this repository. The
io.fuaran:fuaran-ui:0.1.0coordinate is declared, but there is no publishing configuration and no publish workflow. Consume it from a local build for now. -
There is no format gate.
run.ps1runs no formatter; match the surrounding style by hand.
Verifying
pwsh ./run.ps1 # compile + corpus harness (+ the desktop JNI leg when available)
pwsh ./run.ps1 -SkipTests # compile only
pwsh ./run.ps1 -SkipBuild # re-run the harnesses against the existing jar
or drive Gradle directly for the two gates CI runs:
./gradlew :fuaran-ui:corpusCheck --console=plain
./gradlew :fuaran-renderer:testDebugUnitTest --console=plain
corpusCheck is a custom task because the corpus harness is main()-driven and
the ordinary test task would never run it. The renderer gate runs on the
debug variant only — the Compose test activity merges into the debug
manifest, not the release AAR, so the release unit-test variant has no activity
to compose into.
Every leg skips cleanly when a prerequisite is absent, which is exactly how a gate ends up passing while checking nothing — so CI asserts the corpus is present before running either gate.
The Android native leg
pwsh ./run.ps1 -Package cross-builds the Rust core per ABI (arm64-v8a,
armeabi-v7a, x86_64) with cargo ndk, cross-compiles the JNI shim against
each, verifies 16 KB page alignment on the 64-bit ABIs and that the expected
symbol is exported, then assembles a minimal AAR.
It is opt-in and skips with a named message when the NDK, cargo-ndk, the Rust
toolchain or a C compiler is absent. Two limits worth knowing: the packaged
artefacts are gitignored build output, and this path is not exercised in CI —
its bar is build, correct symbols, and alignment, since the Android libraries
cannot run without a device. Runtime behaviour is covered by the desktop JNI leg
instead.