Zachary Lowden 0dbe0a6bfe F5b: bound the model-slot App Block iframe to the viewer's viewport (#4589)
* fix(app-blocks): bound the model-slot iframe to the viewer's viewport (F5b)

`IframeHost.applyHeight` had three layers of height defense: the
isFinite/positive value guard, `manifest.iframe.maxHeight`, and
`HARD_HEIGHT_CEILING` (8000px).

The gap is layer 2 being optional. `public/schemas/app-block/v1.json` types
`iframe.maxHeight` as `["integer","null"]` ("null for unbounded") and `iframe`
declares no required fields at all, so a manifest that simply omits it is
bounded only at 8000px. A block self-reporting 3000px therefore got a 3000px
iframe inside a ~640px phone viewport, and the inline slot swallowed the page.

Adds layer 4:

    next = Math.min(next, Math.max(min, viewportHeight));

The iframe may never exceed the visible viewport height, but the manifest's
`minHeight` still wins if the viewport is somehow shorter than it. The block
scrolls internally instead, which is the intended outcome.

Two properties the shape alone does not give you:

  - the viewport is read at CALL time, never captured at mount, so a rotate or
    a browser-chrome resize cannot leave a stale bound in place;
  - the block's own STATED height is stashed in a ref and the clamp is
    re-applied on `resize`, so the bound tracks the viewport in both
    directions. Host-side only — nothing is posted back to the block, which
    is never asked to re-measure (RESIZE_IFRAME is one-way).

A viewport that cannot be measured (SSR, or an `innerHeight` that is not a
positive finite number) means DO NOT CLAMP, never clamp-to-zero: a failed
measurement degrades to the pre-existing three-layer behaviour.

Surface: the inline model-page slot only. `IframeHost` is the only
RESIZE_IFRAME consumer in `src/`; the full-page host `PageBlockHost`
(`/apps/run/<slug>`) has no RESIZE_IFRAME handling and is already
viewport-bound by its own `calc(100dvh - HEADER_HEIGHT_PX)`. It is untouched.

Tests: `IframeHostViewportHeightClamp.browser.test.tsx`, 6 cases, all at an
explicitly-set phone viewport. The harness default is 414x896, at which every
height the neighbouring IframeHost suites assert (640/700/800) is already under
the bound and the clamp never fires — so each test calls `page.viewport(...)`
first and then re-reads `window.innerHeight` to prove the value took.

  red at origin/main (bbbe837d13): 3 failed | 3 passed (6)
  green at HEAD:                   6 passed (6)

* fix(app-blocks): bound the model-slot WIDGET to the viewport, cap minHeight (F5b audit round 1)

Two measured defects from the adversarial audit of #4589, plus the fixture and
mutation-coverage gaps it found.

1) THE CLAMP BOUNDED THE IFRAME, NOT THE WIDGET.

`framed()` renders AppBlockChrome ABOVE the iframe inside one bordered box, so
clamping the iframe to the viewport still produced a `viewport + chrome` widget.
Measured at 390x640: iframe 640 (correct), frame 738, chrome 98 — a 738px widget
on a 640px screen. On `model.sidebar_top` the block sits in normal page flow on
mobile, so the viewer could never see the whole block AND anything below it.

The budget is now `viewport - overhead`, where overhead is MEASURED live as
`frame.offsetHeight - iframe.offsetHeight` — invariant to the iframe's current
height, correct if the frame ever gains another sibling, and not the pinned
single-row `CHROME_BAR_PX`, which is one theme away from wrong and has gone
stale before. Against the complete approved population (11 of 11 blocks) this is
worth 98px on every block at every viewport: at an 844px viewport it takes
overflow from 11/11 to 0/11.

The suite now asserts the FRAME's height and the document scroll height, not the
iframe's. An iframe-only assertion is exactly how the first revision passed with
the widget still overflowing.

2) A SCHEMA-LEGAL `minHeight` DEFEATED THE CLAMP ENTIRELY.

`Math.max(min, budget)` means the publisher's floor always wins, and `minHeight`
shared `maxHeight`'s ceiling of 4000. Measured at 390x640 with `{minHeight:
4000}` and the block reporting 100: appliedHeight 4000, with the clamp present —
the exact failure the clamp exists to prevent, through one field.

`minHeight` now has its OWN ceiling, `MIN_HEIGHT_MAX_CEILING = 800`, mirrored in
the canonical schema. `maxHeight` stays at 4000, and that asymmetry is
load-bearing in both directions: raising minHeight back re-opens the defect;
lowering maxHeight rejects the entire live population, all 11 of which declare
`maxHeight: 4000`. A new drift guard asserts each bound independently and drives
the validator across the minHeight boundary.

800 rejects nothing that exists: the largest live declared floor is 700.

🔴 THE CAP DOES NOT CLOSE THE RESIDUE, and neither the code nor the PR claims it
does. Live floors are 400 x1 / 600 x5 / 640 x3 / 700 x2, so at a 640px viewport
(budget 542 after chrome) 10 of 11 blocks are bound by their own floor and still
overflow by 58-158px. Shrinking that is a per-publisher change or a change to
which of floor/viewport wins — deliberately not attempted here.

Chose the validator cap over merely restating the claim after measuring the
mirror cost: civitai CI is unaffected (nothing here guards `iframe.*` until the
guard added by this commit). Downstream is time-delayed to the `main` ->
`release` cut, not to this merge, and needs two small hand edits that are NOT in
this repo — see the PR body.

ALSO FIXED, all from the audit:
  - fixture `MIN_H` was 200, identical to the source's own `?? 200` default, so a
    mutant hardcoding 200 as the floor survived the whole file. Now 160, plus a
    case that shrinks the viewport below the floor (without it the re-clamp's
    `min` argument is never exercised — every other case runs where the budget
    wins).
  - the `resize` cleanup had no coverage; deleting it survived. Added a ledger
    test asserting every handler added is removed on unmount, with a positive
    control on the ledger itself.
  - `if (reported === null) return;` is behaviourally inert on every reachable
    input. Relabelled in both the source and the suite as a TYPE NARROWING and an
    INVARIANT guard rather than coverage it cannot have.
  - the suite header named `assertViewportHeight`; the function is `setViewport`.

Nine mutants on the host clamp and three on the validator, all killed by this
suite's own assertion messages. Full matrix in the PR body.

* refactor(app-blocks): split the minHeight cap out of this PR, keep the host fix

Operator decision: ship the host-side viewport clamp on its own. The manifest
contract change (`MIN_HEIGHT_MAX_CEILING`, the schema bound and their guards)
moves to branch `zach/f5b-minheight-cap`, as a separate PR based on `main` — NOT
stacked on this one.

Why the split, recorded so it is not re-litigated:

  - The chrome-subtraction fix has ZERO cross-repo cost and takes the live
    approved population from 11 of 11 blocks overflowing to 0 of 11 at an 844px
    viewport. It should not wait behind anything.
  - The cap closes a hole NO LIVE BLOCK EXERCISES (largest declared `minHeight`
    is 700; all 11 declare `maxHeight: 4000`), costs two hand edits in repos we
    do not control, would turn civitai-app-starters' CI red until a human fixes
    it — and does not close the residue: 600/640/700 stay legal and all exceed
    the 542px budget at a 640px viewport. Weak trade for the coupling. Not
    cancelled, decoupled.

Reverted here: block-manifest-validator.service.ts, public/schemas/app-block/
v1.json, the validator's own test additions, and manifest-iframe-height.schema-
drift.test.ts.

Kept, unchanged: the chrome-overhead subtraction, `frameRef`, the live
`frame.offsetHeight - iframe.offsetHeight` measurement (including that it is
re-read inside the resize handler), the docblock's honest limit about a chrome
bar that changes height with no viewport change, the fixture `MIN_H` of 160 plus
the below-the-floor case, the unmount ledger and its positive control, the
`reported === null` relabelling as a type narrowing rather than coverage, the
`setViewport` header correction, and the IframeHostReadyTransition viewport
change at :516 — that last one is a legitimate consequence of the chrome
subtraction, not scaffolding for the cap.

Prose fixed in the same pass: two docblocks asserted the floor "is now capped at
800", which is false on this branch. They now state the residue accurately
against the UNCAPPED 4000 ceiling and point at the separate branch, so neither
file claims a bound this PR does not deliver.

* fix(app-blocks): measure the clamp's overhead with the real cascade loaded

Round-2 audit finding, and it is a measurement defect rather than a behaviour
one: the shipped clamp was right, every number written about it was not.

THE ARTIFACT. This suite did not import `@mantine/core/styles.css`. Its sibling
`AppBlockChromeResponsive.browser.test.tsx` does, and its header says, verbatim,
that without it "each computes to something meaningless while the assertions
still pass". This file measured its chrome overhead in exactly the harness that
sibling warns about, and every assertion passed anyway because they are all
self-relative.

  measured WITHOUT the stylesheet:  chrome 98, frame border 0,  overhead 98
  measured WITH it:                 chrome 31, frame border 1+1, overhead 33

The 98 also implied a border of zero, because
`border: 1px solid var(--mantine-color-default-border)` is
invalid-at-computed-value-time with no cascade. The 31 matches the sibling's own
pin exactly (22 + 8 from `py={4}` + 1 border).

WHAT THAT FALSIFIED. The published residue table said "at a 640px viewport the
budget is 542, so 10 of 11 live blocks overflow by 58-158px", and the split
commit's stated rationale leaned on "600/640/700 all exceed the 542px budget".
The real budget is 607, and the 600-tier — FIVE of the eleven live blocks —
FITS. Corrected everywhere it appears:

  vp 640, no subtraction:  11/11 overflow, worst 93px
  vp 640, with it:          5/11 overflow, worst 93px  (400- and 600-tiers fit)
  vp 844, no subtraction:  11/11 overflow, 33px each
  vp 844, with it:          0/11 overflow

NO BEHAVIOUR CHANGE. The overhead is measured live at runtime, which is exactly
what made the code right while the comment was wrong; 33 is recorded as an
OBSERVATION, never as a constant the clamp assumes.

THE SUITE NOW LOADS THE STYLESHEET, and says why that is not a contradiction of
the sibling's "siblings MUST NOT" rule: that rule is scoped to suites asserting
attributes and ARIA, which is what the shared scaffold is for. This one asserts
PIXELS, so it takes the same exception the sibling takes for itself. Browser mode
runs each file in its own iframe, so the import cannot leak sideways.

Guarded, not just fixed: `renderReady` now asserts the chrome computes to
`display: flex` — the sibling's own `styleSheetLoaded` predicate. Negative
control run: deleting the import fails all 8 cases with that message, where
before it changed nothing visible.

The two budget assertions were also wrong by the border width, because they
derived the expectation as `viewport - chrome`. They now use
`viewport - chrome - frameBorderPx()`, with the border read from
`getComputedStyle` — an INDEPENDENT observable. Deriving it as
`frameHeight() - appliedHeight()` would have been the implementation's own
arithmetic and vacuously true.

Also: `frameOverheadPx`'s `!frame || !iframe` line was documented as a real
degradation path ("either element unmounted") it cannot take. Relabelled as a
TYPE NARROWING, matching the `reported === null` early-out in the re-clamp
effect — both refs are assigned during commit, before any passive effect runs or
any postMessage can be dispatched.

Verified at this tip: clamp suite 8 passed; red at origin/main b7fd0d0685 with
only the IframeHost hunk reverted, 5 failed | 3 passed; mutants A, F, G and H
each killed by this suite's own message (H now reports a 673px widget, the real
overhead arithmetic, where it reported 738 under the artifact); typecheck 0
errors.

The commit messages already on this branch predate the correction and carry the
98; they cannot be amended after pushing, so the PR body is the corrected record.

Context worth keeping: #4601 (e92cf5fe4) added a whole `geometry` vitest project
BECAUSE the component tier omits the real cascade and lets geometry numbers be
meaningless while assertions pass. This PR hit that trap in the same repo, the
same week, in a file whose sibling documents it. Anyone measuring geometry in a
`.browser.test.tsx` should read that project's setup first.

* docs(app-blocks): correct a false reachability claim, and sweep the shape

Round-3 audit finding. Comment-only — `git diff` with comment lines stripped is
empty, verified mechanically, not by eye.

THE FALSE SENTENCE. `frameOverheadPx`'s docblock said:

  "Both refs are assigned during commit, before any passive effect runs and
   before any postMessage can be dispatched, so neither is null on any path that
   reaches this function."

That is wrong, and the file's OWN comments contain the mechanism that makes it
wrong. Confirmed from the code independently of the instrumentation that found
it (branch counter, hit count 1):

  1. the re-clamp effect's deps are the manifest min/max heights — `status` is
     deliberately absent (the `readGateStatus` note explains why), so its window
     `resize` listener survives a status change;
  2. `BLOCK_ERROR {fatal:true}` sets status 'fatal' (`setStatus` at :1790 admits
     'loading' OR 'ready'), `hostRenderDecision` returns 'collapse', the
     component `return null`s at :2826 — unmounting the frame Box and the
     iframe, so React nulls BOTH refs while the component stays mounted and the
     listener stays registered;
  3. `reportedHeightRef.current` still holds the last stated height, so the
     `reported === null` early-out does not fire;
  4. the next viewport change calls `frameOverheadPx(null, null)`.

No wrong output today: 'fatal' is terminal and the host renders null, so the
recomputed height is unobservable. The harm was what the sentence licensed —
a maintainer acting on it writes `frame!.offsetHeight` and ships a TypeError out
of a resize listener for every viewer who rotates after any block reports a
fatal error.

The TYPE-NARROWING label is KEPT — the branch is genuinely inert — and the
reachability claim is replaced with the path above, plus why deleting the check
is unsafe anyway.

The same paragraph also called the pre-layout zero-boxes case "the reachable
case". Instrumentation never reached it, and every caller runs after the block
has stated a height, which implies a laid-out iframe. Both halves were inverted.
It is now recorded as NOT ESTABLISHED either way rather than asserted — the
honest state, since I could neither reach it nor prove it unreachable.

THE SWEEP (a condition of stopping, not polish). Every reachability / invariant
/ "cannot happen" claim in this file, checked against the code:

  React-lifecycle class — the shape that failed here — 10 claims, 8 hold:
    - statusRef assigned in the render body, not an effect (holds: the only
      writer is `statusRef.current = status` at module render scope)
    - its three concurrent-rendering bullets (hold: idempotent, converges,
      written only from component state)
    - readGateStatus "the window cannot exist if the listener never needs
      replacing" (holds: four gated effects carry "`status` deliberately absent")
    - H-11 "`status` can never become 'ready' again from a terminal state"
      (holds: ALL FIVE `setStatus` writers are `current`-guarded; exactly one
      writes 'ready', and only from 'loading')
    - notifyReady "cannot revive `status`" / "cannot weaken H-11" (holds, same
      enumeration)
    - readyTransitionAppliedRef "an identity change of applyHeight would re-run
      the effect" (holds: `applyHeight` IS in that effect's dep array)
    - the re-clamp's own `reported === null` inert-on-every-reachable-input
      (holds — and note it is consistent with the correction above: the ref is
      NON-null after a collapse, which is precisely what routes execution into
      the branch this commit fixes)
    - reportedHeightRef "could only ever ratchet downward" (holds; mutant C
      demonstrated exactly that failure empirically)
    - the two corrected above.

  Derivation / single-source class — 4 claims, all hold:
    clampBlockHeight "cannot drift apart" (both call sites call it);
    expectedOrigin derived from the BASE src, not the fragmented one;
    opaqueOrigin derived from the same sandbox string; getRecentlyOpenedApps
    SSR-safety (verified: `if (!isClient()) return []`).

  SDK / third-party contract class — 7 claims, NOT verified here and said so
  rather than counted as swept: the requestId-correlation claims, the two
  "a drop cannot hang the block", "an error reply is never dropped", and the
  Mantine-unmount "it could never appear". Each names its mechanism and the SDK
  version it is about, but settling them needs blocks-react and Mantine, not
  this repo.

  21 claims examined; 12 verified true, 2 corrected (both mine, both in the one
  paragraph), 7 out of scope to settle from here.

NOT FIXED, deliberately, and recorded on the PR so they read as open rather than
absent: `expectedBudget()` sums fractional `getComputedStyle` readings against an
integer applied height, and the suite goes red if the frame gains a third child.
Both fail RED rather than falsely green, so they are in the safe direction.
2026-09-03 18:26:10 -05:00
2026-07-30 13:19:05 -05:00

Contributors Forks Stargazers Issues Apache License 2.0 Discord


Table of Contents

About the Project

Our goal with this project is to create a platform where people can share their stable diffusion models (textual inversions, hypernetworks, aesthetic gradients, VAEs, and any other crazy stuff people do to customize their AI generations), collaborate with others to improve them, and learn from each other's work. The platform allows users to create an account, upload their models, and browse models that have been shared by others. Users can also leave comments and feedback on each other's models to facilitate collaboration and knowledge sharing.

Tech Stack

We've built this project using a combination of modern web technologies, including Next.js for the frontend, TRPC for the API, and Prisma + Postgres for the database. By leveraging these tools, we've been able to create a scalable and maintainable platform that is both user-friendly and powerful.

  • DB: Prisma + Postgres
  • API: tRPC
  • Front-end + Back-end: NextJS
  • UI Kit: Mantine
  • Storage: Cloudflare

Getting Started

To get a local copy up and running, follow these steps.

Prerequisites

  • Docker, with Compose v2 (docker compose, not the retired hyphenated docker-compose). The database, Redis, MinIO, Meilisearch, ClickHouse and the mail catcher all run as containers.
  • Node.js 24.19.0. Not "20 or later" — package.json declares engines.node: ">=24.0.0 <25". The exact version lives in .nvmrc; CI installs that file's version and the production image is built on the same one, so nvm use (or any tool that reads .nvmrc) is the right way to get it. Note that nothing stops you: pnpm install only prints WARN Unsupported engine and carries on, so the wrong major surfaces later as odd test failures rather than as a refusal at install time.
  • pnpm. This repo is pnpm-only, and this one is enforced — npm install exits 1 via the preinstall only-allow pnpm hook. corepack enable will pick up the packageManager field for you.
  • Make (optional).

Installation

Standard setup

git clone https://github.com/civitai/civitai.git
cd civitai
nvm use                                              # reads .nvmrc -> 24.19.0
corepack enable
git submodule update --init event-engine-common
cp .env-example .env.development
docker compose -f docker-compose.base.yml up -d
pnpm install
pnpm dev

Optional: Nix flake

Optional, and not the supported default. The standard setup above is what the project expects and what CI builds; nothing in the repo requires Nix, and you can ignore this section entirely. It exists because NixOS cannot use Prisma's published engines (there is no linux-nixos build), so a flake is the practical way to work on this repo there. If you are not on NixOS and not already a flakes user, skip it.

The flake owns the toolchain, so you do not install Node or pnpm yourself:

git clone https://github.com/civitai/civitai.git
cd civitai
nix run .#dev

That single command checks Docker is usable, checks out the event-engine-common submodule, creates .env.development from .env-example if you do not already have one, starts the container stack, waits for Postgres, runs pnpm install, and then starts the dev server on http://localhost:3000. Every step is idempotent — it is safe to re-run in a checkout that already works, and it will not overwrite your .env.development or touch your data.

Useful variants:

nix run .#dev -- --no-start   # bootstrap only, leave the services running
nix run .#dev -- --full       # also start the signals/buzz containers (see below)
nix run .#doctor              # check the flake's pins against the repo
nix flake check               # the same checks, plus their own self-test

For an interactive shell with the same toolchain, use nix develop, or copy .envrc.example to .envrc and run direnv allow to get it automatically on cd.

With devcontainers

⚠️ Known out of step: .devcontainer/public/docker-compose.yml pins mcr.microsoft.com/devcontainers/typescript-node:1-22, i.e. Node 22, which is outside this repo's engines.node range. pnpm install will warn rather than stop, so the container comes up and then misbehaves in ways that look like your branch. There is no 1-24 tag (the template major moved on); 3-24 is the closest equivalent. Not changed here because it could not be exercised.

⚠️ Important Warning for Windows Users: Either clone this repo onto a WSL volume, or use the "clone repository in named container volume" command. Otherwise, you will see performance issues.

  • Open the directory up in your IDE of choice
    • VS Code should prompt you to "Open in container"
      • If not, you may need to manually run Dev Containers: Open Folder in Container
    • For other IDEs, you may need to open the .devcontainer/devcontainer.json file, and click "Create devcontainer and mount sources"
    • Note: this may take some time to run initially
  • Run make run

The signals and buzz services

docker-compose.base.yml holds everything a contributor needs (and is also what nix run .#dev starts). The extra services in docker-compose.yml (signals, buzz) come from private ghcr.io images, so they only work for internal members:

  • create a GitHub personal access token with read:packages
  • set it as CR_PAT
  • echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
  • then docker compose up -d (or, with the flake, nix run .#dev -- --full)

After the first start

  1. Edit .env.development. Most defaults work out of the box; these do not:
    • S3 upload credentials. Open the MinIO console at http://localhost:9001 (username and password both minioadmin) — note it is port 9001, port 9000 is the S3 API itself — go to "Access Keys", click "Create Access Key", and copy the key and secret into S3_UPLOAD_KEY / S3_UPLOAD_SECRET and S3_IMAGE_UPLOAD_KEY / S3_IMAGE_UPLOAD_SECRET.
    • WEBHOOK_TOKEN — any random string; it authenticates requests to the webhook endpoint.
    • EMAIL_USER, EMAIL_PASS, and EMAIL_FROM (a valid email format) — any values, but they must be set for user registration to work.
  2. On an empty database, populate it. These are slow and destructive, which is why no bootstrap runs them for you:
    make run-migrations
    make reseed
    
  3. Visit http://localhost:3000.

Please report any issues with these commands to us on discord.

* Note that account creation will run emails through maildev, which can be accessed at http://localhost:1080.

Altering your user

  • First, create an account for yourself as you normally would through the UI.
  • You may wish to set yourself up as a moderator. To do so:
    • Use a database editor (like DataGrip) or connect directly to the DB (PGPASSWORD=postgres psql -h localhost -p 15432 -U postgres civitai)
    • Find your user (by email or username), and change isModerator to true

Known limitations

Services that require external input will currently not work locally. These include:

  • Orchestration (Generation, Training)
  • Signals (Chat, Notifications, other real-time updates)
  • Buzz

Contributing

Any contributions you make are greatly appreciated.

If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!

  1. Fork the repository to your own GitHub account.
  2. Create a new branch for your changes.
  3. Make your changes to the code.
  4. Commit your changes and push the branch to your forked repository.
  5. Open a pull request on our repository.

If you would like to be more involved, consider joining the Community Development Team! For more information on the team as well as how to join, see Calling All Developers: Join Civitai's Community Development Team.

Data Migrations

Over the course of development, you may need to change the structure of the database. To do this:

  1. Make your changes to the packages/civitai-db-schema/prisma/schema.full.prisma file. Not schema.prisma — that one is gitignored and regenerated from schema.full.prisma by scripts/generate-slim-schema.js on every pnpm run db:generate, so edits to it are silently overwritten.
  2. Run pnpm run db:migrate:empty "brief description here". This creates packages/civitai-db-schema/prisma/migrations/YYYYMMDDHHmmss_brief_description_here/migration.sql for you, in the one directory Prisma reads. To create it by hand instead, use that same path — not the prisma/migrations directory at the repo root, which predates the monorepo layout and is no longer read.
  3. Put your sql changes in the generated migration.sql
    • These are usually simple sql commands like ALTER TABLE ...
  4. Run make run-migrations and make gen-prisma
  5. If you are adding/changing a column or table, please try to keep the gen_seed.ts file up to date with these changes.

Sponsors

Support this project by becoming a sponsor. Your logo will show up here with a link to your website.

License

Apache License 2.0 - Please have a look at the LICENSE for more details.

S
Description
clickup: Interact with ClickUp tasks and documents - get task details, view comments, create and manage tasks, create and edit docs. Use when working with ClickUp…; quick-mockups: Create multiple UI design mockups in parallel. Use when asked to create mockups, wireframes, or design variations for a feature. Creates HTML files using…
Readme 362 MiB
Languages
TypeScript 93.3%
JavaScript 2.6%
Svelte 2.5%
PLpgSQL 0.5%
SCSS 0.4%
Other 0.6%