Skip to content

Commit 3af9996

Browse files
apiadclaude
andcommitted
feat(examples): canonical 02_ssr — SSR guestbook with form POST
Server-only, no Pyodide bundle. Exercises: - App + @app.view for GET - doc.style(href=..., sheet=...) → auto-served stylesheet route - @app.api.post with FastAPI's Form(...) for form-driven mutation - RedirectResponse(303) post-submit pattern - The `with element as builder:` markup pattern throughout - In-memory store as plain module-level list (resets on restart, intentional) Smoke test covers: empty state, form rendering (no `for_=` leak), the served stylesheet route, POST → 303 → entry visible, FastAPI Form validation rejecting missing fields with 422. One new framework gap surfaced (now at issues/7): - 7.4: .attrs(for_="x") renders as `for_="x"` instead of `for="x"`. Worked around in the example via the nested <label>text<input/></label> pattern (better semantics anyway). 39 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6cc9b85 commit 3af9996

3 files changed

Lines changed: 233 additions & 0 deletions

File tree

examples/02_ssr.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Tier 2 canonical example — a server-rendered guestbook.
2+
3+
SSR-only, no client-side Python. Demonstrates `App`, `@app.view` for GET
4+
routes, raw `@app.api.post` with FastAPI's `Form(...)` for form-driven
5+
mutation, `doc.style(href=..., sheet=...)` for auto-served CSS, and the
6+
`with element as builder:` markup pattern.
7+
8+
Run:
9+
10+
python examples/02_ssr.py
11+
12+
Then open http://localhost:8000 in a browser. Submit the form and the new
13+
entry shows up on reload (303 redirect back to `/`). The store is in-memory
14+
and resets on restart — intentional simplicity.
15+
"""
16+
17+
from datetime import datetime
18+
19+
from fastapi import Form
20+
from fastapi.responses import RedirectResponse
21+
22+
from violetear import App, Document, HTML, StyleSheet
23+
from violetear.color import Colors
24+
from violetear.units import px, rem
25+
26+
27+
app = App(title="Guestbook")
28+
29+
# In-memory store. Each entry is {name, message, timestamp}. Resets on restart.
30+
entries: list[dict] = []
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# Stylesheet — flat class selectors only (no descendant combinators yet)
35+
# ---------------------------------------------------------------------------
36+
37+
sheet = StyleSheet(normalize=True)
38+
39+
sheet.select("body").font(
40+
size=rem(1.0), family="system-ui, -apple-system, 'Segoe UI', sans-serif"
41+
).color(Colors.DarkSlateGray).background(Colors.WhiteSmoke).padding(rem(2.5))
42+
43+
sheet.select(".page").rules(max_width="640px").margin("auto").background(
44+
Colors.White
45+
).padding(rem(2.5)).rounded(px(8)).border(px(1), Colors.Gainsboro)
46+
47+
sheet.select(".page-title").font(size=rem(2.0), weight=700).color(Colors.Indigo).margin(
48+
bottom=rem(0.25)
49+
)
50+
51+
sheet.select(".subtitle").color(Colors.SlateGray).font(size=rem(1.0)).margin(
52+
bottom=rem(2.0)
53+
)
54+
55+
sheet.select(".section-heading").font(size=rem(1.25), weight=600).color(
56+
Colors.DarkSlateGray
57+
).margin(top=rem(2.0), bottom=rem(1.0))
58+
59+
# Form
60+
sheet.select(".guestbook-form").flexbox(direction="column", gap=px(12))
61+
sheet.select(".form-field").flexbox(direction="column", gap=px(4))
62+
sheet.select(".form-label").font(size=rem(0.875), weight=600).color(
63+
Colors.DarkSlateGray
64+
)
65+
sheet.select(".form-input").rules(
66+
padding="8px 10px", border=f"1px solid {Colors.Gainsboro}", font_size="1rem"
67+
).rounded(px(4))
68+
sheet.select(".form-textarea").rules(
69+
padding="8px 10px",
70+
border=f"1px solid {Colors.Gainsboro}",
71+
font_size="1rem",
72+
min_height="80px",
73+
font_family="inherit",
74+
).rounded(px(4))
75+
sheet.select(".form-submit").rules(
76+
padding="10px 16px",
77+
border="none",
78+
cursor="pointer",
79+
font_size="1rem",
80+
font_weight=600,
81+
).background(Colors.Indigo).color(Colors.White).rounded(px(4))
82+
83+
# Entries
84+
sheet.select(".entries").flexbox(direction="column", gap=px(12))
85+
sheet.select(".entries-empty").color(Colors.SlateGray).font(size=rem(0.875)).rules(
86+
font_style="italic"
87+
)
88+
sheet.select(".entry").padding(rem(1.0)).background(Colors.WhiteSmoke).rounded(
89+
px(6)
90+
).border(px(1), Colors.Gainsboro)
91+
sheet.select(".entry-header").flexbox(
92+
direction="row", gap=px(12), align="baseline"
93+
).margin(bottom=rem(0.5))
94+
sheet.select(".entry-name").font(weight=700).color(Colors.Indigo)
95+
sheet.select(".entry-time").font(
96+
size=rem(0.75), family="ui-monospace, monospace"
97+
).color(Colors.SlateGray)
98+
sheet.select(".entry-message").color(Colors.DarkSlateGray).rules(
99+
white_space="pre-wrap", line_height=1.5
100+
)
101+
102+
103+
# ---------------------------------------------------------------------------
104+
# Routes
105+
# ---------------------------------------------------------------------------
106+
107+
108+
@app.view("/")
109+
def index():
110+
doc = Document(title="Guestbook")
111+
doc.style(href="/style.css", sheet=sheet)
112+
113+
with doc.body as body:
114+
with body.div(classes="page") as page:
115+
page.h1(text="Guestbook").classes("page-title")
116+
page.p(
117+
text="Leave a note. It will stay until the server restarts."
118+
).classes("subtitle")
119+
120+
# New-entry form — POSTs to /entries (handled by FastAPI below).
121+
# We use the nested-label pattern (<label>Text<input/></label>) instead
122+
# of for=/id= pairing — violetear's .attrs() renders kwargs literally, so
123+
# `for_=` leaks as `for_="..."` in the HTML (see gap 7.4).
124+
page.h2(text="Sign the book").classes("section-heading")
125+
with page.form(classes="guestbook-form").attrs(
126+
method="post", action="/entries"
127+
) as form:
128+
with form.div(classes="form-field") as field:
129+
with field.label(text="Name", classes="form-label") as lbl:
130+
lbl.input(classes="form-input").attrs(
131+
type="text", name="name", required="required"
132+
)
133+
with form.div(classes="form-field") as field:
134+
with field.label(text="Message", classes="form-label") as lbl:
135+
lbl.textarea(classes="form-textarea").attrs(
136+
name="message", required="required"
137+
)
138+
form.button(text="Add entry", classes="form-submit").attrs(
139+
type="submit"
140+
)
141+
142+
# Existing entries — newest first.
143+
page.h2(text="Entries").classes("section-heading")
144+
with page.div(classes="entries") as entry_list:
145+
if not entries:
146+
entry_list.div(
147+
text="No entries yet — be the first.",
148+
classes="entries-empty",
149+
)
150+
else:
151+
for entry in reversed(entries):
152+
with entry_list.div(classes="entry") as card:
153+
with card.div(classes="entry-header") as header:
154+
header.span(text=entry["name"], classes="entry-name")
155+
header.span(
156+
text=entry["timestamp"], classes="entry-time"
157+
)
158+
card.div(text=entry["message"], classes="entry-message")
159+
160+
return doc
161+
162+
163+
@app.api.post("/entries")
164+
async def add_entry(name: str = Form(...), message: str = Form(...)):
165+
entries.append(
166+
{
167+
"name": name.strip(),
168+
"message": message.strip(),
169+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
170+
}
171+
)
172+
return RedirectResponse(url="/", status_code=303)
173+
174+
175+
if __name__ == "__main__":
176+
app.run()

issues/7-framework-gaps-from-canonical-examples.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ Running log of small framework limitations and ergonomic friction discovered whi
4343

4444
**Impact.** Trips up new users; forces a context-manager-or-`HTML.*` pattern that's slightly more verbose than necessary.
4545

46+
## 7.4 — `Element.attrs(**kwargs)` doesn't strip trailing underscores
47+
48+
**Tier(s):** 02
49+
50+
**Symptom.** Calling `.attrs(for_="name")` renders `for_="name"` (with trailing underscore) in the HTML, not `for="name"`. Same would apply to any HTML attribute whose name collides with a Python keyword (`class`, `for`, `type` is fine, `is`, etc.). Workaround in tier 2: use the nested `<label>text<input/></label>` pattern, which doesn't need a `for=` attribute.
51+
52+
**Where to fix.** `violetear/markup.py:Element._render` — when iterating `self._attrs`, transform the key with something like `key = key.rstrip("_") if key != "_" else key` before writing the attribute. Same logic could also live in `Element.attrs()` and the `__init__` kwargs sink — pick one to be the canonical normalization point. Note that gap 7.3-adjacent: `Element.__init__` already handles `class_name``classes` aliasing manually; a generic underscore-strip would be consistent.
53+
54+
**Impact.** Reference examples can't use `for=` / `class=` via kwargs without producing invalid HTML. Subtle because the form still submits (browsers ignore unknown attrs), so the bug is silent until someone inspects the rendered HTML.
55+
4656
---
4757

4858
Add entries here as more examples land.

tests/test_examples_canonical.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,50 @@ def test_01_static_renders_html_and_css(tmp_path):
5151
assert "modern-normalize" in css
5252
assert ".swatch-chip" in css
5353
assert ".tokens-heading" in css
54+
55+
56+
# -- 02_ssr --------------------------------------------------------------------
57+
58+
59+
def test_02_ssr_guestbook_round_trips_an_entry():
60+
"""Tier 2: GET shows form + empty state; POST appends + redirects; subsequent GET shows the entry."""
61+
from fastapi.testclient import TestClient
62+
63+
mod = _load("02_ssr.py")
64+
# Reset the in-memory store between test runs in case other tests touched it.
65+
mod.entries.clear()
66+
67+
client = TestClient(mod.app.api)
68+
69+
# Empty state
70+
r = client.get("/")
71+
assert r.status_code == 200
72+
assert "No entries yet" in r.text
73+
assert "<form" in r.text and 'method="post"' in r.text
74+
# Confirm the for_= leak fix held — no underscore-suffixed attrs in output.
75+
assert "for_=" not in r.text
76+
77+
# Served stylesheet route
78+
r = client.get("/style.css")
79+
assert r.status_code == 200
80+
assert r.headers["content-type"].startswith("text/css")
81+
assert ".entry-name" in r.text
82+
83+
# POST and follow redirect
84+
r = client.post(
85+
"/entries",
86+
data={"name": "Alex", "message": "hello"},
87+
follow_redirects=False,
88+
)
89+
assert r.status_code == 303
90+
assert r.headers["location"] == "/"
91+
92+
# Entry now visible
93+
r = client.get("/")
94+
assert "Alex" in r.text
95+
assert "hello" in r.text
96+
assert "No entries yet" not in r.text
97+
98+
# Pydantic-ish enforcement: missing field is rejected by FastAPI Form(...)
99+
r = client.post("/entries", data={"name": "Alex"})
100+
assert r.status_code == 422

0 commit comments

Comments
 (0)