SWL 5.3.0 · download as pdf · source on github

SWL reference

SWL (Seecr Web Lib) is a small layer on top of Starlette, Jinja and htmx 4. It does not need a CSS framework: its modal, dialog, toast and context menu are plain HTML (<dialog>, popover), and it can bring its own base styles or work with Bootstrap 5 (see Styling). This page lists everything SWL itself provides. For a guided introduction, read example/README.md first; this page is for looking things up.

Setting up an application

from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from seecr_stdlib.config import Config
from swl import SWL, static_dir

swl = SWL(config=Config({}), templates=[my_templates_dir])  # your templates first, then SWL's
swl.register_modules(my_views)                               # modules with __actions__
swl.register_action_kwargs(db=db)                            # extra arguments for every action
swl.add_template_globals(VERSION=swl.SWL_VERSION)            # used by swl/base.j2

app = Starlette(routes=[
    swl.action_route,                                        # /action/{action}
    Route("/", swl.endpoint(page, redirect="/login")),       # a page for logged-in users
    Mount("/static", StaticFiles(directory=static_dir)),     # swl.js and friends
])

Login needs Starlette’s SessionMiddleware and AuthenticationMiddleware with swl.session.SessionAuthBackend(userbase); see example/swl_tasks/app.py for a complete setup with users, sessions and the websocket.

The SWL object

MemberPurpose
SWL(config, templates=[])templates: your template directories, searched before SWL’s own
register_modules(*modules)register every function in each module’s __actions__
register_action_kwargs(**kwargs)keyword arguments passed to every action (swl is always passed)
action_routethe Starlette route for all actions
register(name)url of the action with that function name; raises if it does not exist
endpoint(fn, authenticated=True, is_authorized=None, redirect=None, paths=None)wrap a page function: same arguments as an action, with a login check. redirect: where to send users who are not allowed (the requested path is kept in session["next"]); without it they get 403. paths: see Listing the routes
success(), fail()start a response, see Responses
template(name)a Jinja template, to .render(...) into a string
template_response(request, name, context)a full page response
add_template_globals(**kwargs), add_template_filters(**kwargs)extend Jinja
styles, scriptsthe web resources, used by swl/base.j2
routes_routethe /.routes endpoint, see Listing the routes
SWL_VERSIONversion of SWL

Actions

An action is an async function that the browser can post to:

async def task_save(request, swl, db, **kwargs):
    async with request.form() as form:
        ...
    return swl.success().toast("Saved")

__actions__ = [
    task_save,                                               # login required
    dict(method=login, authenticated=False),                 # open to everyone
    dict(method=admin_only, is_authorized=user_is_admin),    # login + your own check
]

Responses

swl.success() and swl.fail() return a builder. Each method returns the builder, so calls can be chained; the browser carries them out after the new content is in place, in the order they were called.

MethodEffect in the browser
hydrate(id, html, rehydrate=None)the element with that id gets html as content. rehydrate=dict(action=url, context={...}) also changes how it reloads (see rehydrate). Hydrating the same id twice keeps the last
rehydrate(*ids, context=None)these placeholders reload themselves; context is added to that one request
open_modal(html, size="lg", scrollable=False, call=None, rehydrate=None)html in the modal; size: "sm", "lg" or "xl"; call: function name(s) to run once the modal is visible
hydrate_modal(html, rehydrate=None, call=None)replace the content of the (open) modal; call runs when it is or becomes visible
close_modal()close the modal
open_contextmenu(html, location)html as a menu at location={"x": ..., "y": ...} (use the posted event_x/event_y)
toast(text, delay=None, background=None)a short message; text may be HTML; defaults from the config
call(function, **kwargs)window[function](kwargs)
trigger(event, **detail)dispatch your own browser event with event.detail = detail; it bubbles up to document
reset_form()reset the form of the element that made the request
session_store(key, value)sessionStorage[key] = JSON.stringify(value)
set_url(url, **params)replace the address bar url (no page load)
redirect(url)load url as a new page
swl.fail().message(text)show text in the error dialog; nothing else happens

html is inserted as HTML. Render it from a Jinja template (autoescaped) or escape user input yourself.

What goes over the wire, in case you look at it in the browser:

ResponseStatusBodyHeaders
success with hydrate/open_modal/toast/…200<hx-partial hx-target="#id" hx-swap="innerHTML">html</hx-partial> per elementHX-Trigger: {"swl-...": {...}}
success without content204emptyHX-Trigger, HX-Redirect, HX-Replace-Url as needed
fail422emptyHX-Trigger: {"swl-error": {"message": "..."}}, HX-Reswap: none

The event names in HX-Trigger are swl-call, swl-rehydrate, swl-recipe, swl-modal-open, swl-modal-call, swl-modal-close, swl-contextmenu, swl-toast, swl-reset-form, swl-session-store and swl-error, plus your own from trigger().

What a request sends

ElementSends
button, input, select, textarea with hx-postall fields of its form: the enclosing <form>, or the one named in its form="..." attribute
form with hx-post (posts on submit, also on Enter)its fields
anything else (div, a, tr, span, a placeholder)only its hx-vals and the fields of the elements in its hx-include, not the fields inside or around it
swl_action(url, values) in javascriptonly values

On top of that:

Placeholders and rehydrate

A placeholder is an element that loads its own content from an action:

{% from "swl/placeholder.j2" import placeholder %}
{{ placeholder("orders", register('orders_list'), context={"customer": customer.id}) }}

renders

<div id="orders" hx-post="/action/..." hx-trigger="load, swl-refresh" hx-vals='{"customer": "c12"}'></div>
ArgumentMeaning
idelement id, posted as placeholder
actionaction url
contextvalues posted with every request (hx-vals)
on_changedlist of qids; reload when the server signals one of them changed (live updates)
lazy=Truedo not load with the page, only when rehydrated
triggerextra htmx triggers, e.g. "input delay:300ms from:#filters"
attrsextra attributes, e.g. {"hx-include": "#filters", "hx-sync": "this:queue last"}
tag, class_element tag (default div) and class

The action hydrates the placeholder: swl.success().hydrate(placeholder_id, html). Read the id from the posted placeholder field when the same action serves several placeholders.

Rehydrate asks a placeholder to reload itself, with the action and values it already has:

Any element can be rehydrated once it has a “recipe”: an action and a context. A placeholder has one from the start. hydrate(id, html, rehydrate=dict(action=url, context={...})) sets or changes it, so the element reloads from that action with that context from then on (it becomes hx-post/hx-vals on the element).

Rehydrating only reloads the element itself, not placeholders around it. An id that is not on the page is ignored.

Live updates

  1. Route WebSocketRoute("/ws", dispatcher.endpoint) with a swl.dispatcher.Dispatcher(), and load /static/swl/websocket.js in the page.
  2. Give placeholders on_changed=["<qid>", ...] (or the attribute data-swl-on-changed="qid1 qid2").
  3. After a change, await dispatcher.signal_change(obj); obj.qid is the qid (or pass the qid itself). Every open page reloads the placeholders that list it.

The qid is any string you choose: an object id ("Order:123") to refresh only pages that show that order, or a collection name ("orders"). The websocket needs uvicorn[standard] (or websockets) on the server and websocket support in any proxy in front of it.

Only some users. Without more, the qid goes to every open page, also to pages that do not show it; only the reload itself checks who may see what. When the qid tells something (a private conversation, "Chat:alice:bob"), send it to the users concerned only:

await dispatcher.signal_change(conversation, users=["alice", "bob"])

The example’s private tasks do this (_signal_change in example/swl_tasks/views.py).

A page belongs to the user that was logged in when it connected: websocket.user.identity, Starlette’s BaseUser.identity, so the AuthenticationMiddleware must run for the websocket too (it does when it is app middleware). For SWL’s Userbase users the identity is the username; an application’s own user class implements identity. A user with the page open on two devices gets it on both. Dispatcher(identify=fn) takes another function fn(websocket) -> identity or None.

Dispatcher
Dispatcher(authenticated=False, identify=...)authenticated=True: refuse websockets without a logged-in user (close code 1008)
await signal_change(obj, users=None)obj: object with a qid, or the qid; users: identities (or one) to send it to
connected_users()identities with at least one open page
Dispatcher(idle_timeout=seconds)close a page that sent nothing for that long (websocket.js pings every 10 s; background tabs about once a minute, so keep it above that)
async user_connected(identity) / async user_disconnected(identity)for subclasses: the first page of a user connected, the last one closed

The user is looked up once, when the page connects. After a logout the page goes to the login page, which closes its websocket.

Missed changes. websocket.js reconnects when the connection drops (with a growing delay, at most 30 s). Changes signalled while it was down do not reach the page, so after a reconnect it reloads every placeholder with on_changed. A connection that dies without closing (a phone that slept, another network) is noticed by a ping without an answer within 5 s; when the page becomes visible again or the browser comes back online it pings at once.

A screen that actions built up (a placeholder inside a placeholder inside a placeholder, a selected item, an open modal) is not in the url. Reload the page or open a bookmark, and you are back at the start. A deep link fixes that in three steps:

  1. Describe the screen in the url. The action that changes the screen calls set_url(...), e.g. set_url(f"/orders/{order_id}") or set_url("/orders", order=order_id). The address bar changes; nothing is loaded.

  2. Route that url. A Starlette route accepts it (path parameters or query parameters) and renders the page.

  3. Rebuild the screen from what the url says, in one of two ways:

    • Placeholder context, when the state is what a placeholder shows (a filter, a page number, which order the detail panel shows): render the placeholder with that state as its context. It loads with it straight away, and every level can pass on what the level below needs, all the way down. example/swl_tasks does this for its search and status filter.
    • Kickstart, when the state is the result of an action (an open modal, a context menu, something an action hydrates into a nested placeholder): render the page with kickstart={"action": swl.register("..."), "kwargs": {...}}. After the page has loaded and its placeholders have loaded (no requests for 100 ms, or at most 5 seconds), the action is posted with those values, and its response is handled like any other. Because it waits, it can hydrate elements inside nested placeholders.
async def order_show(request, swl, db, **kwargs):
    """Opens an order in the modal, and puts it in the url."""
    async with request.form() as form:
        order_id = form["order"]
    html = swl.template("orders/order.j2").render(order=db.order(order_id))
    return swl.success().open_modal(html).set_url(f"/orders/{order_id}")


async def orders_page(request, swl, **kwargs):
    context = {"title": "Orders", "body_class": ""}
    if (order_id := request.path_params.get("order_id")) is not None:
        context["kickstart"] = {"action": swl.register("order_show"), "kwargs": {"order": order_id}}
    return swl.template_response(request, "orders/page.j2", context)


routes = [
    Route("/orders", swl.endpoint(orders_page, redirect="/login")),
    Route("/orders/{order_id}", swl.endpoint(orders_page, redirect="/login")),
]

Things to know:

example/swl_tasks shows the placeholder-context way for a list with a detail panel and tabs: the url is /tasks/<id>/<tab>?q=..., and a reload shows the same task on the same tab (section 7 of its README).

Tabs

There are two ways to make tabs. The examples use Bootstrap’s nav-tabs look; with any framework you can use <nav class="swl-tabs"> with buttons that have aria-selected="true" on the active one (see example/swl_tasks/templates/tasks/detail.j2). The second way needs Bootstrap’s javascript.

Tabs rendered by the server

The tabs are buttons that post an action with the tab they stand for. The action renders the whole tab panel with that tab active and only that tab’s content, and puts it in the url:

{# panel.j2, rendered by the item_panel action #}
<ul class="nav nav-tabs">
    {% for name in tabs %}
    <li class="nav-item">
        <button type="button" class="nav-link {{ 'active' if name == tab }}"
                hx-post="{{ register('item_panel') }}"
                hx-vals='{{ {"item": item.id, "tab": name}|tojson }}'>{{ name|capitalize }}</button>
    </li>
    {% endfor %}
</ul>
<div class="p-3">{% include "items/tab_" ~ tab ~ ".j2" %}</div>
async def item_panel(request, swl, db, **kwargs):
    async with request.form() as form:
        item_id = form["item"]
        tab = form.get("tab") if form.get("tab") in TABS else TABS[0]  # from the url: check it
    html = swl.template("items/panel.j2").render(item=db.item(item_id), tab=tab, tabs=TABS)
    return swl.success().hydrate("item-panel", html).set_url(f"/items/{item_id}/{tab}")

The server always knows which tab is open, so a page route for /items/{item_id}/{tab} can render the same panel straight into the page and a reload shows the same tab (see Deep links). This is the simplest way, and the one to use by default.

Bootstrap tabs, loading when shown

Bootstrap switches the panes in the browser; each tab loads its own content when it is shown (Bootstrap’s show.bs.tab event). Use this when tabs are expensive to render and you don’t want to render them before they are opened.

<ul class="nav nav-tabs">
    <li class="nav-item">
        <button type="button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-overview"
                hx-post="{{ register('item_tab') }}" hx-vals='{"tab": "overview"}'
                hx-trigger="load, show.bs.tab">Overview</button>
    </li>
    <li class="nav-item">
        <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-history"
                hx-post="{{ register('item_tab') }}" hx-vals='{"tab": "history"}'
                hx-trigger="show.bs.tab">History</button>
    </li>
</ul>
<div class="tab-content">
    <div id="tab-overview" class="tab-pane active"></div>
    <div id="tab-history" class="tab-pane"></div>
</div>

This replaces data-swl-tab from SWL before htmx.

Templates

swl/base.j2

The page skeleton: the web resources (see Styling), htmx, SWL’s styles, the message dialog and the toast.

Load SWL itself in body_extra:

<script type="module" id="swl-run" src="/static/swl/swl-run.js"></script>
<script type="module" src="/static/swl/websocket.js"></script>  <!-- only for live updates -->

Other templates

TemplateWhatNeeded for
swl/modal.j2the modal: a <dialog id="swl-modal"> with #modal-contentopen_modal, hydrate_modal
swl/modal-content.j2base for modal content: blocks modal_title, modal_content, and optionally modal_icon (left of the title, e.g. <span class="swl-avatar swl-avatar-lg">), modal_subtitle (a line below it) and modal_footer. Any button with data-swl-close (or data-bs-dismiss="modal") closes the modal
swl/contextmenu.j2the context menu: a popover #swl-contextmenu; put a <div class="swl-menu"> with buttons in itopen_contextmenu
swl/dialog.j2, swl/toast.j2message dialog (<dialog>) and toast (popover); included by base.j2errors, hx-confirm, toast
swl/placeholder.j2the placeholder macro
swl/inputs.j2macros text_input, password_input, number_input, date_input, textarea_input (Bootstrap floating labels)
swl/card.j2, swl/footer.j2, swl/alert.j2, swl/breadcrumbs.j2small Bootstrap pieces (older; need Bootstrap’s CSS)

Styling

Choose a CSS framework with one config key:

swl = SWL(config=Config({"ui": {"framework": "bulma"}}))
ui.frameworkLoads1Use it for
"bootstrap" (default)Bootstrap 5.3 CSS and JS, Bootstrap Icons, htmxexisting applications built on Bootstrap
"tabler"Tabler 1.6 (an admin design on top of Bootstrap 5, with its own javascript bundle that includes Bootstrap’s), Bootstrap Icons, htmxadmin screens and dashboards; a Bootstrap application gets Tabler’s look without changing its templates
"bulma"Bulma 1.0 (CSS only), Bootstrap Icons, htmxapplications that like Bulma
"swl"SWL’s own swl-base.css, Bootstrap Icons, htmxno CSS framework at all

The choice does two things:

SWL’s modal, message dialog, toast and context menu do not depend on the framework: they are plain HTML (<dialog>, popover) styled by swl.css. Their colours are CSS variables that follow Bootstrap’s, Tabler’s or Bulma’s colours when one is loaded; a theme is a few lines in your own stylesheet:

:root { --swl-accent: #FAA21B; --swl-radius: .25rem; }

--swl-bg, --swl-fg, --swl-muted, --swl-border, --swl-subtle-bg, --swl-accent, --swl-accent-fg, --swl-radius, --swl-danger, --swl-warning, --swl-success, --swl-info.

swl.css also has layout classes that work with every framework: swl-container, swl-nav (top bar, with swl-brand), swl-columns (columns that stack on small screens; swl-wide makes the first one wider), swl-card (with a <header> and swl-card-body), swl-tabs, swl-stack, swl-row, swl-end, swl-field (a label above an input), swl-list, swl-badge, swl-muted, swl-small, swl-hidden, swl-link (a button that looks like a link), swl-icon-button and swl-menu. The example application uses only these and SWL.ui, so it runs with each framework: python -m swl_tasks --framework bulma.

With "swl", swl-base.css styles plain HTML (forms, buttons, tables, headings) and follows the browser’s light or dark mode (Bulma does too; Bootstrap only with data-bs-theme).

Light and dark. /static/swl/theme.js lets the visitor choose light, dark or automatic (the computer’s setting) for every framework: it sets data-theme (swl-base.css, Bulma) and data-bs-theme (Bootstrap) on <html> and remembers the choice in the browser. Load it in the head, so the page is painted in the right colours at once, and call swl_theme.set("dark"), "light" or "auto" from a button; a swl-theme event on document reports changes:

{% block head_extra %}<script src="/static/swl/theme.js"></script>{% endblock %}

Switching the framework in the browser (for a demo or a style guide): add swl.frameworks.FrameworkCookieMiddleware to the application. A cookie swl_framework with a framework name (swl, bootstrap, tabler, bulma) then overrides ui.framework for that visitor; set it from javascript and reload the page. The example application has both switches in its top bar (templates/tasks/style_switch.j2, static/tasks.js).

In every template: register(name) and SWL (the SWL object).

Built-in views: swl.views.account

Register with swl.register_modules(account) and register_action_kwargs(userbase=...):

Action
loginopen to everyone; posts username, password, next; redirects to next
show_account“My account” modal to change your own password (change_password, confirms with a toast)
users_showmodal with all users; creating, deleting and making admin need an admin

account.logout is a page function for a /logout route.

Javascript

On window after swl-run.js has loaded:

swl_action(url, values) / swl.action(...)post to an action from javascript; values is an object or a query string; returns a promise
swl.rehydrate(id, context)reload a placeholder
swl.kickstart(url, values)like swl_action, but first waits until the page and its placeholders have loaded
swl.ui.confirm(message)SWL’s confirm dialog; a promise of true/false
swl.ui.error(message), swl.ui.info(message)message dialogs
swl_theme.get(), swl_theme.set("light" | "dark" | "auto")light or dark colours; only when the page loads /static/swl/theme.js (see Styling)

Events you can listen to:

swl-modal-shown on #swl-modalthe modal opened (open_modal)
close on #swl-modalthe modal closed (a close button, close_modal()); it is a native <dialog>
swl-theme on documentthe colours changed (theme.js); e.detail.mode
your own, on documentswl.success().trigger("name", **detail)

Functions the server calls with call() or open_modal(call=...) must be global: define them in a classic <script> or assign them to window in a module.

Attributes SWL adds to htmx:

hx-confirm="..."shown in SWL’s dialog instead of the browser’s (hx-confirm="js:..." is left to htmx)
data-swl-validate="fn"window.fn() must return true, or the request is not sent
data-swl-on-changed="qid ..."see live updates
data-swl-closeon a button inside a <dialog> (the modal): closes it; data-bs-dismiss="modal" works too

Everything else is plain htmx 4. The texts swl.js shows (dialog buttons, error messages) are put in the page by base.j2, in the page’s language (see Translations).

Translations

Texts are written in English and marked with _(); SWL picks the language per request and translates them with gettext (Babel). SWL’s own texts (dialogs, account and user screens, messages of swl.views.account) come in Dutch, German, French and Spanish. The example (example/README.md, “Translations”) is translated into Dutch, German and Spanish.

Marking texts. In Python:

from swl.i18n import _, ngettext, N_

swl.fail().message(_("Passwords do not match"))
_("Delete %(name)s?", name=user.name)
ngettext("%(num)d user", "%(num)d users", len(users))
STATUSES = {"open": N_("Open")}       # marked, translated later: _(STATUSES[s])

_() translates when it is called, in the language of the request it is called for; texts in module constants are marked with N_ and translated where they are used. In templates:

{{ _("Users") }}  {{ _("Delete %(name)s?", name=user.name) }}
{% trans count=users|length %}{{ count }} user{% pluralize %}{{ count }} users{% endtrans %}

Choosing the language. Add the middleware after the AuthenticationMiddleware and list the languages:

swl = SWL(config=Config({"i18n": {"locales": ["en", "nl"]}, ...}))
app = Starlette(..., middleware=[..., Middleware(AuthenticationMiddleware, ...),
                                 swl.i18n_middleware])

For every request and websocket it takes the first of: the application’s selector (swl.i18n.locale_selector(fn), fn(conn) returns a language or None; conn.user is there, e.g. for a language in the user’s profile), the cookie swl_lang, the browser’s Accept-Language, the first of i18n.locales. Only listed languages are used, nl-BE matches nl. Without the middleware everything is English, as written.

Catalogs. An application keeps its translations in <package>/locale:

python -m swl.i18n_tool extract enigma        # texts -> enigma/locale/messages.pot
python -m swl.i18n_tool init enigma nl        # once per language: .../nl/LC_MESSAGES/messages.po
python -m swl.i18n_tool update enigma         # after extract: new texts into every .po
python -m swl.i18n_tool compile enigma        # .po -> .mo, which is what is used

and adds them with swl.i18n.add_translations(path / "locale"); they win over SWL’s. Commit the .po and the .mo files.

Dates and numbers. Template filters (and functions in swl.i18n) in the language of the request: format_date(d, "medium"), format_time, format_datetime(dt, tzinfo=...), format_skeleton(d, "MMMEd") (Mon, Nov 3 / ma 3 nov), format_number, format_timedelta.

SWL.langthe language of the request for <html lang> (nl, pt-BR)
swl.i18n.activate(locale) / deactivate(token)a language outside a request (tests, jobs)
swl.i18n.current_locale()"nl", or None outside a request

Configuration

SWL(config=Config({...})) reads:

KeyDefault
actions.path/actionurl prefix of actions
actions.hashtruefalse: use function names in urls instead of hashes
ui.framework"bootstrap""swl", "bootstrap", "tabler" or "bulma"; see Styling
paths.resourcesthe framework’syour own JSON file with styles and scripts for base.j2 (must include htmx)
urls.cdnhttps://cdn.jsdelivr.net{BASE_URL} in resource urls; base.j2 preconnects to it
response.toast.delay500toast duration in ms
response.toast.backgroundnonetoast background color
i18n.locales["en"]the languages to use, the first is the default; see Translations
i18n.cookieswl_langthe cookie that chooses a language
routes.allowlistROUTES_ALLOWLISTwho may read /.routes: IPs/CIDRs, a list or a comma-separated string; see Listing the routes

Testing

Post to actions with Starlette’s TestClient; swl.test_utils checks SWL responses:

from swl.test_utils import assert_fail, assert_success

response = client.post(swl.register("task_save"), data={"title": "x"})
events = assert_success(response)            # status 200/204; returns the HX-Trigger events
assert events["swl-toast"] == {"delay": 500}

assert_fail(client.post(swl.register("task_save"), data={}), "A task needs a title")

response.text holds the <hx-partial> elements, response.headers["HX-Redirect"] etc. the rest. example/swl_tasks/app_test.py has complete examples, including login and the websocket.

Debugging

Listing the routes

GET /.routes answers with JSON listing every route of the app (path, an anchored regex, methods, …), e.g. for a proxy that only forwards known paths. Add it to the app’s routes:

swl = SWL(config=Config({"routes": {"allowlist": ["127.0.0.1", "10.0.0.0/8"]}}))
app = Starlette(routes=[..., swl.routes_route])

Only clients in the allow list get an answer, all others a 404. The allow list holds IPs and CIDRs, as a list or a comma-separated string. swl.routes_route takes it from the config key routes.allowlist; without that key from the environment variable ROUTES_ALLOWLIST (default 127.0.0.1,::1). Behind a proxy the client is the proxy, unless the ASGI server trusts its X-Forwarded-For (uvicorn --proxy-headers --forwarded-allow-ips=<proxy-ip>).

A route with a parameter, like /{slug}, is listed as a pattern that matches every path of that shape. To list only the paths that exist, give its endpoint a paths function. It gets the same arguments as the page function (sync or async) and returns the paths, relative to the route’s Mount like the route’s own path. It is called on every request to /.routes; each path becomes a static entry with an exact regex, and the pattern itself is left out:

def project_paths(request, store, **kwargs):
    return [f"/{project.slug}" for project in Project.all(store) if project.active]

Route("/{slug}", swl.endpoint(project_page, authenticated=False, paths=project_paths))

Troubleshooting

Nothing happens when I click.

An error dialog says the action is not allowed (403). Not logged in, the session expired, or is_authorized returned false. Register open actions with authenticated=False.

My fields are not posted.

An attribute on a parent element is ignored. htmx 4 does not inherit attributes unless asked: write hx-confirm:inherited, hx-vals:inherited, hx-target:inherited.

Advice I found online does not work. Most htmx examples on the web are for htmx 2. Common differences in htmx 4: inheritance is explicit (above); events are named htmx:after:swap instead of htmx:afterSwap; hx-disable is now hx-ignore and hx-disabled-elt is now hx-disable; hx-vars and the HX-Trigger-After-Settle header are gone. See https://four.htmx.org/whats-new-in-htmx-4.

After a reload the screen is back at its start. The screen is not in the url. See Deep links.

A kickstarted action does not show anything. Check the page source for the kickstart script (did the route set kickstart?) and the network tab for the request. If its target is inside a placeholder, that placeholder must load without the kickstart’s help: the kickstart waits for loading to stop, it cannot make a placeholder appear.

The page reloads when I press Enter. A form with an action or method attribute submits normally. Remove them (SWL stops Enter in bare forms), or give the form hx-post.

hydrate runs but nothing changes. The id in hydrate() must match an element on the page, exactly (it is not a CSS selector). The modal content is "modal-content" (swl.responses.MODAL_CONTENT); it only exists when the page includes swl/modal.j2.

The modal, context menu or toast does not show. Include swl/modal.j2 / swl/contextmenu.j2 in the page; base.j2 includes the toast and dialog. They need a browser with <dialog> and the popover API (all current browsers). An application that has its own Bootstrap modal markup with id swl-modal needs Bootstrap’s javascript.

“Function not found” for my call(). The function is not global; see Javascript.

A rehydrate does nothing. The element must be on the page and be a placeholder (or have a recipe from hydrate(..., rehydrate=...)). Check the id.

My placeholder loads twice / requests are cancelled. Two triggers fire close together (a rehydrate and a live update, or fast typing). Add hx-sync: "this:queue last" runs them one after another and keeps the last; "this:replace" cancels the running one.

Live updates do not arrive. Check that websocket.js is loaded, the /ws route exists, the server has uvicorn[standard] (a log line “No supported WebSocket library detected” means it doesn’t), the qid in signal_change matches on_changed exactly, and that signal_change is awaited. With users=...: the page’s user must have that identity (connected_users() shows who is connected); with authenticated=True: the websocket must get the session cookie.

Error pages appear inside my page. The page lacks the htmx config from base.j2: <meta name="htmx-config" content='{"noSwap": [204, 304, "4xx", "5xx"]}'>.

pip install fails on seecr-stdlib. SWL needs seecr-stdlib, which is not on PyPI. Install it from Seecr’s repository first.


  1. All from cdn.jsdelivr.net, with integrity hashes. ↩︎