Gravity Visual Editor — Design Plan
Goal: let clients update and manage their own deployed Gravity site — headers, titles, descriptions, images, sections — through a WordPress/Squarespace-style GUI, without touching Jinja templates or needing a redeploy.
Locked decisions (2026-06-01)
- Tenancy: single-tenant. The editor ships inside each client's deployed container. Content + media live in that client's own GCS bucket. Matches the current one-container-per-client deploy. (Multi-tenant control plane = future v2.)
- Storage: GCS bucket, JSON doc per site (
gs://<GRAVITY_SITE_BUCKET>/content.{draft,published}.json), images in the same bucket. Local-file fallback for dev. Object versioning = free revision history. - Editor scope: build all the way to sections/blocks, but in testable phases. No launch deadline — correctness over speed. Therefore the content model is section-based from day one (see §4.1) so we never build flat fields and rebuild.
1. The core decision: edit content, not templates
There are two ways to make a generated site client-editable:
| Approach | What the client edits | Risk | Verdict |
|---|---|---|---|
| A. Edit the Jinja templates directly | Raw .j2 files |
Clients break layout/markup; a bad edit takes the site down; security hole (template = code execution); can't preview safely | ❌ Reject |
| B. Edit a structured content layer the templates render | Named fields (hero.title, about.paragraphs[], cta.button_label) |
Layout is fixed by the designer; content can't break the page | ✅ Recommend |
This is precisely how WordPress works: the theme (PHP, analogous to your .j2) is owned by
the developer; the content (DB rows) is owned by the client. Gravity already leans this way —
about.j2/features.j2 render from models/*.json. We generalize that pattern to the whole site.
Principle: Templates define where things go and how they look. The content layer defines what the words and images are. Clients only ever touch the second.
2. What you already have (don't rebuild)
- Design/content separation —
views/<theme>/(design) is already distinct frommodels/*.json(content). - A content read path — at the time this was written,
GravityModels.readModel()→JsonFile; that module is since deleted and replaced byapp/controllers/main.py::_read_model(), a plain read-only helper for the staticmodels/*.jsonseed content (seedocs/gravity/architecture.md). - An admin shell —
views/<theme>/admin/...with auth, layout, and CRUD screens for blog/events/contacts/users. - A mutation API —
/api/message→app/messaging/handlers.pydispatch pattern. - A theme fallback loader — partial themes work; the editor's admin templates live in the default theme and are inherited by all.
- External collections via Adhara — blog, events, forms, newsletter, commerce are already a managed CMS. The editor should not reimplement these; it links to them.
The editor is a new admin section + a content store + a Jinja helper, not a new application.
3. The hard constraint: Cloud Run is stateless
scripts/deploy_gcp.sh already warns: "Cloud Run is stateless — /public/upload won't persist."
Consequence: edited content cannot be written to JSON files inside the container. A save would vanish on the next deploy, restart, or autoscale to a second instance. The content store must be external and shared across instances.
Storage options (ranked)
| Option | Persistence | Cost/Setup | Query/versioning | Recommendation |
|---|---|---|---|---|
GCS bucket — one JSON doc per site (gs://<site>/content.json) |
✅ | Lowest; mounts as a Cloud Run volume or via client lib | Whole-doc read/write; versioning via GCS object versions | ✅ Phase 1 — closest to current models/*.json, trivial migration |
| Firestore (Native) | ✅ | Low; serverless, no instance to run | Per-field, real-time, built-in revisions doable | ✅ Phase 2 — when sites get large or need drafts/history |
| Cloud SQL (Postgres) | ✅ | Higher; an instance to run + connection mgmt | Full relational, multi-tenant friendly | Consider only for a central multi-tenant control plane |
| Container-local JSON | ❌ | — | — | ❌ Never (the trap to avoid) |
Media/images go to a GCS bucket (public-read or signed URLs), not /public/upload. Same bucket can hold content.json.
What actually shipped: this section's GCS-only framing was the Phase 0 decision; the storage layer that got built (
app/stores/) generalized it into a fullContentStoreabstraction with six backends — local, GCS, S3, Azure Blob, Cloudflare R2, Vercel Blob — auto-selected from env vars, not just GCS. Seedocs/gravity/architecture.md§5. Everything else in this section (external, shared, never container-local) is still exactly right.
4. Architecture
┌─────────────────────────────────────────────┐
Client browser │ Gravity (FastAPI) │
┌──────────────┐ │ │
│ /admin/ │ edit │ routes/admin.py ── editor controller │
│ editor │ ──────► │ │ │
│ (GUI) │ POST │ ▼ │
│ │ /api/ │ app/content.py ◄── content schema (per page)│
│ live │ message │ │ load() / save() / publish() │
│ preview ◄───┼─────────┤ ▼ │
└──────────────┘ iframe │ ContentStore (GCS / Firestore) ◄── images→GCS│
│ │ │
│ ▼ │
Public visitor ───────►│ theme_render(page) → Jinja .j2 │
│ uses field()/section() helpers, which │
│ read the loaded content doc │
└─────────────────────────────────────────────┘
4.1 The content model is section-based (block-native from day one)
Because the destination is add/reorder/delete sections (Wix/Squarespace-style), the content model is an ordered list of section blocks from the very first commit — not flat fields we'd later rebuild. A page's published content doc looks like:
{
"page": "home",
"sections": [
{ "id": "s1", "type": "hero", "fields": {
"eyebrow": "Open Source · Python 3.12",
"title": "Ship beautiful websites.",
"subtitle": "Gravity bundles everything a production site needs…",
"cta_label": "Get started free", "cta_href": "/register",
"image": "gs://…/hero.png"
}},
{ "id": "s2", "type": "feature-grid", "fields": {
"heading": "What Gravity believes in",
"items": [
{ "icon": "📚", "title": "Readability first", "body": "Code is read more than written." },
{ "icon": "🚀", "title": "Production from day one", "body": "Gunicorn, Docker, sensible defaults." }
]
}}
]
}
Two schema layers drive the editor:
(a) Block-type registry — defines what each section type is and its editable fields. One file
per block type, e.g. schema/blocks/hero.json. This is the contract a theme implements:
{
"type": "hero",
"label": "Hero banner",
"partial": "blocks/hero.j2",
"fields": [
{ "key": "eyebrow", "type": "text" },
{ "key": "title", "type": "richtext" },
{ "key": "subtitle", "type": "textarea" },
{ "key": "cta_label", "type": "text" },
{ "key": "cta_href", "type": "link" },
{ "key": "image", "type": "image" }
]
}
(b) Page template — which block types a page allows and its default section list (used to seed
new sites). Field types map to editor widgets: text→input, textarea/richtext→editor,
image→GCS uploader, link→URL+page picker, list→repeatable group, select→dropdown.
4.1.1 Blocks render via per-type partials
Each block type has a Jinja partial under views/<theme>/blocks/<type>.j2. The page template just
loops the section list and renders the matching partial — this is the same loop whether there's 1
section or 20, so "add/reorder/delete sections" needs no template changes, only data changes:
{# views/<theme>/index.j2 — after conversion #}
{% block content %}
{% for section in content.sections %}
{% include 'blocks/' ~ section.type ~ '.j2' with context %}
{% endfor %}
{% endblock %}
{# views/<theme>/blocks/hero.j2 #}
<section class="hero" data-section="{{ section.id }}">
<div class="hero__eyebrow" data-field="eyebrow">{{ section.fields.eyebrow }}</div>
<h1 class="hero__title" data-field="title">{{ section.fields.title }}</h1>
<p class="hero__subtitle" data-field="subtitle">{{ section.fields.subtitle }}</p>
<a class="btn btn--accent" href="{{ section.fields.cta_href }}" data-field="cta_label">{{ section.fields.cta_label }}</a>
</section>
The data-section/data-field attributes are what Phase 3's inline editor hooks onto — emitted from
the start so we don't re-touch the partials later.
4.2 Controller change is one line
The controller loads the page's content doc and passes it in. The block loop (§4.1.1) does the rest:
def home():
return theme_render('index.j2', content=content.load('home'))
A small block_fields(type, key, default) Jinja helper (registered in app/__init__.py) is handy
for falling back to a block's schema default when a field is missing, so a newly-added section
renders sensibly before it's filled in.
4.3 The editor UI (/admin/editor)
Reuses the existing admin shell. Editing power is layered, but all on the same section-based data model, so each phase adds capability without reworking the previous one:
-
Section field editor (Phase 2) — pick a page → see its section list → click a section → an auto-generated form (from the block-type schema) of that section's fields. Save writes the store. Solves "update headers, descriptions, titles, images" within the existing sections.
-
Section management (Phase 3) — add / reorder (drag) / delete / duplicate sections; "add section" offers the page's allowed block types from the registry. This is the Wix/Squarespace capability you asked for — and because the page template just loops
content.sections, it needs zero template changes, only data changes. -
Live inline editing (Phase 4) — render the real page in an iframe; overlay click-to-edit on any
data-fieldelement (already emitted by the partials). Edits map back viadata-section+data-field. The Squarespace "click the text and type" feel — still constrained to declared fields, so layout can't break.
A full GrapesJS-style free-canvas builder is possible but fights your curated themes: it stores arbitrary HTML, loses theme consistency, and is hard to keep on-brand. If a client truly needs raw freedom, add it as a single
freeformblock type (one section that holds sanitized HTML), not a whole-page canvas. That keeps the rest of the site safe and on-theme.
4.4 Save path & draft/publish
- Editor POSTs to
/api/messagewith a new command, e.g.content_update(andcontent_publish), following the existinghandlers.pypattern. - Store two docs per site:
content.draft.jsonandcontent.published.json. Public pages render published; the preview iframe renders draft (via a?preview=1+ auth check). "Publish" copies draft→published. GCS object versioning gives free revision history / rollback.
5. Migrating the existing templates into blocks (the unlock)
Each hardcoded page (index.j2 etc.) is decomposed into block partials + a seeded section list.
This is mechanical and ideal for AI assistance. Per page, a conversion agent:
- Splits the page's
<section>s into block partials underviews/<theme>/blocks/<type>.j2, replacing literal text with{{ section.fields.<key> }}and emittingdata-section/data-field. - Writes/extends the block-type schema
schema/blocks/<type>.jsonfor any new type. - Seeds the page's default section list (the originals) into
content.published.json.
Do it lowest-risk first — about/features are already data-driven, so they become a rich-text
and a feature-grid block almost for free; convert the home hero next. A page not yet converted
still renders from its old hardcoded template, so the site is always shippable mid-migration.
A reusable starter set of block types covers most pages: hero, feature-grid, rich-text, cta,
gallery, logo-strip, stats, testimonial. New themes implement the same types' partials, so
content carries across themes.
6. Multi-tenancy: how many sites per editor?
Two models — pick based on your business shape:
- Single-tenant (recommended first): the editor ships inside each client's deployed container.
Content + media live in that client's GCS bucket (env:
GRAVITY_SITE_BUCKET). Matches your current "one container per client" deploy exactly. Zero new infra beyond a bucket. Each client logs into their site's/admin. - Multi-tenant control plane (later): one central app manages many sites, content keyed by
site_idin Firestore/Postgres. More powerful (one dashboard for all clients, you push template updates centrally) but a real product to build and operate. Good v2 once single-tenant proves out.
7. Phased build plan (each phase independently testable)
Block-native from the start, building toward full section management. Every phase ends with a concrete test you can run before moving on.
| Phase | Deliverable | Test / acceptance |
|---|---|---|
| 0. Content layer | app/content.py (load/save/publish), ContentStore over GCS + local-dev fallback, GRAVITY_SITE_BUCKET env, draft/published docs, block-type registry loader |
Unit: write a section doc → read it back from GCS. Render a page from a hand-written content.json and see it match the old hardcoded page. |
| 1. Block rendering | Convert home (+ about/features) into block partials (views/<theme>/blocks/*.j2); page templates loop content.sections; seed default section lists |
Visual: converted pages render byte-for-similar to before, now driven entirely by content.json. Hand-edit the JSON, reload, see the change. |
| 2. Section field editor | /admin/editor page list → section list → per-section auto-form from block schema; content_update/content_publish commands; draft vs published |
Log in, edit a hero headline in the GUI, publish, see it live on the public page. Core "edit my text/images" goal met. |
| 3. Section management | Add / reorder (drag) / delete / duplicate sections; "add section" picker from allowed block types; media uploads → GCS + image picker | Add a new feature-grid to a page, reorder it above the hero, delete a section, upload a hero image — all from the GUI, all persisted, no redeploy. Wix-like capability met. |
| 4. Inline editing | iframe live preview + click-to-edit overlay on data-field elements; autosave |
Click a headline on the previewed page, type, blur → saved to draft; publish → live. |
| 5. Polish / optional | Editor-vs-admin role, revision rollback (GCS object versions), optional freeform sanitized-HTML block type |
Roll a page back to a prior version; restrict an editor account to content only. |
Phases 0–3 deliver the full vision you asked for (edit content + add/reorder/delete sections). Phases 4–5 are UX and governance refinements on top of the same data model.
8. Key risks & mitigations
- Statelessness (biggest): never write content to the container FS — always GCS/Firestore. ✔ designed in.
- Caching/freshness: load content per-request (or cache with short TTL + invalidate on publish) so edits show immediately.
- Security: clients edit data, never templates — no
eval, no template injection. Sanitize/escape richtext (autoescape on; allowlist tags forrichtext). Editor behind existing admin auth + a neweditorrole. - Concurrent edits: single-tenant makes this rare; use draft doc + last-write-wins + GCS version history for rollback. Add optimistic locking later if needed.
- Theme upgrades vs content drift: keep field keys stable across theme versions; schema is the contract between a theme and its content. New theme = new/extra fields with defaults, old content still maps.
- Scope creep toward a full page builder: resist. Structured fields + sections cover ~95% of client needs at a fraction of the complexity and with zero ability to break the design.
9. Concrete first commit (Phase 0 skeleton)
app/content.py—load(page, *, published=True),save(page, sections, *, draft=True),publish(page), andblock_schema(type); backed byContentStore.app/stores/gcs_store.py— read/writecontent.{draft,published}.jsoninGRAVITY_SITE_BUCKET; local-file fallback for dev (mirrors currentmodels/). AddGRAVITY_SITE_BUCKETtodeploy_gcp.sh's env vars (alongsideGRAVITY_TEMPLATE).schema/blocks/*.json— seedhero,feature-grid,rich-textblock-type definitions.- Register a
block_fields()helper inapp/__init__.py's Jinja env. - (Phase 1 start) Add
views/<theme>/blocks/{hero,feature-grid,rich-text}.j2; convertindex.j2to the section loop; seedhome's default section list into the published doc. - Wire
home()/about()/features()controllers to passcontent=content.load(page).
After that, Phase 2's editor is "list sections, render a form from the block schema, POST changes" —
small, on top of the admin shell you already have. Phase 3 adds array operations (insert/move/remove)
on the same sections list.
10. What shipped beyond this plan
Two things exist today that this design doc, written at Phase 0, doesn't cover:
The Builder (went past Phase 5). A full 3-pane drag-and-configure canvas
at /admin/editor/build/<page> (editor_build/editor_canvas/
editor_build_data in app/controllers/editor.py) — a further capability
tier beyond this doc's own "5. Polish / optional" phase, built on the exact
same section/draft/publish data model described in §4 above. Inline editing
(/admin/editor/inline/<page>, this doc's Phase 4) also shipped and is the
third of the three ways to edit content documented in the README.
Theme Tweaks (a sibling system, not covered here at all).
app/theme_tokens.py + app/controllers/theme_tweaks.py apply the identical
draft/publish pattern from §4.4 above — theme.draft.json/
theme.published.json on the same ContentStore — to CSS custom-property
overrides (colors, shadows, opacity) instead of page content. It's a
separate system with its own routes (/admin/theme/*) and its own gate
(GRAVITY_DEV_THEME_TWEAKS=1, independent of admin auth — see
docs/gravity/authentication.md), layered on top of a theme's main.css
rather than editing it. If you're extending the Visual Editor's
draft/publish/schema pattern to a new kind of editable thing, Theme Tweaks
is a second worked example of the same shape besides page content itself.