> ## Documentation Index
> Fetch the complete documentation index at: https://docs.frankenpress.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Designer flow

> Design FSE block themes locally, capture state via `fp snapshot`, ship via the same image-build path as code.

FrankenPress sites separate **design** (what a designer builds) from
**content** (what an editor publishes). Design state ships through git

* image releases. Content stays live on the production site.

This page walks the designer half of that line: edit FSE block
templates locally in Site Editor, capture into a snapshot, promote
through git the same way code changes are.

<Note>
  This page documents the default **DB-row mode** — design state is
  captured as `wp_template / wp_template_part / wp_global_styles` DB
  rows in the snapshot's `templates.json` sidecar and upserted on
  apply.
  An opt-in alternative — [theme-files mode](/designer-flow-theme-files) —
  saves design state to theme files in the site repo instead. Same
  goal, different storage shape. Pick one per tenant.
</Note>

## What's in scope (ships in the snapshot)

| Surface                                                                                                              | Captured how                                                                                                                         |
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `wp_template`, `wp_template_part`, `wp_global_styles`, `wp_navigation`                                               | `templates.json` sidecar, **upsert** by `post_name + post_type` on apply (designer iteration on existing rows propagates)            |
| Site-identity options (`blogname`, `blogdescription`, `show_on_front`, `page_on_front`, `permalink_structure`, etc.) | `options.json` sidecar, `update_option` on apply                                                                                     |
| `theme_mods_<active-stylesheet>`                                                                                     | Same `options.json` sidecar                                                                                                          |
| Designer-asset attachments (logos, header/footer imagery)                                                            | `attachments.json` + `uploads/<rel-path>/...` binaries — see [How attachments are discovered](#how-attachments-are-discovered) below |

## What's NOT in scope (never ships, lives only on the live site)

* `page` — pages created/edited live by editors
* `post` — posts created/edited live by editors
* `attachment` rows that aren't referenced by design — editor-uploaded media stays put
* `comment` / `user` / `wc_order` / activity log — structurally outside any adapter's scope; can never enter or be modified by an apply

This is a deliberate architectural line. A designer building the site
locally and an editor managing content on production should never
overwrite each other's work. Snapshots ship design; content stays
live.

## How attachments are discovered

The capturer ships an attachment **only if it's referenced by design
state**. Two reference paths, both walked at capture time:

1. **Option references** — for each option declared in
   `option_keys_attachment_refs` (the bundled `Fse` adapter declares
   `site_logo`, `site_icon`, `custom_logo`), the option's value is
   read as an attachment ID and that attachment is captured.
2. **Block references** — every captured `wp_template`,
   `wp_template_part`, `wp_global_styles`, and `wp_navigation`'s
   `post_content` is parsed via `parse_blocks()`. Every integer attr
   value is collected; each one that resolves to an `attachment` post
   is captured.

For each captured attachment:

* The post fields + key postmeta (`_wp_attached_file`,
  `_wp_attachment_metadata`) go into `attachments.json` keyed by
  relative file path
* Every binary file (the original + every WP-generated size variant)
  is bundled into `snapshot/uploads/<relative-path>`

Attachments uploaded locally but **not** referenced by any design
state are skipped silently — they're treated as content the editor
will manage live.

## Designer-as-admin

Designers operate as full WordPress admins on the local docker-compose
stack. The safety boundary is **the immutable image + in-cluster
lockdown**, not WP roles. In-cluster, `DISALLOW_FILE_EDIT` and
`DISALLOW_FILE_MODS` block theme edits and plugin installs for
everyone, admins included. Locally, designers have full latitude.

The controlled promotion path is the **PR + image-build + gitops bump**,
not a WordPress capability check.

## The workflow

<Note>
  **Prerequisite — install `fp` once.** The capture step uses the host-side
  [`fp`](https://github.com/frankenpress/fp) CLI, which wraps the mu-plugin's
  `wp fp snapshot` and extracts the result. Install via the FrankenPress
  Homebrew tap:

  ```bash theme={null}
  brew install frankenpress/tap/fp
  ```

  No-brew fallbacks: `go install github.com/frankenpress/fp/cmd/fp@latest`,
  or grab a binary from [Releases](https://github.com/frankenpress/fp/releases).
  Verify with `fp version`.
</Note>

<Steps>
  <Step title="Onboard with fp init">
    ```bash theme={null}
    git clone git@github.com:<your-org>/<your-site>.git
    cd <your-site>
    fp init
    ```

    One command does it all: scaffolds `.env`, runs `composer install`
    via docker, brings the stack up (`docker compose up -d --wait`),
    installs WordPress, and applies the most recent committed snapshot
    (if any) so the designer's previous work is restored — assets and
    all.

    Defaults: admin `admin` / admin pass `admin`, URL `http://localhost:8080`,
    title "FrankenPress site". Override via `[init]` in `frankenpress.toml`.

    Re-run `fp init` after `docker compose down -v` to recover from a
    wiped local stack. Same command, same result. Idempotent — every
    step no-ops if the state is already correct.

    **Lower-level alternative**: if you'd rather drive it manually,
    `make setup && make up && make wp ARGS="core install ..."` is the
    equivalent sequence.
  </Step>

  <Step title="Design in Site Editor">
    Log in at `http://localhost:8080/wp/wp-admin/`. Open
    **Appearance → Editor**. Edit templates, template parts, global
    styles, and navigation. Upload media to use as **site logos**,
    **header / footer images**, or **inline block images** —
    anything referenced by your design will be captured.

    Settings to typically also touch:

    * **Settings → General**: Site Title, Tagline
    * **Settings → Reading**: Front-page configuration
    * **Site Editor → Styles**: Site logo, accent colours, typography
  </Step>

  <Step title="Capture the snapshot">
    From the site repo root (or any subdirectory — `fp` walks up to find
    `frankenpress.toml` or `composer.json`):

    ```bash theme={null}
    fp snapshot
    ```

    `fp` prompts twice:

    * **slug** — defaults to a UTC timestamp (`YYYY-MM-DDTHH-MM-SSZ`),
      filename-safe and lex-sortable so `ls web/imports/` is naturally
      chronological. Enter accepts the timestamp; type a name to attach a
      milestone marker (e.g. `pre-2026-rebrand`) instead.
    * **note** — opens `$EDITOR` if set + interactive; otherwise reads a
      single line from stdin. Empty notes are allowed.

    Both can be supplied as flags (`--slug <s>` / `--note <s>` /
    `--note-file <path>`) to skip prompting. `--quick` skips everything
    and does not update `.fp/state.json` — useful for ad-hoc / scripted
    captures.

    Writes `web/imports/<slug>/`:

    | File                     | Contains                                                                     |
    | ------------------------ | ---------------------------------------------------------------------------- |
    | `manifest.{yaml,json}`   | `fp.snapshot/v4` schema, ID, source URL, source theme, scope, content hashes |
    | `templates.json`         | Owned-CPT entries (templates, template parts, global styles, navigation)     |
    | `options.json`           | Scoped `wp_options` + `theme_mods`                                           |
    | `attachments.json`       | Designer-asset attachment posts (option-ref + block-ref discovery)           |
    | `uploads/<rel-path>/...` | Binary files for the captured attachments                                    |
    | `content.xml.gz`         | WXR — typically empty for FSE sites (page/post are out of scope)             |
    | `uploads-manifest.txt`   | sha256 + size audit log of the entire uploads dir                            |

    Re-running with the same explicit `--slug=<name>` pre-cleans the host
    target then re-captures cleanly (the iterate-on-a-milestone path). The
    default timestamp slug is fresh on every run, so accidental overwrite
    isn't possible unless two captures land in the same UTC second — in
    that case `fp` refuses and asks you to wait a moment, rather than
    silently wiping the prior dir. If the target directory has uncommitted
    git changes, `fp` will ask before overwriting — pass `--quick` to
    bypass that guard.

    Behind the scenes `fp` shells `docker compose exec site wp fp snapshot
            ...` and `docker cp` to extract; if the stack isn't running it prints
    a `make up` hint and exits.
  </Step>

  <Step title="Commit and tag">
    ```bash theme={null}
    git checkout -b feat/snapshot-<short-description>
    git add web/imports/<slug>/
    git commit -m "Snapshot: <description>"
    git push -u origin feat/snapshot-<short-description>
    # → PR → review → merge → git tag vX.Y.Z → CI builds + pushes
    #   ghcr.io/<your-org>/<your-site>:vX.Y.Z
    ```

    **No `git rm`** of the previous snapshot dir — older
    `web/imports/<timestamp>/` directories are intentional history.
    The chart picks the snapshot with the highest `manifest.created`
    at deploy time, so accumulating dirs is fine.
  </Step>

  <Step title="Deploy">
    Bump `imageTag` in your GitOps overlay (or `helm upgrade --set
            image.tag=vX.Y.Z` for vanilla Helm). The chart's install Job
    (charts ≥ v0.12.0) picks the snapshot with the highest
    `manifest.created` and runs `wp fp apply --snapshot-dir=<that one>`
    on every helm upgrade. Idempotent.
  </Step>
</Steps>

## What apply does

The chart's install Job (charts ≥ v0.12.0) scans every subdirectory of
`/app/web/imports/` whose `manifest.json` exists, picks the one with
the highest `created` UTC timestamp, and runs `wp fp apply` against
it. Older snapshot dirs are skipped (logged as "skipped — older"),
not deleted — they stay baked in the image as history. Per applied
snapshot, in order:

1. **Schema check** — reject if not `fp.snapshot/v4`.
2. **Idempotency check** — compare `manifest.id` and `wxr_sha256`
   against the `fp_snapshot_applied_ref` / `_sha256` options. Match
   → skip.
3. **WXR import** — additive INSERT-only. For Fse-adapter sites this
   is a no-op (post\_types\_additive is empty).
4. **Apply attachments** — upsert each `attachments.json` entry by
   `_wp_attached_file` (the stable cross-environment key). Copy each
   `snapshot/uploads/<rel>` to `wp_upload_dir()/<rel>`. With
   `S3UploadsBootstrap` active, that's an `s3://` stream wrapper
   write — files land directly in S3. Returns a `captured_id →
   local_id` remap.
5. **Apply owned-posts** — upsert each `templates.json` entry by
   `post_name + post_type`. Before upsert, rewrite
   `"id":<captured>` JSON block-attrs and `wp-image-<captured>` CSS
   classes in `post_content` to the local IDs from step 4's remap.
6. **Apply options** — `update_option` for each. For options listed
   in `option_keys_attachment_refs`, replace the captured ID value
   with the local ID from step 4's remap. `theme_mods_<stylesheet>`
   written directly.
7. **URL retarget**, two passes:
   * `<source>/app/uploads → wp_get_upload_dir()['baseurl']`
     (rewrites block-attr `url` strings + `<img src>` URLs to point
     at the S3 bucket)
   * `<source> → <target_url>` (catches non-upload host references)
8. **Adapter `post_apply()`** — `Fse` sweeps `wp_global_styles`
   orphans whose stylesheet metadata doesn't match the current
   `get_stylesheet()` (theme-switch cleanup).
9. **Markers stamped** — subsequent re-applies short-circuit at
   step 2.

## Iteration semantics

Different rows have different update behaviour on apply:

| Row type                                                         | Behaviour                                                                                           |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Owned-CPT (templates, template parts, global styles, navigation) | **UPSERT by slug** — designer edits propagate cleanly                                               |
| Options + theme\_mods                                            | **UPSERT by key** — values overwrite                                                                |
| Attachments                                                      | **UPSERT by `_wp_attached_file`** — same source file = same target post; new files create new posts |
| Pages, posts, comments, users, etc.                              | **Never touched** — out of scope entirely                                                           |

So designer iteration on an existing template (edit text, change a
color, swap the footer image) flows through every subsequent deploy.
Editor-managed content (a customer adds a blog post on production)
is never touched by the designer's pipeline.

## Safety properties

* **No DROPs of UGC.** User-generated content (orders, comments,
  user accounts, custom CPTs not in scope) is structurally
  inaccessible — it's not in any adapter's scope, so no codepath in
  the apply can reach it.
* **`wp_global_styles` orphan cleanup is scoped.** The Fse adapter
  deletes only `wp_global_styles` rows for stylesheets other than
  the current — that's snapshot-managed metadata, not UGC.
* **Designer-asset attachments are discovered by reference.**
  Uploads not referenced by site\_logo, site\_icon, custom\_logo, or
  inline block markup stay put.

## Common questions

<AccordionGroup>
  <Accordion title="What about a customer admin who edits a template via Site Editor on production?" icon="circle-question">
    Their edit lands in the production DB's `wp_template` row. The
    next designer push re-upserts that template from the snapshot,
    overwriting the customer edit. **Templates are designer state by
    convention** — admins editing them on production should expect
    their changes to be reverted on the next designer release.

    Pages, posts, customer-uploaded media — those are content; the
    designer pipeline doesn't touch them.
  </Accordion>

  <Accordion title="Can I capture a snapshot from production?" icon="circle-question">
    Technically yes (`wp fp snapshot` runs anywhere WP-CLI runs), but
    production captures will pick up customer-edited templates +
    customer-uploaded media you may not want in the image. **Local
    capture from a designer-controlled stack is the canonical flow.**
  </Accordion>

  <Accordion title="What about classic (non-FSE) themes?" icon="palette">
    The shipping `Fse` adapter detects via `wp_is_block_theme()`;
    classic themes won't trigger it. `wp fp snapshot` errors out
    with "no snapshot adapter detected." A site that genuinely needs
    a classic-theme adapter can register one against the existing
    `AdapterInterface` — but the slim mu-plugin baseline ships only
    Fse, and adding a second registered adapter requires explicit
    user approval per the mu-plugin slim-by-default contract.
  </Accordion>

  <Accordion title="Why are uploaded images in my Media Library NOT shipping?" icon="image">
    The capturer only ships attachments **referenced by design state**
    — site logos (via `site_logo` / `site_icon` / `custom_logo`
    options) and inline `wp:image` block references inside captured
    templates (an image block carries an `"id":N` attribute pointing
    at the attachment). Uploads sitting in the Media Library that
    aren't referenced anywhere are treated as content
    (editor-managed) and stay local.

    To ship a designer asset: either set it as a logo, or place it
    via Site Editor inside a template, template part, or global
    style. Both reference paths are captured automatically.
  </Accordion>

  <Accordion title="What does the URL retarget actually do for images?" icon="globe">
    Block markup captures the image URL as a literal string in the
    block attrs, e.g.:

    ```html theme={null}
    <!-- wp:image {"id":42,"url":"http://localhost:8080/app/uploads/..."} -->
    ```

    `wp_get_attachment_url()` filters don't run on literal strings at
    render time. So the apply-side URL retarget rewrites the captured
    uploads prefix to `wp_get_upload_dir()['baseurl']` BEFORE the host
    retarget, ensuring `img src` lands pointing directly at the S3
    bucket (or your configured CDN URL via `FP_S3_BUCKET_URL`).
  </Accordion>

  <Accordion title="How do I roll back a snapshot apply?" icon="rotate-left">
    The forward path is image-versioned. Roll back by deploying an
    earlier image tag whose `web/imports/` didn't contain the
    snapshot. The snapshot-imported posts will still exist in the DB
    (additive — apply doesn't delete on rollback), but no apply runs
    against the missing snapshot directory so no further changes
    land. For a full content rollback, restore from your MariaDB
    backup.
  </Accordion>

  <Accordion title="How do I reset markers to force re-apply?" icon="hammer">
    On the target environment:

    ```bash theme={null}
    kubectl -n <namespace> exec deploy/site -- wp --allow-root --path=/app/web/wp \
      option delete fp_snapshot_applied_ref fp_snapshot_applied_sha256
    ```

    The next ArgoCD reconcile / install Job run will see no markers
    and run a full apply. Useful when you've changed the apply
    behaviour (e.g. a mu-plugin upgrade) without changing the
    snapshot itself.
  </Accordion>
</AccordionGroup>

## Companion repos

| Repo                                                             | Role in the designer flow                                                                                                                                                                                                                                                                 |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`fp`](https://github.com/frankenpress/fp)                       | Host-side CLI. `fp snapshot` wraps the mu-plugin's `wp fp snapshot` + the post-capture `docker cp` extraction, with interactive slug + note prompts. Installed via the [`homebrew-tap`](https://github.com/frankenpress/homebrew-tap).                                                    |
| [`mu-plugin`](https://github.com/frankenpress/mu-plugin)         | Ships the `Fse` adapter + `wp fp snapshot` + `wp fp apply`. v0.12.0+ ships the design/content scope split + designer-asset attachment shipping (option-ref + block-scan). v0.12.2+ includes the apply-side uploads URL retarget.                                                          |
| [`site-template`](https://github.com/frankenpress/site-template) | What you forked from. `web/imports/.gitkeep` is the placeholder for committed snapshots.                                                                                                                                                                                                  |
| [`charts`](https://github.com/frankenpress/charts)               | Install Job picks the snapshot under `web/imports/` with the highest `manifest.created` and applies it on every helm upgrade. v0.10.0+ provides RW root FS on the install Job for transient WP-Importer install; v0.12.0+ moves from "fail on >1 snapshot" to "pick latest by `created`". |
