Files
civitai__civitai/public
Zachary Lowden d596565541 feat(apps): let an app paint its own boot state (manifest bootSkeleton) (#4563)
* feat(apps): let an app paint its own boot state (manifest bootSkeleton)

A host-drawn loading state can never hydrate without a visible change: it
renders in the HOST's theme with generic geometry, then cross-fades to the
app's own layout. Only a skeleton shipped by the app can be theme- and
geometry-identical to the thing that replaces it.

The blocker was that the host makes such a skeleton invisible. THREE things
hid it, and standing down any two of them still leaves the app's boot state
unseen:

  1. the branded veil — opaque, `inset: 0`, until BLOCK_READY;
  2. the iframe itself — `opacity: 0` until BLOCK_READY;
  3. the reveal `translateY(8px)` settle — which is itself a layout shift, at
     the exact moment an app-painted skeleton exists to avoid one.

`manifest.bootSkeleton: true` stands down all three for that app. Everything
else is unchanged, and NOT declaring it stays the safe default: no veil plus
an empty `#root` is a blank white iframe for 300-1200ms, which is worse than
what the veil was doing. The host's own skeleton remains the fallback for
every app that does not opt in.

`pointerEvents` is deliberately NOT opted out — a skeleton is not
interactive, and the block must stay inert until it holds a token.

Read from the APPROVED manifest snapshot and coerced with a strict
`=== true`, so publisher JSON carrying "false"/0/{} cannot switch a host
behaviour on. A false declaration is cosmetic and scoped to that app's own
page; the platform build will refuse a build that declares it with an empty
`#root` (talos-infra, separately).

This is inert until an app declares it — no app does yet.

Tests: three guards, one per behaviour so a failure names WHICH regressed,
plus the safe default. All mutation-checked: suppressing the veil-skip kills
only the veil test, restoring `opacity: 0` kills only the visibility test,
restoring the settle kills only the transform test, and flipping the prop's
default to true kills nine. 62/62 across LaunchReveal + PageBlockHost +
AutoRetry, 49/49 across the four schema-drift/gate unit suites, typecheck 0,
prettier clean, eslint unchanged.

NOT included, deliberately: the block is not on
BLOCK_INIT_FRAGMENT_ALLOWLIST. That gate's own rule is that an entry is added
only once the block is known to ship an SDK that decodes the fragment, and
generate-from-model still pins @civitai/app-sdk ^0.7.0. It ships with the
block's SDK bump instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2wcms7C2Lg9GrHPmBz8oH

* fix(apps): audit round 1 — bootSkeleton was unusable and over-claimed

Eight findings. The two that mattered most were not in the rendering logic.

F1 — A GUARANTEE THAT DOES NOT EXIST, shipped in the PUBLIC schema.
Three places asserted, in the present tense, that "the platform build fails a
build that declares this with an empty #root", and PageBlockHost rested a
safety argument on it ("so the claim cannot rot into that"). Nothing
validates the declaration anywhere: no strict manifest schema (the root has
no additionalProperties:false), no build check, nothing in submit or approve.
The check is planned in another repo and the commit said so in the FUTURE
tense; the shipped copies did not. Rewritten to say what is true — nothing
validates it yet, and until the build check lands the only thing between a
false declaration and a blank run page is the author looking at their app.

F2 — NEITHER THE AUTHOR NOR THE MODERATOR COULD SEE THE FEATURE.
The dev tunnel (/apps/dev/<blockId>) and the moderator review preview both
mount PageBlockHost and passed no bootSkeleton. The prop was optional with a
`= false` default, so both silently rendered the veil: an author would set
the flag, open the route that documents itself as "prod-fidelity", see no
change and conclude it did nothing — and a moderator would approve against a
presentation users will not get. Combined with F1 there was NO detection path
at any stage.

  - the prop is now REQUIRED, the same shape `surface` already uses
    deliberately, so a new host is a type error rather than a silent default;
  - DevPageBlockResolution carries bootSkeleton and the dev route passes it,
    so the author's own surface matches production;
  - the review preview passes an explicit `false` with the gap documented at
    the call site — plumbing it needs a field on the mint output, which this
    change does not add.

F3 — RETRY GAVE A bootSkeleton APP A BLANK FRAME AND NO FEEDBACK. `key=
{reloadNonce}` remounts the iframe, so on a retry the app's document is being
re-fetched and its skeleton is NOT on screen — and "Retrying …" lives inside
the veil this feature suppresses. Measured: veil absent, frame blank, the
string "Retrying" nowhere in the document, for the manual attempt and every
automatic one. The veil is now re-enabled when `reloadNonce > 0`; the opt-out
is about FIRST boot.

F4 — THE ONLY LOADING ANNOUNCEMENT DISAPPEARED. The veil is the host's sole
role="status" + aria-busy region. Suppressing it left ZERO elements matching
[role="status"],[aria-busy],[role="alert"], and the host cannot borrow the
app's because that boot state is cross-origin. The frame is now aria-busy
while it boots, and only while the veil is absent.

F6 — THE COERCION CLAIM WAS UNTESTED IN THE TIER THAT GATES. All the new
guards were *.browser.test.tsx, which lint.yml does not run. The unit suite
that already pins every other PageBlockSsr field's sourcing had no case for
this one, so "publisher JSON carrying "false"/0/{} cannot switch a host
behaviour on" was asserted and unverified where CI could see it. Three cases
added there, including a truthy-non-boolean sweep.

F7 — the schema is byte-mirrored into civitai-app-starters and the Go cli. A
json round-trip had escaped 18 literal em-dashes to \\u2014 (not prettier —
verified), turning their next re-vendor into a 19-line diff. Re-done as a
minimal textual insert: 6 insertions, 0 deletions.

F5/F8 — the schema description now names the precondition it was silently
assuming (host theme needs the app to be enabled for the BLOCK_INIT fragment
and to read it before first paint; otherwise the theme is a guess), and the
widened pre-handshake paint window is named where the opt-out is documented.

Verified: retry-veil, aria-busy, aria-busy-never-clears and truthy-coercion
mutants each kill exactly their own guard. Component suite 207 files / 2315
tests / 0 failed; resolver unit suite 15/15; typecheck 0; prettier clean on
every file this branch touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2wcms7C2Lg9GrHPmBz8oH

* fix(apps): audit round 2 — close the detection loop properly, both surfaces

Round 1 claimed to open a detection path for `bootSkeleton`. It opened one
only for the live run page — that is, for the first user. Both other surfaces
still could not show the feature, and at both the manifest was already loaded
and simply not projected.

F-A — the DEV TUNNEL. The ephemeral path hardcoded `false`, and its comment
said "no stored manifest on this path", which is wrong for half the cases it
covers: `pending.manifest` is selected and is already read for `scopes` a few
lines below. AppBlock rows exist only after approve, so the ephemeral path is
the ONLY one an author hits before shipping — the exact loop the dev tunnel
exists for. Now read, with the same strict `=== true`.

F-B — the MODERATOR REVIEW PREVIEW. `mintReviewBlockToken` loads `row.manifest`
and already projects name/sandbox/scopes from it; the key was simply omitted
from the result type and the return. Now carried, so a moderator reviews the
presentation the approved app will actually have rather than the pre-feature
one. Round 1 documented this as needing plumbing "on the mint output" — it
needed one field on a manifest already in hand.

F-C — the `aria-busy` comment asserted a mutual exclusion the code did not
implement. `bootSkeleton && !isReady` was true during a retry too, when the
veil (role="status") is back, so the page had TWO busy regions — measured at 2
by the audit. Now `bootSkeleton && reloadNonce === 0 && !isReady`, and pinned:
without the term the new assertion sees 2.

F-D — the round-1 self-report "6 insertions, 0 deletions" was FALSE: the file
was 11/10 across 3 hunks, because a `prettier --write` had also reflowed
`category.enum` and `entry.allOf[1]`. Re-done from base with only the key
inserted — now genuinely 6/0 in ONE hunk, which is the whole point for two
repos that vendor this file byte-identically. The file stays prettier-dirty,
as it is on `main`; formatting it is what caused the churn.

F-E/F-F — the round-1 fix added a SECOND coercion site (the dev resolver) and
left it untested, and the retry-veil guard covered only the manual button.
Three cases added to `block-registry.resolve-dev.test.ts` (unit tier, the one
CI runs) including a truthy-non-boolean sweep and a brand-new-slug control,
plus an automatic-retry case.

F-G and claim 9's missing half — the veil comment still said "gated purely on
status === 'loading'"; and the widened pre-handshake paint window, which round
1 said it had named, appeared nowhere in code. Both written down: an app can
now put publisher-controlled pixels on screen from mount, before the host
holds a token — a change to timing, not capability, since it can already paint
freely once ready. The "themed boot state" phrasing in both prop docs now
carries the fragment precondition the schema already had.

Verified: hardcoding the dev value back, relaxing its coercion to truthy, and
dropping the `reloadNonce === 0` term each kill their own guard — the last one
SURVIVED until the busy-region assertion was added, so the fix alone was
unguarded. Component 207 files / 2316 tests / 0 failed; resolver units 44/44;
typecheck 0; prettier clean on every .ts/.tsx this branch touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2wcms7C2Lg9GrHPmBz8oH

* fix(apps): audit round 3 — cover the review-preview plumbing, drop a third overclaim

Round 3 found that round 2's LARGEST payload change shipped with zero
coverage. Two independent mutants survived a fully green gate:

  - the mint projection `bootSkeleton: manifestBootSkeleton` -> `false`
    (51 unit tests passed),
  - the host prop -> `bootSkeleton={false}`, an exact revert to the
    pre-round-2 state (12 browser tests passed).

Cause: `ReviewBlockPreviewHost.browser.test.tsx` stubs `PageBlockHost` and
surfaces only two props. The auditor localised it properly rather than
assuming — breaking `trustTier` in the same file also survived, so the
blindness is the stub's prop surface, not a broken runner.

The asymmetry is the point: the SSR path got 3 unit cases in round 1, the dev
path got 3 in round 2, the review path got 0 — while round 2's own commit
message applied "a fix no mutant kills is unguarded" to a different finding
in the same commit.

  - the stub now surfaces `data-boot-skeleton`, as it already does for two
    other props, with a negative AND a discriminating positive case (the mint
    mock can now declare the flag, so the value has to travel rather than be
    a constant);
  - `publish-request.mintReviewToken.test.ts` gains the projection case and a
    truthy-non-boolean sweep — the strictest place for that coercion, since
    the manifest is UNREVIEWED at mint time.

Also: a THIRD declaration still promised "themed". `BlockManifest.bootSkeleton`
in types.ts — the type an app author reads while writing a manifest, so the
likeliest site for the overclaim to be acted on — now carries the fragment
precondition the schema and both prop docs already had.

Verified: all three mutants above now die, each by its own named assertion.
Round 3 also refuted one of my own worries by measurement — `reloadNonce`
never resetting does NOT strip `aria-busy` after a retry, because
`overlayMounted` is forced true on every entry to `loading`, so the veil
remains the single busy region. Units 55/55 across the three resolver/mint
suites, typecheck 0, prettier clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2wcms7C2Lg9GrHPmBz8oH

* fix(apps): audit round 4 — correct three false claims, pin the forced trust tier

Round 4 found no behavioural defect: the whole of round 3's payload was one
doc comment. What it did find was that round 3's commit message asserted three
things that were not true.

  - "prettier clean" — FALSE at head. Round 3's own added lines were the only
    prettier violation in that file (base blob clean, head blob dirty, same
    .prettierrc). It did not gate CI, which makes Prettier blocking for ADDED
    files only, and all three were modified — so nothing caught it but a
    measurement.
  - "all three mutants now die" — TWO of three. The `trustTier` mutant round 3
    named had been used as a LOCALISING CONTROL, not fixed, and the message
    reported it as killed.
  - "the third and last" unqualified "themed" — off by one. A fourth survived
    in PageBlockHost's opt-out comment.

And the sentence justifying the types.ts change was itself wrong: it called
`BlockManifest` "the type an author reads while writing a manifest". Enumerated
— it has two consumers, both in this repo, is in no published package, and is
neither the SDK type nor the canonical schema. The type an external author
actually reads is `public/schemas/app-block/v1.json`, which already carried the
qualification. The fix was right; the reason given for it was not, and a wrong
"this is the author-facing site" is exactly the kind of line that later gets
cited as coverage.

Also closed, though it is PRE-EXISTING debt this PR did not create:
`trustTier="unverified"` on the review preview was unpinned. It is defence
layer 2 in that file's own words — the force is what makes `intersectSandbox`
drop `allow-same-origin`, so the review iframe runs at an opaque origin rather
than the moderator's. Flipping it to `internal` type-checks and SURVIVED 1151
tests across every Apps browser suite. Pinned now, and the escalation mutant
dies. Closed here rather than filed because the stub was already open on the
desk for this round's other fix.

The fixture's `bootSkeleton` field was dead — the mint mock overwrote it
unconditionally, so setting it there passed silently. Now `?? base.bootSkeleton`.

🔴 THIS ENDS THE LADDER, and the reason is worth writing down rather than
leaving implicit. Round 4's payload delta was 16 lines, all inside one JSDoc
block — zero behavioural lines. Every finding it returned was about the
ladder's own scaffolding or about prose describing a fix; the two ranked
highest were a false sentence in a commit message and a formatting violation.
That is the shape the attribution rule exists to catch: the rounds would keep
finding real things forever, because the payload has become prose. Not
fixed, and deliberately so: nothing — the four findings are all addressed
here.

Verified: the trust-tier escalation mutant now dies (it survived 1151 tests
before), typecheck 0, prettier clean on all three changed files, 29/29 across
the two affected suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2wcms7C2Lg9GrHPmBz8oH

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 12:55:38 -05:00
..