skills/cali-starhtml/SKILL.md
Builds reactive web applications with StarHTML, a Python-first framework over Datastar. Use when writing StarHTML components, signals, event handlers, reactive attributes, conditional helpers, CSS classes, computed signals, HTTP actions, SSE endpoints, or plugins (persist, scroll, resize, drag, canvas, motion, markdown, katex, mermaid, split, nodegraph). For UI components, use StarUI (shadcn/ui for Python). After generating any StarHTML file, run `starhtml-check <file.py>` to validate.
npx skillsauth add renatocaliari/agent-sync-public-skills cali-starhtmlInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
StarHTML = Python objects that compile to reactive Datastar HTML.
After generating any component, validate with: starhtml-check <file.py>
If
starhtml-checkis not installed:curl -L https://raw.githubusercontent.com/renatocaliari/starhtml-skill/main/starhtml_check.py \ -o /usr/local/bin/starhtml-check && chmod +x /usr/local/bin/starhtml-check
UI Components: For production-ready UI, use StarUI — shadcn/ui for Python. See
./reference/starui.mdfor all 34+ components (Button, Card, Dialog, Table, etc.)
Sub-references (load when needed, same directory as this file):
./reference/starui.md·./reference/icons.md·./reference/js.md·./reference/handlers.md·./reference/slots.md·./reference/demos.mdOfficial demos (canonical runnable examples, always from official framework repo):
https://raw.githubusercontent.com/banditburai/starHTML/main/web/demos/NN_name.py
R1 — No f-strings in reactive attributes (they become static)
# WRONG — evaluated once in Python, never updates in browser:
data_text=f"Count: {counter}"
# RIGHT — reactive, updates when signal changes:
data_text="Count: " + counter # for 1-2 signals
data_text=f("Count: {c}", c=counter) # for 3+ signals
# f() requires: from starhtml.datastar import f
R2 — data_show always needs flash prevention
# WRONG — element flashes visible before JS loads:
Div("modal content", data_show=is_open)
# RIGHT — hidden by default, shown reactively:
Div("modal content", style="display: none", data_show=is_open)
# Alternatives:
Div("modal content", cls="hidden", data_class_hidden=~is_open)
Div("modal content", style="opacity:0;transition:opacity .3s",
data_style_opacity=is_open.if_("1", "0"))
R3 — Positional arguments BEFORE keyword arguments
# WRONG — SyntaxError at runtime:
Div(cls="container", "Hello World")
# RIGHT:
Div("Hello World", cls="container")
# Rule: content/signals first, then attributes/handlers
R4 — Signal names must be snake_case
# WRONG — will error at runtime:
(myCounter := Signal("myCounter", 0))
# RIGHT:
(my_counter := Signal("my_counter", 0))
R5 — Walrus operator := must be wrapped in outer parentheses
# WRONG — won't register as positional argument:
name := Signal("name", "")
# RIGHT — works as positional argument in HTML elements:
(name := Signal("name", ""))
R6 — Signals are reactive state references, NOT data containers
# ❌ WRONG — Signals are NOT for storing/accessing data:
todos = Signal("todos", [])
todos.value.append(item) # .value does NOT exist!
print(len(todos)) # Signals don't support len()!
# ✅ RIGHT — Use plain Python variables for data:
todos_data = [] # Python variable for the actual data
(todos_count := Signal("todos_count", 0)) # Signal for reactive UI state
# Add item to data:
todos_data.append({"id": 1, "text": "Buy milk"})
# Update Signal (syncs to frontend, can be received in backend):
yield signals(todos_count=len(todos_data))
Signals are reactive state references that sync between frontend ($signalName in JS) and backend (Python Signal objects). They are NOT Python containers for data.
Data lives in regular Python variables (lists, dicts, strings, etc.).
# State management with pure Python
todos_data = [] # The actual data (Python list)
next_id = 1 # Counter (Python int)
# Signals for reactive state (frontend + backend)
(todos_count := Signal("todos_count", 0))
(is_loading := Signal("is_loading", False))
@rt("/todos/add", methods=["POST"])
@sse
def add_todo(todo_text: str):
global next_id
# 1. Update Python data (always use variables for data)
todos_data.append({"id": next_id, "text": todo_text})
next_id += 1
# 2. Send updated UI to client
yield elements(render_todo_item(todos_data[-1]), "#todo-list", "append")
# 3. Update Signals (syncs to frontend AND can be received in backend)
yield signals(todos_count=len(todos_data), is_loading=False)
# Frontend → Backend: Signal as parameter
@rt("/api/search")
@sse
def search(req, query: Signal): # Receives Signal from frontend
results = db.search(query) # Can read Signal in backend
yield signals(results_count=len(results))
_ prefix)# Signal with _ prefix = frontend-only by convention
# (no backend parameter, not received in SSE handlers)
(_animation_state := Signal("_animation_state", "idle"))
# With _ref_only=True, Signal is excluded from data-signals HTML attribute
(_cache := Signal("_cache", {}, _ref_only=True))
Mistake 1: f-string with Signal — displays $signal_name literal
(thought_count := Signal("thought_count", 5))
# ❌ WRONG — f-string converts Signal to literal string "$thought_count"
Div(f"{thought_count} thoughts captured") # Shows: "$thought_count thoughts captured"
# ✅ RIGHT — Signal as argument (concatenates value)
Div(thought_count, " thoughts captured") # Shows: "5 thoughts captured"
# ✅ RIGHT — Reactive attribute (updates dynamically)
Span(data_text=thought_count)
Mistake 2: Signal in Python conditional — always truthy
(is_saving := Signal("is_saving", False))
# ❌ WRONG — Signal object is always truthy in Python
Button("Save" if not is_saving else "Saving...") # Always shows "Saving..."
# ✅ RIGHT — Use reactive attribute
Button(
"Save this thought",
data_text=is_saving.if_("Saving...", "Save this thought")
)
Key points:
signal.value or use len(signal) — Signals are not containers_ prefix automatically sync to backend via parametersyield signals(...) at the end to reset statedata_textfrom starhtml import *
# Define reactive state — walrus := always in outer ()
(counter := Signal("counter", 0))
(name := Signal("name", ""))
(visible := Signal("visible", True))
# Reactive attributes
data_show=visible # show/hide element
data_text=name # display signal value as text
data_bind=name # two-way binding (inputs)
data_class_active=visible # toggle class "active"
# Events
data_on_click=counter.add(1)
data_on_input=(search_fn, {"debounce": 300}) # wait 300ms after typing
data_on_scroll=(update_fn, {"throttle": 16}) # max 60fps
data_on_submit=(post("/api/save"), {"prevent": True})
# Signal operations
counter.add(1) # increment: $counter + 1
counter.set(0) # assign: $counter = 0
visible.toggle() # flip boolean: !$visible
name.upper() # string method: $name.toUpperCase()
count.default(0) # nullish fallback: $count ?? 0
count.default(0).clamp(0, 99) # chain with other methods
theme.one_of("light","dark") # constrain to valid values
sig.then(action) # conditional execute if truthy
# Value guards (chainable)
status.one_of("draft", "published") # validate enum
theme.one_of("light", "dark", default="light")
# Logical operators
name & email # → $name && $email
~is_visible # → !$is_visible
all(a, b, c) # → !!$a && !!$b && !!$c (preferred for 3+)
any(a, b) # → $a || $b
age >= 18 # → $age >= 18
📄 Runnable example: 01_basic_signals.py (fetch from demos URL above)
| Helper | Behavior | Use for |
|--------|----------|---------|
| sig.if_("a", "b") | exclusive — one result | simple true/false choice |
| match(sig, a="x", default="z") | exclusive — maps value to output | value-based mapping |
| switch([(cond, "msg"), ...], default="") | exclusive — first match wins | validation chains |
| collect([(cond, "cls"), ...]) | inclusive — ALL true combined | CSS class building |
# EXCLUSIVE — only one result ever returned
data_text=status.if_("Active", "Inactive")
data_attr_class=match(theme,
light="bg-white text-black",
dark="bg-gray-900 text-white",
default="bg-white")
msg = switch([
(~name, "Name is required"),
(name.length < 2, "Name too short"),
(~email.contains("@"), "Invalid email"),
], default="")
# INCLUSIVE — combines ALL true conditions (correct for CSS classes)
data_attr_class=collect([
(True, "btn"),
(is_primary, "btn-primary"),
(is_large, "btn-lg"),
(is_disabled, "opacity-50 cursor-not-allowed"),
])
# WRONG: using collect() for exclusive logic (use switch or if_ instead)
# WRONG: using switch() to combine CSS classes (use collect instead)
📄 See: 06_control_attributes.py, 25_advanced_toggle_patterns.py, 28_datastar_helpers_showcase.py
Form(
(name := Signal("name", "")),
(email := Signal("email", "")),
(valid := Signal("valid", all(name, email.contains("@")))),
Input(type="text", data_bind=name, placeholder="Name"),
Span(
data_text=switch([(~name, "Required"), (name.length < 2, "Too short")],
default=""),
data_show=~name, style="display:none"
),
Input(type="email", data_bind=email, placeholder="Email"),
Button("Submit",
data_attr_disabled=~valid,
type="submit"),
data_on_submit=(is_valid.then(post("/api/submit", name=name, email=email)),
{"prevent": True})
)
📄 See: 03_forms_binding.py
# HTTP actions — pass signals as params, never f-strings
data_on_click=get("/api/data")
data_on_click=post("/api/submit", name=name_sig, email=email_sig)
data_on_click=is_valid.then(post("/api/submit")) # conditional
data_on_click=delete("/api/item")
# Conditional execution with .then()
data_on_click=is_confirmed.then(delete("/api/item")) # only if confirmed
data_effect=is_form_complete.then(auto_save) # side effect
# WRONG — f-string URL is Python-static, signal value not reactive:
data_on_click=get(f"/api/{item_id}")
# RIGHT — pass signal as parameter:
data_on_click=get("/api/item", id=item_id_sig)
Datastar sends JSON by default, NOT form data.
# ❌ WRONG — Datastar sends JSON, req.form() returns empty!
@rt("/todos", methods=["POST"])
def create(req):
form = req.form() # Returns empty dict!
text = form.get("text", "") # Always empty
mood = form.get("mood", "")
# ✅ RIGHT — Parse JSON first (Datastar default)
@rt("/todos", methods=["POST"])
def create(req):
import json
data = json.loads(req.body())
text = data.get("text", "")
mood = data.get("mood", "")
# ✅ RIGHT — Support both JSON and form data (defensive)
@rt("/todos", methods=["POST"])
def create(req):
import json
try:
data = json.loads(req.body())
text = data.get("text", "")
mood = data.get("mood", "")
except (json.JSONDecodeError, ValueError):
# Fallback for traditional form submissions
form = req.form()
text = form.get("text", "")
mood = form.get("mood", "")
Why this happens:
fetch() with Content-Type: application/jsonContent-Type: application/x-www-form-urlencodedBest Practice: Always parse JSON first, optionally fallback to form data for compatibility.
# SSE endpoint — always yield signals() at end to reset client state
@rt("/send", methods=["POST"])
@sse
def send():
yield signals(is_sending=True)
yield elements(Div("msg", cls="msg"), "#chat", "append") # append mode
yield elements(Div("x", id="chat"), "#chat") # replace/morph mode
yield signals(is_sending=False, message="") # REQUIRED: reset state
# Hypermedia morph rule: returned element id MUST match the target selector
@rt("/partial")
def partial():
return Div("new content", id="target") # id="target" matches get("/partial") target
# SSE Best Practices:
# 1. Always yield signals() at end to reset client state
# 2. For replace-mode: preserve id attribute for future targeting
# 3. Use append/prepend for lists, replace for single elements
📄 See: 02_sse_elements.py, 04_live_updates.py, 05_background_tasks.py, 08_routing_patterns.py
| Use Case | SSR Needed? | Pattern |
|----------|-------------|---------|
| Toggle single class | No | data_class_active=signal |
| Tailwind special chars | No | data_attr_class=signal.if_("hover:bg-blue-500", "") |
| Show/hide elements | Yes | style="display: none" + data_show=signal |
| Base + toggle classes | Yes | cls="base" + data_class_* |
| Base + dynamic classes | Yes | cls="base" + data_attr_cls=reactive |
# Simple class names (no special characters) → data_class_*
data_class_active=is_active # adds/removes class "active"
data_class_hidden=~is_visible # adds/removes class "hidden"
# Special characters (: / [ ]) in class names → data_attr_class
# WRONG — colon in keyword name is a Python parse error:
data_class_hover:bg-blue=sig
# RIGHT:
data_attr_class=is_active.if_("hover:bg-blue-500 focus:ring-2", "")
data_attr_class=is_loading.if_("bg-blue-500/50", "bg-blue-500")
data_attr_class=is_custom.if_("bg-[#1da1f2]", "bg-gray-500")
# data_attr_cls vs data_attr_class — DIFFERENT behaviors:
# data_attr_cls = ADDITIVE — merges with base cls= classes
# data_attr_class = REPLACES — sets the full class attribute
Button("OK",
cls="btn", # base classes
data_attr_cls=is_valid.if_("btn-success", "btn-error")) # additive
Button("OK",
data_attr_class=collect([(True, "btn"),
(is_primary, "btn-primary")])) # replaces
# Dictionary syntax for conditional classes
data_class={"active selected": role == "admin", "disabled": role == "guest"}
# Static CSS (SSR)
style="background-color: red; font-size: 16px"
# Reactive CSS properties
data_style_width=progress + "px"
data_style_opacity=is_visible.if_("1", "0")
# CSS template with multiple signals
from starhtml.datastar import f
data_attr_style=f("color: {c}; opacity: {o}", c=theme_color, o=opacity)
# Computed Signal — pass expression (not literal) as initial value
# auto-updates whenever dependencies change
(first := Signal("first", ""))
(last := Signal("last", ""))
(full_name := Signal("full_name", first + " " + last)) # string computed
(is_valid := Signal("is_valid", all(name, email))) # boolean computed
(total := Signal("total", price * quantity)) # math computed
# data_effect — side effects when signals change (assignments, not return values)
data_effect=total.set(price * quantity)
data_effect=[
total.set(price * quantity),
tax.set(total * 0.1),
final.set(total + tax),
]
# Performance: exclude internal-only signals from HTML output
(cache := Signal("cache", {}, _ref_only=True))
📄 See: 27_nested_property_chaining.py
Each plugin requires import from cali-starhtml.plugins and registration with the app.
Fetch the demo file for the exact integration pattern — demo files are complete, runnable examples.
Base URL for all demos: https://raw.githubusercontent.com/banditburai/starHTML/main/web/demos/
from starhtml.plugins import persist, scroll, resize, drag, canvas, position, motion, markdown, split
app, rt = star_app()
app.register(persist) # register each plugin you need
app.register(motion)
| Plugin | Demo file(s) | What it does |
|--------|-------------|--------------|
| persist | 09_persist_plugin.py | Sync signals to localStorage/sessionStorage |
| scroll | 10_scroll_plugin.py | Track scroll position, page progress |
| resize | 11_resize_plugin.py | Window/element resize events |
| drag | 12_drag_plugin.py, 16_freeform_drag.py | Drag and drop, sortable lists |
| canvas | 17_canvas_plugin.py, 18_canvas_fullpage.py, 29_drawing_canvas.py | Canvas drawing |
| position | 20_position_plugin.py | Element positioning |
| motion | 23_motion_plugin.py, 24_motion_svg_plugin.py | CSS/SVG animations (enter, exit, hover, in_view) |
| markdown | 13_markdown_plugin.py | Render markdown content via data_markdown |
| katex | 14_katex_plugin.py | Math / LaTeX rendering |
| mermaid | 15_mermaid_plugin.py | Diagram rendering |
| split | 21_split_responsive.py, 22_split_universal.py | Resizable split panes |
| nodegraph | 19_nodegraph_demo.py | Node graph UI |
Also fetch: 07_todo_list.py (complete real-world app), 30_debugger_demo.py (debugger tool)
For full demo index with descriptions: see ./reference/demos.md
For Icon() component: see ./reference/icons.md
For js(), f(), regex(): see ./reference/js.md
For plugins API (persist, scroll, resize, drag, canvas, position, motion): see ./reference/handlers.md
For slot system: see ./reference/slots.md
| Error | Cause | Solution |
|-------|-------|----------|
| Signal has no len() or AttributeError: 'Signal' object has no attribute 'value' | Treating Signal as data container (Signals are reactive state, not data) | Use Python variables for data: todos_data = [] instead of Signal("todos", []) |
| signals() takes 0 to 1 positional arguments | Passing positional args to signals() | Use kwargs: yield signals(count=1, status="done") not signals(count, status) |
| Method not found on $signal (JS console) | Using plugin attributes without registering | Import and register: app.register(persist) |
| NameError: name 'xyz' is not defined | Using Signal without walrus := parentheses | Wrap in parens: (xyz := Signal("xyz", 0)) |
| SyntaxError: positional argument follows keyword argument | Wrong argument order | Content first, attributes after: Div("text", cls="class") |
| Displays $signal_name literal | f-string with Signal — converts to string | Use arguments: Div(count, " items") or data_text=count |
| Button always shows wrong text | Signal in Python conditional — always truthy | Use reactive: data_text=is_saving.if_("Saving...", "Save") |
| Element flashes before hiding on load | Missing flash prevention | Add style="display:none" with data_show |
| Form submits and reloads page | Missing {"prevent": True} | Add: data_on_submit=(post("/api"), {"prevent": True}) |
| POST returns 400 Bad Request | Datastar sends JSON, not form data | Parse JSON: data = json.loads(req.body()) |
| SSE endpoint leaves UI in loading state | Missing yield signals() reset | Always end with: yield signals(loading=False) |
| Signal value not updating in backend handler | Trying to read signal.value | Receive Signal as parameter: def handler(req, my_sig: Signal) |
| Direct Datastar import | Importing @getdatastar/datastar manually | Remove it — StarHTML manages Datastar automatically |
The checker is a standalone CLI (zero dependencies, stdlib only) that validates StarHTML components.
Check if installed:
starhtml-check --help || echo "Not installed"
Install if missing:
# macOS / Linux - global install (recommended)
curl -L https://raw.githubusercontent.com/renatocaliari/starhtml-skill/main/starhtml_check.py \
-o /usr/local/bin/starhtml-check && chmod +x /usr/local/bin/starhtml-check
# Or user-local (no sudo required)
curl -L https://raw.githubusercontent.com/renatocaliari/starhtml-skill/main/starhtml_check.py \
-o ~/.local/bin/starhtml-check && chmod +x ~/.local/bin/starhtml-check
Verify installation:
starhtml-check --version # Should show help
Once installed, update to the latest version anytime:
starhtml-check --update
This fetches the latest version from GitHub, creates a backup (.bak), and updates automatically.
# After generating StarHTML code, always run:
starhtml-check component.py # full analysis
starhtml-check --summary f.py # compact output (fewer tokens)
starhtml-check --update # check for updates and update
Development Loop: write → check → fix ERRORs → re-run → ✓ no issues
tools
Auto-initialize structured documentation for any project using lat.md (knowledge graph of markdown files with [[wiki links]], // @lat: code refs, and semantic search). Detects cali-product-workflow artifacts (spec-product.md, spec-tech.md, critiques) and uses them as seed material. Falls back to extracting business rules, architecture, and design decisions directly from the codebase. Use when a project lacks structured documentation or when lat.md/ is missing. After seeding, lat.md extension hooks keep documentation alive automatically.
testing
[Cali] Server security audit and hardening for private servers behind Tailscale. Use when: auditing server security, hardening SSH/firewall/Docker, checking for vulnerabilities, setting up fail2ban, reviewing port exposure, or responding to security alerts. Covers 6 layers: CloudFlare, UFW, Tailscale, SSH, Docker, Application. Triggers: "server security", "security audit", "harden server", "SSH hardening", "firewall rules", "UFW config", "fail2ban", "port security", "Docker security", "vulnerability check", "security review".
tools
Run supply chain security scans before installing packages or before releases. Triggers when: user installs a package (npm, pip, go get, brew), user asks to 'scan dependencies', 'check vulnerabilities', 'supply chain', 'security audit', 'run trivy', 'run socket', or before any release/deployment. Also triggers on mentions of: socket.dev, trivy, OSV-scanner, dotenvx, CVE, dependency audit. Covers all four tools with concrete commands.
tools
[Cali] - INTERACTIVE SKILL: Discover servers from ~/.ssh/config, auto-detect hosts (filtering out non-server entries like github.com), prompt user to pick one via question tool, then SSH into the chosen server and render a real-time ASCII dashboard with Docker containers, images, volumes, routes, cron, orphaned resources, and cleanup suggestions. REQUIRES question tool, SSH config parsing, and shell execution.