The preview registered the {contact:} directive unconditionally, so with
no configured label it showed the button with the 'Contact us'
placeholder while the served page renders nothing. Mirror the page
logic in the preview render path.
In-article buttons (the {contact:} trigger and raw <button>s, not the
editor chrome) get a / slanted parallelogram clip-path in the nitro
theme: nav-tab-style bezier sweeps at the obtuse corners, small arcs at
the sharp ones, fixed 1.7em height so every button shares one shape.
Base stylesheet: the contact envelope's line-height no longer inflates
the button, and article buttons get a slight scale(1.01) hover lift.
The contact-email frame now carries the RFC-encoded recipient (John
Smith <john@example.com> → John%20Smith%20%3Cjohn@example.com%3E),
encoded server-side where the current brand lives; the link text stays
the bare address and the localized subject rides the query as before.
Data.contact_enabled is gone: an empty contact label disables messaging
(no buttons, no visitor icon, sends rejected). The settings row is one
line — flag selector, label, email, subject as small boxes with tooltip
instructions — and saves re-render the page in place so button/dialog
strings update without a reload; the open dialog also re-reads its
strings on page swaps. The built-in Contact us default survives only as
the inert preview placeholder.
The site settings' messaging block now opens with the article editor's
LangSelect (the flag dropdown on the shared shell-wide editorLang, same
option construction) instead of a plain select, and the enable checkbox
is fused into the "messaging" section header — one line for the whole
header. The separate enabled field row is gone.
The contact bundle submits each solved challenge immediately and treats
{quota} frames as the grant that releases queued sends/uploads; an
upload answered 429 "pow" re-registers for a fresh challenge and
retries once it lands (Composer's reauthorize prop). The contact email
frame fills the dialog's mailto link only after a solve. Visit trails
render the ✉️ marker on the step where the panel was opened and the
proof solved.
The client now submits each solved challenge as soon as it is ready
({token, pow}); a validated proof grants a quota of 10 messages/uploads
(RAM, keyed by token hash so the HTTP upload route shares it) and a
fresh challenge is pushed only when the quota runs out. The site
contact email leaves the page asset config and is revealed over the
socket only after a solve. A validated proof also records a
server-originated contact marker in the analytics raw log, which
display() lands on the visit's trail step where it first happened.
docs/messages.md gains the site-settings/localization section (enabled
toggle, mailto link, chunk-keyed string translations and their manual
override UI); docs/localization.md notes the site strings riding the
title/nav pipeline; AGENTS.md's i18n/translate bullets match.
The site editor gains a messaging block: enabled toggle, contact email,
and label/subject fields with a language selector that switches them to
editing per-language overrides (the manual-override UI for the strings
auto translators fill). The visitor dialog titles itself with the
localized label and shows a mailto link (localized subject prefilled)
when an email is configured; pagerite.js hides the corner icon from
visitors when messaging is disabled.
GET reports contact_enabled/label/email/subject; with a translation
?lang= it also reports the per-language label/subject overrides (*_tr).
PUT accepts the contact fields; with a translation ?lang= only the
label/subject apply, written as overrides into the chunk-keyed trans
store (i18n.set_string_translation) — empty or the original's text
drops the override.
pending_items emits the effective contact label/subject as title-kind
fragments (chunk-keyed trans storage, like node titles), and nav-mode
jobs carry them as extra top-level list items, so Seed-X and LLM
translators localize the contact chrome with the menu. Manual overrides
land in the same store via i18n.set_string_translation.
Data gains contact_enabled/contact_label/contact_email/contact_subject.
The label and subject localize via the chunk-keyed trans store
(i18n.site_string), ride the page asset config as pagerite:contact-*
(absent when disabled), and default the {contact:} button label; a
per-button {contact: Label} arg still wins. Disabled messaging renders
no buttons and rejects visitor sends; admins keep the inbox.
Nav family/weight become --font-nav/--nav-weight (base defaults body/400; nitro heading/700, corporate 600), referenced by both #nav and .contact-trigger. The button keeps the article's size and line-height.
.editor-pens button's font: inherit out-specified .contact-icon; scope the size bump to .editor-pens .contact-icon. The visitor panel only ever existed while open (the overlay is v-if'd), so its state.open watch never fired: scroll to the newest message on mount instead, with the message-count watch covering sent/received while open.
Unread state (new reply for visitors, unanswered threads for admins) now animates the trigger button and the corner icon themselves: a scale-up shake every 4.5 s, gated on prefers-reduced-motion, stopping when read (the has-unread class goes away). The static accent dot and the admin count pill are gone.
The admin inbox leaves the short-lived /_m page and joins the visitor panel as one overlay (ContactOverlay shell over paskia's shared blur backdrop, centered dialog, full-screen on mobile): the corner ✉️ icon opens the visitor thread for visitors and the wider sidebar inbox for admins, while {contact:} trigger buttons always open the visitor side so admins can test both. The icon highlights unread state without extra connections: visitors via localStorage seen/last-admin timestamps, admins via the contact_unread count added to the /_api/settings probe, both kept live by the bundle once loaded. MessagesView owns its admin socket only while the overlay is open.
The panel was a right-docked sidebar with its own translucent backdrop. It is main content: now centered at the paskia dialog's dimensions (min(36rem,100%) x min(42rem,92vh)), full-screen below 36rem, holding the refcounted global blur backdrop (z 1099) while open with the panel's click-catch overlay at 1100 (below the auth iframe at 9999). Escape closes too.
The filled button now hovers like nitro's accent nav tab: text --ink (falling back to --bg on themes without it) switching to the bright --tab-hover with the same text-shadow glow, instead of the invented fill shift.
The {contact:} button was a hollow outline that inverted wholesale on hover; now it is filled with the accent (--bg text, the pairing accents are chosen to contrast with), hover only nudges the fill toward the page text color, and keyboard focus gets an outline. The button also gains margins so it separates from surrounding text both standalone and inline.
The inbox moves out of the analytics tab to its own page at /_m (own pens-row icon, own entry and socket lifecycle; the admin-gated /_api/ws/analytics socket keeps serving both, with typed messaging frames ignored by AnalyticsView). Admin replies are labeled with the currently configured site name in the wire payload (the SSO name stays stored for audit), and the thread view is capped to roughly the visitor panel's width.
Email is no longer required and its format is not checked anywhere
(placeholder notes it's optional); only the name gates the first
message. Timestamps are just the analytics relative format — no full
date on hover.
- The first message of a thread must carry name + email introducing the
visitor; the panel shows the two fields until the thread has an
identity (send stays disabled while they look empty, so the draft is
never lost), prefilled from and saved to localStorage. The identity
is stored on ContactThread (msgspec defaults — old databases decode
unchanged) and fixed afterwards; it labels the visitor's messages
instead of 'you', and the admin inbox lists threads by name with the
email as a mailto in the thread header.
- Timestamps use the analytics relative formatter (formatWhen), with
the full date on hover.
- Message headers alternate alignment (own messages right, theirs
left) as the visual side cue; the sent-from page link is dropped
from the display (kept as stored metadata).
- Composer gains a sendDisabled prop for the intro gate.
Drop the bubble styling (muted/accent backgrounds, rounded boxes,
left/right alignment) from both the visitor panel and the admin inbox:
messages now stack full-width as a flat thread with hairline
separators, a small muted meta line (author + time) above the body,
and 'you'/'visitor' fallback labels carrying the side distinction
instead of color.
Move the PoW state from a module-level dict keyed by WebSocket in
messages.py to a plain per-connection local in tracking.activity_ws,
making PoW a WS-level concern instead of being buried in the message
handler:
- activity_ws validates and burns the solution (raw.pop('pow')) before
forwarding a message frame, and issues a fresh challenge after every
registration and every vetted message frame; a failed validation is
answered with a 'required' challenge and the frame never reaches
messages.py.
- messages.py loses the _pow dict, _issue_pow and the disconnect
cleanup for it; visitor_frame needs no return value since a vetted
frame always triggers a fresh challenge (a too-long message simply
comes with contact-error and required=false, so the client does not
retry it).
The wire protocol and the frontend are unchanged.
The bridge routes {type: pow} frames to the panel. contact-main solves
each fresh challenge in the background with the backend-served easypow
solver module (imported by URL from a variable so Rollup leaves it
dynamic), aborting stale solves on replacement challenges. Sending
awaits the current solution and burns it with the message; a
'required' rejection re-solves and retries the message once.
Composer and ContactPanel need no changes: uploads stay ungated, and
the send path just awaits the in-flight solve.
New dependency: easypow (PBKDF2-SHA512 challenges, bundled JS solver).
Sends are PoW-gated: registration/resync and every accepted message
come with a fresh single-use challenge (~5 s solve), held in per-socket
RAM state only — nothing in the database. A message burns one
solution; a missing, stale or spent solution is dropped and answered
with a fresh 'required' challenge. The bundled browser solver is
served at /_contact/easypow.js straight from the installed package, so
client and validator versions always match.
Uploads are deliberately not PoW-gated (the transfer rate is the real
cost). Instead, uploads are tracked as pending per session and a quota
caps pending files at 10 — answered 429, not 403, which could trip
auth flows. A sent message strikes the files it references off the
pending set; when the session's last socket closes, the remaining
pending files are deleted from the store.
Rework attachment storage and serving per the refined separation
scheme:
- Non-media uploads are stored as {hash}.file blobs (store_attachment)
— the original extension never lands on the filesystem, so the store
directory holds nothing double-click-executable. The original name
lives only in the URL: /_f/{hash}/{original-name}. The .file suffix
itself is rejected in URLs.
- Serving is strictly separated: /_f/{name} serves media only (hash
with or without extension, .orig. still blocked) and 404s .file
names; /_f/{name}/{filename} serves only {name}.file blobs, never
media, never by hash alone.
- Every /_f/ response now carries bare 'content-disposition:
attachment' (the URL's last segment already names the download) and
x-content-type-options: nosniff — media included. Subresource
fetches (<img>, <video>, favicons, the zoom viewer) ignore the
disposition, so inline media is unaffected, while a direct
navigation can never render or sniff a store file into something
executable in the site's origin. The mime-based script-carrier
forcing and the header filename are gone (subsumed).
- Message uploads treat SVG as media (rasterized derivatives; an
as-is-served .svg is script-inert inside <img>), and undecodable
fake images fall through to the .file attachment path.
Drop the script-carrier blocklist: any file type (html, exe, svg, ...)
may be attached now, with safety from enforced downloads instead of
type filtering, verified twice:
- the attachment chip anchor carries a download attribute (already),
- the link is /_f/{name}/{original-filename}, a new file-store route
that answers content-disposition: attachment naming the original
file (ASCII-safe fallback + RFC 5987 UTF-8 form). The filename rides
in the URL itself — no filename registry in the database.
Close the residual gap on the plain /_f/{name} route: a stored file
whose mime executes script in origin (html/xml/svg/js) is forced to
download there too, so stripping the filename segment off an
attachment URL cannot turn it into an in-origin page. All /_f/
responses now carry x-content-type-options: nosniff. Subresource
fetches (<img>, favicons) ignore Content-Disposition, so inline
images and icons are unaffected.
Undecodable fake images (store_image falls back to as-is storage) now
attach as files instead of being marked inline images.
The serializer treated only DIV/P as blocks: a formatting element
directly under the editable root (a bolded segment of an undivided
line is a top-level <b>) became a separate PLAIN block, losing both
the formatting and the paragraph structure. Top-level inline runs now
accumulate into one paragraph, and style-based formatting (Firefox's
font-weight/font-style spans) maps back to markers.
Attachments: the upload allowlist rejected everything but a handful of
types with a console-only 415. The logic inverts — raster images get
derivatives, script carriers (svg/html/js/xml...) are refused, and any
other file stores as a download chip. Upload failures now show in the
composer, and toolbar buttons keep the contenteditable selection on
click (mousedown.prevent).
docs/messages.md covers the trigger, both socket protocols, the storage
model, the XSS stance (grammar restriction via a strict renderer, never
HTML sanitization), uploads, and the MVP gaps (PoW seams, moderation,
orphan GC, i18n). AGENTS.md and frontend-runtime.md learn the new
module, bundles and asset keys.
The analytics viewer gains a messages tab over the same socket: frames
with a type field are inbox payloads (thread list on connect, live
thread upserts), the type-less snapshot stays analytics. MessagesView
shows the thread list with unread dots and the selected thread with the
shared composer for replies; opening a thread marks it read server-side.
The header badge counts unread threads.
The composer is a constrained contenteditable (plain-text paste,
toolbar formatting, upload chips for images/attachments) serialized to
a Markdown subset by richtext.js — the wire format is text and the
server re-renders it, so no user markup ever reaches a page. Uploads go
to /_contact/files with the session token (admins share the component
via /_api/files). ContactPanel shows the thread pushed live over the
activity socket: pagerite.js lazy-loads the bundle on trigger clicks,
routes socket pushes to it, re-registers the session token on every
reconnect (the resync), and dots trigger buttons on unread replies.
A lone {contact: Label} line in article Markdown renders a
.contact-trigger button that opens the visitor panel (pagerite.js binds
the click and lazy-loads the contact bundle; inert in the editor
preview, whose resolver now matches the served page). The on-demand
bundle's URLs ride in the pagerite-assets config like langselect's, and
the build gets a contact entry point. Description/card mining renders
resolve the tag to nothing.
PUT /_contact/files/{name} stores panel uploads in the content-addressed
file store: raster images through the usual derivative pipeline, a small
allowlist of document types as-is. SVG/HTML are excluded (script
carriers). Gates for now: a session-shaped token plus size/type caps;
the proof-of-work stamp plugs in here later. The dev proxy learns the
/_contact path.
/_ws routes frames carrying a session token to pagerite.messages (and
now also pushes thread state back down the same socket); the visitor
registry is cleaned on disconnect. /_api/ws/analytics becomes
bidirectional: admins get the contact inbox on connect, visitor messages
are pushed live, and reply/read frames are handled. Analytics payloads
keep their type-less shape; messaging frames always carry a type field.
Data.contact holds visitor threads keyed by blake3(session token).
Message bodies are a Markdown subset rendered with a strict MarkdownIt
(html=False): the stored html is always markdown-it output, never
sanitized user markup. pagerite/messages.py owns the store ops, wire
payloads and both socket protocols (visitor frames on /_ws, admin frames
on /_api/ws/analytics); the socket wiring follows.
Beads now take their connector's color — accent for internal links,
plain text color for external ones (matching the tconn-exit styling) —
and render as an elongated pulse along the direction of travel instead
of a plain dot: a rounded capsule with a gradient-faded tail, rotated
to the edge's angle (cached per flow). Flow objects carry an 'external'
tag from buildFlows; the gradients live in the svg defs with stop
colors resolved from CSS so theme variables apply.
The inactivity cutoff timer cannot fire while the computer is asleep, and
performance.now() keeps running through sleep on some platforms, so after
a wake the whole slept span could be credited as reading time (inflating
min/visit stats). A gap longer than the inactivity timeout now closes the
span at its intended cutoff, and the cutoff timer clamps its accumulation
to its scheduled fire time — covering both event-first and timer-first
wake orderings.
An expired session failed the /_api/ws/editor handshake exactly like a dead
network, so the page/banner editors retried forever — the WS API cannot
open the login dialog. reconnect.js gains authReconnect(): on close, probe
/_api/settings (dialog-less fetchJson); a 401/403 runs the paskia login
flow and reconnects the moment it completes, a declined login holds at a
'session expired' ConnNote, and only transient failures take the paced
backoff. A paskia auth-success message from anywhere in the page
reconnects a waiting editor at once. AnalyticsView moves from its inline
copy of the same flow onto the shared helper.
/pages.md (front page /.md) serves the page's source Markdown — per-language
hybrid assembly, title injected as '# title' when the content has no h1 —
with the same ETag/Last-Modified/no-cache scheme as the HTML, which now
carries vary: accept. Pages advertise the counterpart via
<link rel=alternate type=text/markdown> and llms.txt links point at the
.md URLs.
- Expired session dropped the socket into an endless paced retry loop the
WS handshake can never escape: probe /_api/settings on close, run the
paskia login flow on 401/403 and reconnect the moment it resolves; an
aborted login leaves for the front page (history replace).
- Follow paskia's auth-success/auth-logout window messages: logins anywhere
in the page reconnect at once instead of waiting out the backoff; logout
leaves /_a. pagerite.js re-probes its pens on the same events.
- Recycle the socket on visibilitychange/online/bfcache restore: a socket
that survived sleep looks OPEN but is dead, so no updates ever landed.
- The stats range window was frozen at mount (no reactive time dependency);
it now slides on a minute clock.
- Transition map beads: fast-forward emitter schedules after long gaps
instead of spawning a bead per missed interval in one tick.
Node inclusion no longer depends on views in the window: every page
from /_api/pages is shown (the front page used to vanish when quiet).
Omission follows the navigation instead — index pages without content
of their own (category labels and empty pages, the nodes nav links
bypass to the first child) are omitted, children promoted, first
visible child taking the slot. Deleted pages named by traffic are
inserted as ghosts where they would have been, materializing missing
ancestors as content-less category lanes.
/_api/pages gains the "empty" flag (not node.chunks) for the nav
notion; has_content (chunks is not None) stays for the structure
editor's delete affordance.