Metadata-Version: 2.4
Name: pyrite-web
Version: 0.1.4
Summary: Code websites in Python 3, without writing JavaScript or CSS.
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# Pyrite

Release: **0.1.4**.

Code websites in Python 3, for JavaScript and CSS haters.

**Want the short version? [Start here: button colors, styling, first page](QUICKSTART.md).**

Pyrite is a small, dependency-free starting point: compose Python components,
return a page, and serve HTML with built-in responsive styling. Requires Python 3.10+.

## Install from PyPI

Create a folder for your website, then use a virtual environment. This also avoids
Arch Linux's `externally-managed-environment` error.

```sh
python3 -m venv .venv
.venv/bin/python -m pip install pyrite-web
```

The package name on PyPI is `pyrite-web`; the Python import is `pyrite`.
On Windows, replace `.venv/bin/python` with `.venv\Scripts\python.exe`.

## Your first website

Save this as `app.py` in your project folder:

```python
from pyrite import App, Button, Column, Heading, Page, Text

app = App()

@app.page("/")
def home():
    return Page(Column(
        Heading("My first Pyrite website."),
        Text("Written in Python. No JavaScript or CSS files."),
        Button("About this site", "/about"),
    ), title="My website")

@app.page("/about")
def about():
    return Page(Column(
        Heading("About"),
        Text("Another URL. Another Python function."),
        Button("Back home", "/"),
    ), title="About")

if __name__ == "__main__":
    app.run()
```

Run it, then open http://127.0.0.1:8000:

```sh
.venv/bin/python app.py
```

Stop with Ctrl+C. Use `app.run(reload=True)` for automatic restart and browser
refresh. Otherwise, after editing Python files, restart the server and refresh your
browser. Use `app.run(port=8001)` if port 8000 is already occupied.

For your own shared style, create `styles.py` next to `app.py`:

```python
from pyrite import Gradient, Theme

theme = Theme(
    background="#111820",
    text="#eef3f8",
    heading_font="sans",
    radius=16,
    button_radius=999,
    gradient=Gradient(
        colors=("#111820", "#214a42"),
        moving=True,
        duration=14,
    ),
)
```

Import `theme` with `from styles import theme`, then pass `theme=theme` to each
`Page(...)`. The root `styles.py` in this repository is an example, not a file
installed into your project by pip.

The sections below cover components, forms, HTTP calls, animation, and chat.
The demo URLs such as `/try` and `/chat` belong to this repository's `example.py`;
installing the library does not automatically add these routes to your app.

## Run the example

```sh
python3 example.py
```

Open http://127.0.0.1:8000. The example is Pyrite's first website: a homepage
with a code/output comparison, quickstart, expandable FAQ, and a component
guide at `/guide`. Try `/playground` for a Python-powered calculator.
Its application source uses only Python components.
No installation is required when running from this repository.

## Build a page

```python
from pyrite import App, Column, Heading, Page, Text

app = App()

@app.page("/")
def home():
    return Page(
        Column(Heading("Hello, Pyrite."), Text("Just Python.")),
        title="My website",
    )

if __name__ == "__main__":
    app.run()
```

Available components: `Heading`, `Text`, `Column`, `Row`, `Card`, `Link`, and
`Button`, `Section`, `Nav`, `Code`, and `Details`. Nest components directly.
`Section(..., id="start")` creates an anchor target. `Code(source, label="Python")`
displays escaped source. `Details(question, ...)` provides a native expandable answer.
`Heading` accepts `level=1` through `6`.
`Link(label, href)` and `Button(label, href)` navigate to another URL.
Text and HTML attributes are escaped automatically.

Register exact paths with `@app.page("/about")`. Handlers take no arguments and
return a `Page`. Query strings do not affect route matching. `Page` accepts
`title` and `language`; `app.run` accepts `host` and `port`.

## Current scope

This version serves server-rendered pages with native GET forms and JSON POST
callbacks through `LiveForm`, `Chat`, and `Poll`. Request access and cookies
support `SessionStore` for bounded in-memory sessions. Action buttons submit
POST requests; structured polling updates component children. Optional hot reload
restarts the worker and refreshes browser pages. Token streaming and a production
server are not included. The built-in
HTTP server is for local development. Styling is supplied by Pyrite; applications
do not need CSS files or JavaScript tooling.

## Styling in Python

Edit the root `styles.py` to change the example website's shared theme. Restart
the server after editing. `Theme` supports six-digit hex colors, `sans`, `serif`,
or `mono` fonts, page width, corner radius, and spacing. Apply a theme to any page:

```python
from pyrite import Theme

theme = Theme(background="#111820", text="#eef3f8", heading_font="sans")
page = Page(Heading("Your style."), theme=theme)
```

## Forms without JavaScript

```python
from pyrite import Form, Input, Submit, query

@app.page("/hello")
def hello():
    return Page(Column(
        Form(Input("name", "Your name", value=query("name")), Submit("Say hello")),
        Text(f"Hello, {query('name', 'world')}!"),
    ))
```

`Slider(name, label, value=5, minimum=1, maximum=10)` and
`Select(name, label, {"value": "Label"}, value="value")` also work in forms.
Submission reloads the page; `query` reads request-local URL parameters. GET forms
are for read-only calculations and searches; their values appear in the URL.

## Try Pyrite in Browser

Visit `/try` to edit Python and render a real `Page` without installing Python.
Assign your result to `page`; do not call `app.run()`. The editor loads Pyodide
from jsDelivr and runs code in a disposable Web Worker, with a 10-second execution
limit and an isolated preview. First download can take up to two minutes.
Code is not executed on the application server. Browser networking follows CORS
rules; the standard-library HTTP client below is for server-side Python.

## Images and motion

```python
from pyrite import Image, Motion
from styles import hero_image

banner = Motion(Image(hero_image, "Mountain landscape"), effect="rise")
```

Choose `rise`, `fade`, or `float`. In `styles.py`, set `motion=False` to disable
component animations, or change `motion_duration` (100–5000 milliseconds) for
entrances. Reduced-motion browser preferences disable animations automatically.
`Theme(background_image="https://example.com/background.jpg")` attaches a page
background. `Image` accepts relative or HTTP(S) URLs and requires alt text.
External images load directly from their host; local file serving is not included.

## Requests and changing API data

```python
from pyrite import requests

data = requests.get(
    "https://api.mymemory.translated.net/get",
    params={"q": "Good morning", "langpair": "en|uk"},
).json()

# For an API you control, with the authentication it requires:
# requests.post("https://your-api.example/items", json={"name": "New"})
# requests.put("https://your-api.example/items/1", json={"name": "Changed"})
# requests.delete("https://your-api.example/items/1")
```

The dependency-free client supports `get`, `post`, `put`, and `delete`, optional
headers, JSON bodies, query parameters, and a timeout (8 seconds by default).
Responses expose `status_code`, `text`, and `json()`. Catch `requests.RequestError`
for HTTP/network/decoding failures. Responses are limited to 1 MiB. Supply trusted
URLs in application code; do not forward visitor-provided URLs to this client.
GET page handlers should only read data. POST actions can make remote writes;
add application authentication and authorization before exposing private data or
operations. Pyrite's local development server does not provide authentication.

`/translate` demonstrates input, a real free MyMemory request, and updated page
content. No API key required; availability, translation quality, and quotas depend
on MyMemory. Submitted text is sent to that service.

## Corners and gradients

```python
from pyrite import Gradient, Theme

theme = Theme(
    radius=20,              # Panels, code blocks, images, details
    button_radius=999,      # Pill buttons; 0 gives square corners
    input_radius=12,        # Inputs and dropdowns
    gradient=Gradient(
        colors=("#101923", "#214a42", "#17283d"),
        angle=120,
        moving=True,
        duration=14,       # Seconds per cycle
    ),
)
```

`button_radius` and `input_radius` inherit `radius` when omitted. Gradients accept
2–8 hex colors; set `moving=False` for a static gradient or `gradient=None` for
the usual background. A fixed background layer animates independently of page
length. `motion=False` or the browser's reduced-motion preference stops its movement.
The gradient layer sits over a background image when both are configured.

## In-page requests and chat

```python
from pyrite import App, Chat, Page

app = App()

@app.action("/api/reply")
def reply(data):
    message = data["message"]
    history = data.get("history", [])
    # Call a model here with pyrite.requests; keep API keys on the server.
    return {"reply": "You said: " + message}

@app.page("/")
def home():
    return Page(Chat(action="/api/reply"))
```

Visit `/chat` for a working local rule-based chatbot and a live translation form.
`Chat` sends `{message, history}` and displays `reply` without a page reload.
History stays in this page's browser memory (last 20 messages), is sent with each
request, and clears on reload. It is untrusted input; validate it in callbacks.
This is completed-response chat, not token streaming. The demo uses no AI service.

For other interfaces, use `LiveForm(Input(...), Submit(...), action="/api/action")`
and return `{"text": "Updated result"}` from the action. All field values arrive
as strings. Pyrite supplies the JavaScript, pending state, errors, 30-second
client timeout, and safe text rendering. The callback can use `requests` to reach
external APIs without exposing credentials to the browser. Aborting a request
does not cancel Python work already running.

Actions accept JSON objects up to 16 KiB and reject cross-origin browser requests.
These checks do not authenticate users. Use application validation and authorization
for sensitive actions. Live components require JavaScript; ordinary pages and GET
forms continue working without it. Browser playground previews remain isolated and
cannot call server actions.

## Per-component color labels

Define reusable labels in your project's `styles.py`:

```python
from pyrite import Color, Theme

theme = Theme(colors={
    "danger": Color("#b91c1c", text="#ffffff", hover="#991b1b"),
    "success": Color("#166534", text="#ffffff", hover="#14532d"),
})
```

Use `Button("Continue", "/next", theme=theme["success"])` or
`Submit("Save", theme=theme["success"])`. This changes only that component.
`Text`, `Heading`, `Column`, `Row`, and `Card` also accept a color through `theme=`.
Other built-in elements support `.themed(theme["danger"])`.
`Page(..., theme=theme)` still sets the whole page's theme.
`theme=theme:label` is not valid Python; use `theme=theme["label"]`.
Labels contain validated `Color` objects, not arbitrary CSS. Missing labels raise
an error explaining where to define them.

## Pyrite doctor

From your project folder:

```sh
.venv/bin/pyrite doctor
# Or specify a directory:
.venv/bin/python -m pyrite doctor /path/to/your/project
```

Doctor reports known issues with file, line, explanation, and suggested fix:

| Severity | Meaning | Exit code |
| --- | --- | --- |
| Severe | Syntax, invalid settings/colors, or known component misuse | 2 |
| Medium | Missing page theme or low text contrast | 1 |
| Mild | Usability advice, such as overly fast gradients | 0 |

The highest severity determines the exit code. No issues also returns 0.
Doctor reads Python source without executing apps or sending API requests. It
skips virtual environments and build folders. Dynamic values and runtime behavior
still need testing; passing doctor is not proof that every issue is fixed.

## Tests

```sh
python3 -m unittest discover -s tests -v
```

## Publishing a release to PyPI

These steps are for maintainers publishing Pyrite itself. Run from the repository
root. Users building websites only need the installation instructions above.

### Set up publishing tools

```sh
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade build twine
```

Keep publishing tools inside this environment. Do not install them into Arch's
managed system Python or use `--break-system-packages`.

### Create an upload token

1. Register or sign in at https://pypi.org and enable two-factor authentication.
2. Open https://pypi.org/manage/account/token/ and create an API token.
3. For an existing project, restrict its scope to `pyrite-web`. An account-wide
   token is needed only when creating a project for the first time.
4. Copy the token immediately; PyPI shows it once. Keep it out of source files,
   Git, logs, screenshots, and chat.

### Build and check

Choose an unused version in `pyproject.toml`. PyPI cannot overwrite an uploaded
release, even when only the README changes. The README becomes the package's PyPI
description when the new artifacts are uploaded.

For version `0.1.4`:

```sh
.venv/bin/python -m unittest discover -s tests -v
.venv/bin/python -m build
.venv/bin/python -m twine check dist/pyrite_web-0.1.4.tar.gz dist/pyrite_web-0.1.4-py3-none-any.whl
```

Use the matching version in every filename for later releases. Upload explicit
filenames so old artifacts in `dist/` are not accidentally uploaded again.

### Upload and verify

```sh
.venv/bin/python -m twine upload dist/pyrite_web-0.1.4.tar.gz dist/pyrite_web-0.1.4-py3-none-any.whl
```

When prompted, use `__token__` as the username and the full token, including its
`pypi-` prefix, as the password. The password prompt hides your input.

Check https://pypi.org/project/pyrite-web/0.1.4/ and install in a fresh environment:

```sh
python3 -m venv /tmp/pyrite-release-check
/tmp/pyrite-release-check/bin/python -m pip install pyrite-web==0.1.4
/tmp/pyrite-release-check/bin/python -c 'from pyrite import App, Chat, Gradient, Theme; print("Pyrite installed")'
```

If Twine reports `No module named twine`, use `.venv/bin/python` instead of system
`python3`. A `403` usually means missing project permissions or an invalid token;
an already-existing-file error requires a new version, not another upload of the
same filename. Never paste your token into an error report.

Doctor also checks imported Pyrite component arguments, literal heading levels,
motion effects, input kinds, slider bounds, link/image URLs, live-action paths,
empty labels and page metadata, select options, and duplicate or invalid App
endpoint paths. Import aliases and `import pyrite as p` are supported for these
checks. Dynamic values are left for runtime validation; application code is
never executed. Route checks are local to each file and App variable.

```sh
# Machine-readable issues and severity counts
python -m pyrite doctor app.py --json
# Report severe issues only (also controls exit status)
python -m pyrite doctor . --severity severe
# Skip additional directory names; repeat as needed
python -m pyrite doctor . --exclude generated --exclude vendor
```

Exit status: `2` for severe issues, `1` for medium issues, `0` otherwise.
`--severity` defaults to `mild`, reporting every severity. JSON output contains
`issues` (severity, path, line, message, fix) and `counts`.

## Guide, rating, and helpful errors

Run `pyrite guide app.py` for a cyan terminal workspace with Guide, Doctor,
and Logs tabs. Doctor findings refresh as you edit your source. Use Tab to
switch views, arrows or j/k to select, Page Up/Down to scroll explanations,
`/` to search, Escape to clear search, `r` to refresh, and `q` to quit.
Click the underlined topic links to open explanations inside the guide;
Enter opens the selected finding's explanation. Mouse support depends on
your terminal. Use `g` to return to the guide topics. The TUI needs a terminal
of at least 65 columns by 14 rows and Python's standard-library curses module.

```sh
pyrite guide app.py
pyrite guide app.py --topic theme
pyrite guide app.py --log pyrite-errors.jsonl
pyrite guide app.py --plain  # also automatic when output is redirected
pyrite rating app.py
pyrite rating app.py --json
pyrite run app.py --log startup-errors.jsonl
```

`guide` and `rating` only inspect application source; they never run it.
Both accept repeatable `--exclude DIRECTORY` arguments. The guide includes
runnable getting-started code and explanations of components, forms, routing,
themes, URLs, diagnostics, and Python errors. Logs refresh every second,
showing the latest 200 lines from at most 128 KiB, newest first. Both JSONL
runtime logs and existing plain-text logs are supported; select an entry
and open its explanation. A missing log file can be created while the guide
is running. `--plain` prints the selected topic and a diagnostic/log snapshot.

Rating measures **static diagnostic health**: start at 100 and subtract 20
for each severe finding, 5 for each medium finding, and 1 for each mild
finding, stopping at zero. It shows the five highest-priority fixes; JSON
includes all tips, severity counts, and penalties. No source files or a
missing path produces an unavailable score. A clean score does not prove
correct runtime behavior, security, performance, or visual quality. Rating
uses Doctor's exit codes: 2 for severe findings, 1 for medium, otherwise 0.

Request-handler failures now print the Python exception, application source
location, a suggested fix, a guide topic, and the full traceback to the
server terminal. Missing page returns point to the handler definition.
Page configuration and execution failures return HTTP 500; malformed query
parameters return 400. Actions may raise ValueError to reject user input
with 400; other failures return 500. HTTP error responses stay generic.

Enable structured runtime error logs explicitly:

```python
from pyrite import App

app = App(log_path="pyrite-errors.jsonl")
```

Use `pyrite run app.py` to execute an application with the same explanations
for startup errors, including syntax mistakes. Its `--log` writes startup
failures; use `App(log_path=...)` for failures during HTTP requests. Running
`python app.py` also gets helpful request-handler errors, while Python
handles startup exceptions normally. `run` returns 1 on a caught startup
error and preserves explicit application exit codes.

New error records do not include request bodies, headers, query strings,
or local variables. Exception messages may contain private data; keep logs
outside source control. New log files use owner-only permissions on POSIX;
existing files keep their current permissions. Standard HTTP access logs
remain separate and can include request URLs.


Doctor also flags recognizable security risks: `eval`/`exec`, shell execution,
unsafe pickle/shelve/YAML deserialization, SQL built with string interpolation,
and disabled TLS certificate verification. Findings include file and line numbers
and advice such as using explicit action allowlists, argument lists with
`shell=False`, safe data formats, parameterized SQL, and trusted CA certificates.
Imported aliases are recognized for these APIs.

These are static risk warnings, not proof of an exploitable vulnerability or a
security audit. Doctor does not trace input through variables or across files,
and dynamic imports and some rebinding patterns can evade checks. A clean scan
does not prove safety. Validate request data, enforce authentication and
per-action authorization, protect sensitive actions against CSRF, keep secrets
server-side, and run production services behind a hardened HTTPS server rather
than exposing Pyrite's development server directly.


## Hover effects

Chain `.hover(...)` onto any component to change its appearance when the pointer
hovers over it. Focusable components use the same effects on keyboard focus.

```python
Button("Hover me", "/").hover(
    scale=1.1,
    background="#6633cc",
    color="#ffffff",
    font="mono",
    font_size=20,
    glow="#9966ff",
)
```

All options are optional and can be combined. `scale` is a size multiplier
(0.1–3); `font_size` is pixels (1–300). Colors use six-digit hex values.
`font` accepts `sans`, `serif`, or `mono`. Effects transition smoothly and reset
when hover/focus ends. Reduced-motion preferences disable transitions and scaling.
The original component stays unchanged; repeated calls merge settings. Works
with `.themed(...)` in either order and requires no JavaScript. Hover is a visual
state, not a Python callback; touch users can still activate buttons normally.

## Noninteractive guide for AI agents

```sh
pyrite guide --agent
pyrite guide --agent --topic hover
pyrite guide --agent > pyrite-reference.txt
```

`--agent` prints a plain-text reference once and exits, even in a terminal.
Without `--topic`, it includes every guide topic and public component signatures.
It never prompts, opens the TUI, scans project files, reads logs, or executes your
app. Use `pyrite doctor app.py --json` separately for static diagnostics.
For a human-readable topic with project diagnostics, use
`pyrite guide app.py --plain --topic hover`.


## Automatic updates

`Poll` refreshes text from a Python action without submitting a form or reloading
the page. For example, this clock updates every two seconds:

```python
from datetime import datetime, timezone
from pyrite import App, Page, Column, Heading, Poll

app = App()

@app.action("/clock")
def clock(data):
    return {"text": datetime.now(timezone.utc).strftime("%H:%M:%S UTC")}

@app.page("/")
def home():
    return Page(Column(Heading("Server time"),
                       Poll(action="/clock", interval=2, initial="Loading clock…")))

if __name__ == "__main__":
    app.run()
```

`Poll(action=..., interval=5, initial="Loading…")` accepts an interval of 1–86400
seconds. It calls the action immediately with `{}`, then waits the interval after
each request finishes. Return `{"text": "..."}`; format numbers as strings.
Responses are displayed as text, so HTML stays escaped.

Requests never overlap for one component. Errors retain the last value and show
a status message while retrying; requests time out after 30 seconds. Hidden tabs
pause new requests, and removed components stop polling. Each component in each
visitor's browser polls separately. Use read-only actions and choose an interval
that fits your server load. JavaScript and a reachable Pyrite action server are
required; Pyrite supplies the browser code.

```sh
pyrite guide --agent --topic polling
pyrite guide app.py --plain --topic polling
```


## Request access and session cookies

Page handlers still take no arguments; action handlers still take one payload
dictionary. Import `request` to read the current HTTP request inside either:

```python
from pyrite import App, Page, Text, request

app = App()

@app.page("/")
def home():
    current = request()
    user_agent = current.headers.get("User-Agent", "unknown")
    return Page(Text(f"Client: {current.client_ip}; browser: {user_agent}"))
```

`request()` exposes `method`, `path` (without the query string), case-insensitive
`headers`, `cookies`, `query` (each value is a tuple of strings), and `client_ip`.
These mappings are read-only. `query("name", "default")` also works inside actions.
Request context is isolated per request and cleared on completion or failure;
`request()` raises `RuntimeError` outside a request. `client_ip` is the direct
socket peer, which may be your reverse proxy; forwarded IP headers are untrusted.
Cookies and headers are client input, not proof of authentication.

Inside a handler, use `request().set_cookie(name, value, max_age=3600)` and
`request().delete_cookie(name)`. Cookies default to host-only, `path="/"`,
`secure=True`, `httponly=True`, and `samesite="Lax"`. Use `secure=False` explicitly
for local HTTP. `max_age` is seconds; omit it for a browser-session cookie.
`delete_cookie` expires the browser cookie; use the original path and revoke
its server-side session too. Multiple cookies are sent as separate `Set-Cookie`
headers, and cookie changes are discarded if the handler or rendering fails.
Responses use `Cache-Control: no-store`.

Session login is now possible: validate credentials in a POST action, generate
an unpredictable opaque session ID, store its account and expiry server-side,
and set that ID as a cookie. Look up the ID and check expiry for every protected
page/action. Rotate IDs on login and revoke them on logout. `HttpOnly` keeps
JavaScript from reading the cookie; browser requests still send it automatically,
including `LiveForm`, `Chat`, and `Poll`. Use `Input(..., kind="password")` inside
a `LiveForm` for credentials; GET forms put their fields in the URL.

The repository includes a runnable single-account example with login, expiry,
session rotation, a protected account page, and logout:

```sh
PYRITE_DEMO_PASSWORD='choose-a-local-demo-password' python3 session_example.py
```

Open http://127.0.0.1:8000. The example uses an in-memory store and explicitly
allows local HTTP cookies. Sessions disappear on restart. A deployed application
needs HTTPS, shared persistent session storage, proper account/password storage,
and login rate limits. Pyrite supplies the request and cookie primitives;
application code owns authentication; `SessionStore` supplies bounded in-memory
session storage. The existing action
Origin/Fetch-Metadata checks remain active.

```sh
pyrite guide --agent --topic request
pyrite guide --agent --topic sessions
```


### Live action response contracts

`LiveForm`, action `Button`, and default text-mode `Poll` actions must return `{"text": "..."}`. `Chat` actions
must return `{"reply": "..."}`. Values must be strings; convert numbers with
`str()`. Free-form JSON dictionaries are for custom HTTP clients that implement
their own response schema. Structured polling uses `Poll(mode="children")` and `poll_response(...)`
for safe component JSON, including list and table elements. Arbitrary response
keys still require a custom client. A response error does not undo an action's
server-side changes; avoid blindly retrying mutations.

Run `pyrite doctor app.py` to flag statically recognizable response mismatches
for actions used by these components. Dynamic responses still need runtime tests.
Use `pyrite guide --agent --topic forms` for a focused reference; the output
includes the complete topic index, including `request`, `sessions`, `polling`,
`hover`, and `logs`. Enable runtime logging with
`App(log_path="pyrite-errors.jsonl")`; browse with
`pyrite guide --log pyrite-errors.jsonl`.

For POST logout, use `LiveForm(Submit("Sign out"), action="/logout")` and
revoke the session inside the action. `Button` navigates with GET.
See `session_example.py` for expiry, rotation, and revocation.

For a per-visitor counter with separate POST increments and read-only polling,
expiry, locking, and cache-control guidance, run
`pyrite guide --agent --topic polling`.


### Action buttons, sessions, and structured polling

`Button("Sign out", action="/logout")` sends a same-origin JSON POST with `{}`,
using the same status and error handling as `LiveForm`. Its action must return
`{"text": "..."}`. Supply exactly one of `href` or `action`; navigation buttons
keep their existing behavior. No automatic retry occurs for action buttons.

Create one store outside request handlers:

```python
from pyrite import SessionStore

sessions = SessionStore(ttl=3600)  # HTTPS; secure=False only for local HTTP
```

After validating credentials in a POST action, call
`sessions.create({"account_id": "alice"})`. This rotates the current session ID.
Protected handlers use `sessions.get()`, which returns an isolated dictionary
or `None`. `sessions.rotate()` preserves data, renews expiry, and replaces the ID;
it returns `False` for an expired or missing session. `sessions.destroy()` revokes
the record and expires its cookie. All mutations require POST. Reads never extend
expiry; modifying a returned dictionary does not save it. Use `create(updated)`
to save changes and rotate the ID.

The thread-safe store holds JSON data server-side, behind random opaque cookie
IDs. Defaults: one-hour expiry, at most 10,000 sessions, host-only HttpOnly
SameSite=Lax cookies with `path=/` and `Secure`. The default cookie name is
`__Host-pyrite_session`, or `pyrite_session` with `secure=False`. Configure
`ttl`, `max_sessions`, and `cookie_name` when needed. Writes remove expired records;
`sessions.prune()` also performs cleanup. At capacity, new sessions raise
`RuntimeError`; replacing the current session remains possible. Sessions disappear
on restart and are not shared across workers. Use shared persistent storage for
multi-process deployment. Store mutations are not rolled back if a later handler
step fails; finish validation first and return the documented response shape.
`session_example.py` demonstrates the complete login/logout flow.

For live lists and leaderboards, return safe component JSON:

```python
from pyrite import App, Card, Column, Heading, Page, Poll, Text, poll_response

app = App()

@app.action("/leaders")
def leaders(data):
    rows = [("Ada", 42), ("Lin", 37)]
    return poll_response(Column(*(
        Card(Heading(name, level=2), Text(score)) for name, score in rows
    )))

@app.page("/")
def home():
    return Page(Poll(action="/leaders", mode="children", interval=10,
                     initial=Text("Loading leaderboard…")))

if __name__ == "__main__":
    app.run(reload=True)
```

`poll_response` supports `Column`, `Row`, `Card`, `Heading`, and `Text`.
Plain list/table elements from `pyrite.core.Element` are also accepted. Links,
forms, scripts, event attributes, arbitrary classes, and inline styles are
rejected. The browser constructs elements and text nodes without `innerHTML`;
invalid responses preserve the last display. Limits: 2,000 nodes and 32 nesting
levels. Default `Poll(mode="text")` still expects a string `text` field.

### Development hot reload

Put `app.run(reload=True)` under the usual `if __name__ == "__main__":` guard,
then run your script or module. Python edits, additions, and deletions under the
working directory and entry script directory restart the worker and refresh open
browser pages. Hidden directories, virtual environments, and generated build
folders are ignored. Syntax/import failures leave the watcher running; save a fix
to retry. Ctrl+C stops both processes and releases the port. The reloader never
kills an unrelated process using the port; stop that process or choose another
port. `/__pyrite_reload__` is reserved while reload is enabled.

Reload re-executes startup code and clears in-memory state, including sessions.
Only Python files are watched. Ordinary `app.run()` keeps its existing manual
restart behavior. Both modes are for local development.

```sh
pyrite guide --agent --topic sessions
pyrite guide --agent --topic polling
pyrite guide --agent --topic reload
```


### Component placement and page headers

Use `.layout(...)` to position and size any component. It returns a new component;
repeated calls merge settings, and `.hover(...)`/`.themed(...)` preserve them.

```python
from pyrite import Card, Column, Header, Heading, Link, Nav, Page, Text

header = Header(Nav(Link("Brand", "/"), Link("About", "/about"))).layout(
    position="sticky", top=0, z_index=10,
)
badge = Card(Text("Top right")).layout(
    position="absolute", top=16, right=16, width=180,
)
content = Column(Heading("Placed with Python"), badge).layout(
    position="relative", min_height=360, padding=24,
)
page = Page(content, header=header)
```

`Header` renders `<header>`. The `Page(header=...)` slot places it before and
outside `<main>`, independent of the centered main width. `Nav` retains its
semantic navigation element and accessible label.

Positions: `static`, `relative`, `absolute`, `fixed`, `sticky`. Absolute children
use the nearest positioned ancestor; give that container `position="relative"`.
Fixed content uses the viewport. Sticky content uses its offset, such as `top=0`.
Absolute/fixed content reserves no space; account for overlap and small screens.
Visual placement does not change reading or keyboard order.

Numbers are pixels. Strings accept numeric `px`, `%`, `rem`, `em`, `vw`, `vh`,
`svh`, `dvh`, or `ch` units. Offsets and margins may be negative; offsets, width,
height, and margins also accept `"auto"`. Other lengths are nonnegative.
Lengths have a maximum absolute magnitude of 100,000. `padding` and `margin`
accept a scalar or tuple of one to four lengths in CSS order.

Available settings:

- Placement: `position`, `top`, `right`, `bottom`, `left`, `z_index`.
- Sizing: `width`, `height`, `min_width`, `max_width`, `min_height`, `max_height`.
- Spacing: `gap`, `padding`, `margin`.
- Flex/grid: `display`, `direction`, `align`, `justify`, `wrap`, `columns`, `order`.
- Clipping/scrolling: `overflow`.

For a custom row, use
`Row(...).layout(gap=12, justify="space-between", align="start", wrap=False)`.
For equal grid columns, use
`Column(...).layout(display="grid", columns=3, gap=16)`.
`columns` accepts 1–24; `z_index` and `order` accept integers from -100,000 to
100,000. Omitted/`None` values leave previous settings unchanged. Choose grid
columns for the target screen; this API does not add automatic breakpoints.
Layout values are validated and component text remains escaped. Doctor checks
known component layout calls and literal values without executing the app.

```sh
pyrite guide --agent --topic layout
```
