| name | tech-vanilla-flask |
| description | Vanilla JS SPA + Flask stack (stack id `vanilla-flask`): idiomatic structure, the self-contained fragment files each feature creates, where features auto-discover at boot/build, the no-build static serving, and the token-CSS styling tool. Invoke when the lab's stack is `vanilla-flask`.
|
tech-vanilla-flask skill
The lab is a plain HTML/JS single-page app served by Flask, one container,
no build step. Flask serves the API at /api/* and the static frontend at /.
Umbrella rule โ never edit shared entrypoints
A feature author creates ONLY its own files and NEVER edits app.py,
core.py, app.js, index.html, or base.html. These entrypoints
auto-discover features at boot/build. There are no "edit the AGENT marker"
steps anymore โ drop your feature's fragment files and the loaders pick them up.
One feature author owns both halves of its feature: the Flask backend
fragments (routes/, schema/, seed/) and the vanilla frontend module
(features/). Parallel authoring agents touch disjoint sets of files, so they
never collide.
File map
Backend (already in $ARENA_WORKDIR/app/) โ the skeleton ships these; do not
edit them:
app.py โ Flask app. Serves the static SPA from static_assets/ at /,
defines auth + health routes, and calls register_feature_routes(app) which
auto-imports every routes/<feature>.py.
core.py โ shared helpers (get_db, current_user, login_required) and
the fragment loaders. Features import from here.
Backend feature fragments โ a feature creates these (auto-discovered):
routes/<feature>.py โ exposes def register(app): ... with the routes.
schema/<feature>.sql โ CREATE TABLE IF NOT EXISTS ....
seed/<feature>.sql โ INSERT OR IGNORE ... rows (re-runs every boot).
Frontend (already in $ARENA_WORKDIR/app/static_assets/) โ the skeleton ships
these; do not edit them:
index.html โ the shell (nav + login view + #feature-views host). Nav links
and per-feature <section>s are built dynamically by app.js.
app.js โ api.*, login/logout, and the feature loader (fetches
features/manifest.json, dynamic-imports each module, builds the nav, calls
render()).
style.css โ the token-driven sheet with the AGENT THEME TOKENS block.
Frontend feature fragment โ a feature creates this (auto-discovered):
features/<feature>.js โ an ES module exporting nav + render().
Scaffold (skeleton stage)
The flask backend is already in app/, the vanilla frontend is already in
app/static_assets/, and Flask is already wired to serve it at /. Do NOT
copy templates and do NOT touch the static-serving config. The skeleton-only work:
- Seed users in
core.py seed_users() (the single-writer step that needs
werkzeug password hashing). Feature data seeding does not go here.
- Set the per-run brand + theme tokens in
static_assets/style.css (see design-token-css).
Base tables/seed beyond users live in schema/_base.sql / seed/_base.sql if
needed โ _-prefixed files sort first and are skeleton-owned.
How a feature wires in (feature stage)
A feature drops fragment files; the loaders discover them. Nothing else.
Backend โ routes/<feature>.py
Expose def register(app) and import helpers from core:
from core import get_db, current_user, login_required
def register(app):
@app.get("/orders")
@login_required
def list_orders():
rows = get_db().execute("SELECT id, item FROM orders").fetchall()
return {"orders": [dict(r) for r in rows]}
register_feature_routes(app) imports every routes/*.py in sorted order
(_-prefixed files skipped) and calls its register(app).
Backend schema/seed โ schema/<feature>.sql + seed/<feature>.sql
CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, item TEXT NOT NULL);
INSERT OR IGNORE INTO orders (id, item) VALUES (1, 'Wireless mouse');
*.sql files in each dir run in sorted order on every boot (_base.sql
first); _-prefixed files are skeleton-owned. Seed re-runs each boot, so use
INSERT OR IGNORE (or equivalent) to stay idempotent.
Frontend โ features/<feature>.js
An ES module that exports a nav descriptor and a render() function:
export const nav = { id: "orders", label: "Orders", slot: 20, path: "/orders" };
export function render(container, api, user, route) {
}
nav.group (optional) keeps the top bar small. Features that share the same
group string collapse into ONE top-level dropdown (button label = the group
string) instead of each adding its own top-level anchor. Omit group to render
as a top-level anchor (the default). The feature planner assigns group so a
large lab's nav stays readable โ a feature author just sets whatever nav.group
the plan gives it. A feature that is reached only from another page (not from
the top nav at all) sets nav to null (no nav entry). See the nav-contract
details in the reference.
app.js fetches features/manifest.json (a list of {file}), dynamic-imports
each module, reads its nav export, and builds the nav (sorted by slot then
label, as REAL <a href> anchors) into a per-feature <section>. nav.id must
be unique across features.
The shell is a real client router (History API): the active feature is the
one whose nav.path is a segment-prefix of window.location.pathname (longest
match wins; "/orders" matches "/orders" and "/orders/123" but NOT
"/orders-extra"). render(container, api, user, route) is (re)called whenever
the URL enters or changes within the feature's base path, where:
route.path โ full window.location.pathname (e.g. /orders/123).
route.subpath โ pathname minus the feature base, no leading slash ("" at
the base, "123" at /orders/123).
route.navigate(to) โ history.pushState to absolute path to and re-render
the now-active feature.
Sub-views MUST be URL-driven. A listโdetail / tabs / wizard MUST change the
URL via route.navigate('/orders/' + id) and branch on route.subpath โ do
NOT track the active sub-view in in-memory state. This keeps the URL truthful
and deep-linkable, and makes back/forward work.
features/manifest.json is served dynamically by the Flask backend โ it
globs static_assets/features/*.js at request time and returns the file list,
so a dropped-in features/<feature>.js is discovered automatically. nav
metadata (id/label/slot/path) comes from each module's own nav export. Feature
authors do not create or edit a manifest โ they only drop features/<feature>.js.
The vanilla-flask stack serves the SPA statically from Flask. A feature's
backend routes go in routes/<feature>.py (Flask); its frontend goes in
features/<feature>.js (vanilla). One author owns both halves.
Common pitfalls
- All data routes under
/api/...; the frontend calls them with relative paths.
- There is no build โ feature modules are served as-is; do not add Vite/npm.
- Never hand-roll nav in
index.html or hand-edit manifest.json โ set nav in
your features/<feature>.js and let the loader build it.
- Never edit
app.py / core.py / app.js / index.html to register a feature.
Writing idiomatic vanilla-flask code
For copy-pasteable patterns tied to the template's helpers, see
references/writing-vanilla-flask.md.
Highlights:
- Backend: put routes in
routes/<feature>.py under register(app); gate
with @login_required; use the request-scoped get_db() (do not .close()
it โ close_db owns it). Return JSON (a dict or jsonify(...)).
- Use Flask's
<int:id> URL converter; return {"error": โฆ}, 404 for a missing
row, not {}/null.
- Parse bodies with
request.get_json() or {}, validate the minimum, return
sensible status codes (400/201).
- Always use
? placeholders + a params tuple โ never f-string values into SQL
(for clean/supporting features).
- Frontend (no build): in
render(container, api, user), build the feature's
DOM into container with small helpers that own their own loading / empty /
error UI.
- Escape user/DB text (or use
textContent) before innerHTML โ don't
introduce accidental XSS in a clean feature.
- Reuse
.card/.btn/.alert/table so it matches the shared chrome.
- Seed real, domain-matched data (no lorem) in
seed/<feature>.sql; keep
accessible forms (<label for> + <input id>).
Styling
Use the token CSS system โ invoke the design-token-css skill. Theme per-run
by editing only the AGENT THEME TOKENS block in style.css.