The why behind this project's choices. For the current state see docs/ROADMAP.md, for
the cycle log docs/CYCLES.md.
A personal portfolio at esse.dev, in Astro (strict TS, Tailwind 4, Svelte 5 islands) on
Cloudflare Workers. File-based content in the repo, EN/IT i18n with translated routes and
slugs, Open Graph images generated at build. Until Cycle 10 it was SvelteKit: the rewrite
is in docs/archive/CYCLES-11-12.md (Cycle 11) and the choice in docs/DECISIONS.md #10.
Every page is prerendered at build and served by Cloudflare as a static asset. The Worker only receives what is not a file:
- the root
/(src/pages/index.ts), which picks the language fromAccept-Language; - the catch-all
src/pages/[...path].astro, which for a URL with the wrong language, route or slug makes a single permanent (301) redirect to the canonical one (resolveRedirectinsrc/lib/i18n.ts) and otherwise answers 404 with the localized page, which suggests the pages closest to the wrong path (src/lib/suggest.ts), the list search and the agent; /agents/site-agent/*, the agent's WebSocket (below).
When a route on the Worker throws, Astro renders src/pages/500.astro (not prerendered):
it logs the error with Cloudflare's cf-ray id and shows the visitor only that id.
The Worker entry is src/worker.ts: it sends only /agents/site-agent/* to
routeAgentRequest and everything else to the Astro handler, and it exports the two
Durable Objects (SiteAgent and Ledger). The path filter is there because
routeAgentRequest would route any exported class, and Ledger must stay reachable only
through RPC.
The prerender runs in Node (prerenderEnvironment: 'node') because the OG images use
resvg, which is native. The E2E tests run against the build served by wrangler dev, not
the dev server, so redirects, headers and 404s are the production ones.
Each project and article is a folder in src/content/: a meta.json with the fields
shared across languages (status, dates, links, published) and one <lang>.md per
language (frontmatter with slug, title, excerpt, tags and, for projects, the earlier
iterations in previously, #20; body in Markdown). The two halves are separate content
collections (src/content.config.ts, Zod schemas) and are joined in src/lib/content.ts,
the single access point to content for pages and endpoints. That is where the build
enforces the rules the schema cannot see: a published item has its text in every
language, every text has its meta, slugs are unique per language, the showcase
(src/config/featured.json) points to published projects. A violation fails the build.
Why two files per item: meta.json holds the facts, <lang>.md the prose. The facts are
shared across languages (copying them into every Markdown would make them diverge) and
are the ones a script can update from the sources (repo, dates, status) without touching
hand-written text. Astro's standard would be one Markdown per language with all the
frontmatter: it costs duplication, and grows with every shared field (cover and logo, #22).
The single pages (welcome, about, contacts, privacy) are Markdown per language in
src/content/pages/. The welcome page keeps its text in the frontmatter, without a body:
the headline (*...* marks the accent) and the introduction, where {tools} places the
logos of the projects listed in tools, are composed with components, which Markdown
cannot do. The home's method card shows the quote of the first note that has one (#30).
The site title, section names and UI strings are the site collection, with a strict
schema: every language has all the keys and none extra, and the keys are a type. Config
(languages, routes, showcase) stays JSON, imported and validated by src/lib/config.ts,
because it is configuration and not content.
Where Astro has a standard, that is used: content collections, @astrojs/rss, the Fonts
API for self-hosted fonts with preload and tuned fallbacks. The sitemap is not:
@astrojs/sitemap derives the alternates by replacing the language prefix and with
translated slugs it would get the hreflang wrong. Opened in a browser it reads as a page:
an xml-stylesheet instruction points to /sitemap.css, CSS applied to the XML and not
XSLT, which Chrome removes. That stylesheet is a prerendered endpoint that reads the color
tokens of both themes out of global.css (light from the system's preference, since the
toggle cannot reach an XML document) and takes Departure Mono's URL from the Fonts API, so
no color is copied and no font file is duplicated.
Two languages and full control of the URLs (/en/projects/x against /it/progetti/y)
make a library oversized, and Astro's i18n does not translate segments or slugs. The
routes per language are in src/config/navigation.json; the slug map (id -> language ->
slug) is derived from the content. All the logic (section of a route, translation of
route and slug, equivalent URL in another language, redirect to the canonical one,
preferred language) is in pure functions in src/lib/i18n.ts, unit-tested and used by
pages, headers, SEO, sitemap and the catch-all.
A prerendered endpoint (src/pages/og/[name].png.ts) produces one PNG per page with
satori and resvg: home, listing-<section>-<language>,
detail-<section>-<id>-<language>. The layout is a pure tree in src/lib/og.ts, in the
terminal style of concept D (docs/DECISIONS.md #19). Zero compute at runtime, no
injection surface, deterministic images. Satori gotcha: woff/ttf fonts, never woff2
(Geist Sans from @fontsource/geist-sans, Departure Mono from the woff in
src/assets/fonts/).
CSP generated by Astro (security.csp) as a meta tag in the pages, with the hashes of
the inline scripts; the only external domains allowed are Umami (analytics.esse.dev) and
Turnstile (challenges.cloudflare.com, for draft_message). Umami is self-hosted and
cookieless, so no consent banner; it loads after the page and only on esse.dev
(src/scripts/analytics.ts). Custom events (src/scripts/track.ts) count interactions and
never carry the visitor's text. What the site collects and for how long is stated in the
privacy page, in categories (#23). frame-ancestors in the meta tag is
ignored, so it goes as a header in public/_headers together with
X-Content-Type-Options, Referrer-Policy, X-Frame-Options and Permissions-Policy.
The Worker's responses (root, redirects, 404) do not go through _headers: the same
headers are added by src/middleware.ts. Hashed assets in /_astro/ are cached as
immutable. No endpoint reflects user input.
The site is used like an app (concept A, docs/concepts/concept-a-spazio.html, Cycle 12):
two columns as tall as the window, each with its own tone. On the left the list (logo,
search, items, a status line with the keys; the LED legend sits in the projects list), on
the right the content pane with a toolbar (path, document actions, language). No
full-width bar: with two tones on horizontal rows the page formed a T that matched no zone
(Cycle 13). Each item stays a static page with its own URL, with no router. The page
change is instant, like changing document in an app: the earlier fade with a rise (view
transition) looked like a site loading a new page and slowed down j k and h/l.
On a wide screen the two columns are two cards inside a rounded window, detached from the
edges. On mobile the window stays, with the content pane only. The window rests on a
background (wallpaper): a continuous diagonal veil from lavender to deep purple fading
into black, over a fine grid, with a single soft point of light at the top left
(--wall-light) that shows through the list. No scattered glows, which through the glass
turned into color blotches, and no green, which is reserved for live states. Frame, list,
pane, agent field and menus are glass (glass utility: strong blur, moderate saturation,
no white veil, which turns grey, and a border that catches a little light; values in
--glass-* and --wall). Only the glasses that contain no other glass blur (list, agent
field, menus): in Chromium a blurring glass inside another stops blurring, so the frame
has only the border and the pane blurs from a layer behind the content
(data-pane-glass, the same panel glass as the list, 48% on desktop: a darker pane read
as a different material), a sibling and not an ancestor of the agent
field and the menus, which therefore still blur. The toolbar stays flat. surface and
hover are light veils and not solid greys, so controls on the glass lighten instead of
acting as a hole: a single scale, hover surface/60 and selection surface (the "you
are here" stays above the hover), pane blocks on surface/60, because a solid panel on
the glass does not stand out. subtle is tuned on the glass, where the veil behind is
lighter. It comes from concept C (docs/concepts/concept-c-vetro.html, variant B),
choices in docs/DECISIONS.md #16. No bounce and no scroll passing underneath:
overscroll-none on the page and on the main containers, overscroll-x-none on nested
blocks that scroll sideways; a rule on every element made Chromium hook the wheel to an
ancestor that does not scroll, and the list stopped. The style comes from concept B
(docs/concepts/concept-b-stile.html): lavender accent on a slightly cold black, a
phosphor green only for live or successful states (LED in progress and maintained,
copies and sends), Departure Mono for the interface monospace, and a CRT monitor veil
(phosphor glow on titles and labels, red-cyan fringe, lines and vignette) set by the
--crt-* variables in global.css. Choices in docs/DECISIONS.md #15. Labels with
data-scramble decode once when the pointer enters the link or button that contains
them and once when it leaves. Icons with data-motion make a gesture of their own on
hover of the link that contains them (concept E, docs/concepts/concept-e-icone.html):
the house jumps, the agent tilts its head, the line of Now traces itself and retracts in
a loop, the arrows go out and come back, the LEDs send out a ring; with the keyboard and
on touch they stay still. With prefers-reduced-motion only displacement stops: what
translates, rotates or scales gets an equivalent that stays put (the icons glow in the
accent, the mobile menu fades), while color, opacity and redrawn strokes keep running
(docs/DECISIONS.md #25).
- List (
src/lib/workspace.ts): in order of importance and at most 900 px tall. First Welcome (the home) and the single pages (about, now, agent), then the project showcase with "all N" leading to the registry, method, writing. Projects outside the showcase stay in the page: search finds them and the open one appears. It is also the source of the pager order. At the bottom, above the status line, the external profiles (SocialLinks.astro, the same links as the contacts) as mono names with the arrow. - Navigation (
src/scripts/workspace.ts): j/k and arrows in the list, Enter, Esc to the level above, h/l (or the side arrows) for previous and next,/and Cmd/Ctrl+K for search. Not[/]: on the Italian Mac keyboard they need Option. The path in the toolbar always starts with~, which leads to the home. The level above (Esc, breadcrumb, "‹ section" on mobile) is computed from thecrumbsand goes back through history when you arrive from there, so the registry finds its filters again. The document has only title, meta and text: no "Back" in the content. - Mobile: every page shows the pane, which scrolls inside the window with the toolbar
fixed at the top and "‹ section" as in iOS, and under it the tab bar
(
src/components/workspace/TabBar.astro, concept M): Home, Projects, the agent raised at the center, Method, and Menu. Menu opens the list (data-draweron[data-workspace]) as a sheet that rises from below up to the bar, over the pane, which becomes inert; the bar stays usable and Menu turns into an X. It closes with Menu, Esc, a tap outside, a tap on its handle or by dragging the handle down: only the handle drags, because the list scrolls vertically (touch-pan-y, which does not pass inside a scrolling container and must be set on the list too). The sheet holds the search, the pages, the projects, the method and the profiles; the keys it does not. A phone has no hover: a tap setsdata-playon a tab and its icon makes the gesture the desktop list makes on hover, and the sheet's blocks rise in turn as it opens. With reduced motion the tap glows and the sheet fades in place. Choices indocs/DECISIONS.md#31. The window stays still and only the pane scrolls, so Safari's bottom bar never shrinks. Safari 26 ignorestheme-colorand tints its bars from a fixed element on each edge or the html background: two solid strips on phones and the html background carry the wallpaper's edge colors, measured on its frame (--color-bar-top,--color-bar-bottom), and a closed full-screen layer leaves the page, since Safari samples it even at opacity 0. - Tokens (
@themeinsrc/styles/global.css):deskandframebehind and around the window; surfacesbg,panel,surface,hover;line; textfg,text,muted,subtle(the minimum for text, 4.5:1 measured on the glass);accentandon-accent;livefor live states;glow, the deep purple of the veil behind the window;danger;status-*for the status of projects; radii--radius-control,--radius-panel,--radius-card,--radius-window,--radius-edge(the pane on a phone, close to the screen's corners). Shared classes:.led,.kbd,.label,.chip,.ulink,.caret. - Themes (
docs/DECISIONS.md#27): dark and light. The@themevalues are the dark ones;:root[data-theme='light']overrides them, and the values that were white or black by hand (grid, scan lines, glass edge, vignette) are channel variables (--veil-rgb,--shade-rgb,--glass-edge) that the light theme flips. What changes shape rather than color uses thelight:variant. The theme follows the system until the visitor picks one with the toggle in the toolbar (src/scripts/theme.ts, choice inlocalStorage, not saved when it equals the system's);src/scripts/theme-init.js, inline in the head, setsdata-themebefore the first paint, andastro.config.mjscomputes its CSP hash from the same file, since Astro does not hash inline scripts. Logos and designed covers have a light variant written by the render scripts (#28). - Measure: no maximum width on the content, the column and the fluid size set it
(
Page.astro,Prose.astro). In detail pages, fromxl, the facts sit in a column on the right; the page fills at least the pane and the pager sits at the bottom.
/it/agente is a static page with a Svelte island (AgentChat.svelte) that opens a
WebSocket to a Durable Object per visitor (SiteAgent, src/agent/). Inside runs
pi-durable through the Agents SDK's PiHarness: conversation in the object's SQLite,
resumption after a suspension, tools as pi extensions. The socket protocol and the event
reducer (sockets.ts, view.ts) come from the official example and are the same on both
sides.
- Models: from OpenRouter (
src/agent/models.ts),glm-5.3-flashon an ordered list of fast providers with automatic fallback to the next, a timeout on a stream without tokens and pi's retries. KeyOPENROUTER_API_KEYas a Worker secret. TheAIbinding remains only as an alternative transport for Jev. Choices indocs/DECISIONS.md#11. - Site tools:
search_site,read_page,list_projectsandshow_pageread/agent/index.json, a site index generated at build from the same collections as the pages and read from the assets: the agent sees only what the site publishes.show_pagebecomes a card in the transcript, which the visitor opens when they want. - Code tools:
repo_overview,list_files,read_file,search_codeandrecent_commitsread the public repos from GitHub's REST API (github.ts). The repo is a closed-value parameter: therepoof the published projects plus the site's own.read_filenumbers the lines, gives the GitHub link and reads long files in pieces. WithGITHUB_TOKEN(optional) the limit rises to 5,000 requests an hour andsearch_codesearches the code; without it, it searches only file names. Choices in #13. - Capabilities:
renderdraws bars, tables and timelines. The model sends data, never HTML; the same validation (render.ts) runs in the Durable Object, which sends the error back to the model, and in the browser, which draws with the site's tokens (the bars are SVG: the CSP blocks inline styles). run_code: Cloudflare's Code Mode (@cloudflare/codemode,run-code.ts). The model writes a JavaScript function that calls the read-only tools ascodemode.name(), with the TypeScript declarations generated from the schemas in the tool description. It runs in a Dynamic Worker (bindingLOADER, paid plan) with no network or environment, with caps on time, calls and result size; every call goes through the same validation and the sameexecuteas the normal tools.delegate: 2 or 3 sub-agents in parallel, with the sub-agent scheme of pi-durable: each child is a conversation owned by the call (stopping the parent stops the children, a resume finds them again), with only the read-only tools and sub-agent instructions. Each child has a token cap (child-budget.ts): an extension selected only on the children that, once at the cap, asks for the final answer before the request and blocks further tools. Without credits for all the children in the worst case, the tool does not create them and the parent answers. The children's cost is not in the main conversation: the tool deducts it, once only (memo). The page shows for each child calls, tokens, cost and answer (delegate.ts,DelegateView.svelte); the children's events do not arrive live.draft_message: the model writes only a draft; the page shows it in an editable card with Turnstile and the visitor sends it. The server (sendDraft) checks that the draft is a call of the agent in this conversation and has not already gone out, verifies Turnstile, applies the daily caps (per visitor in theSiteAgent, for the site in theLedger) and sends with thesend_emailbinding to the address verified inMAIL_TO(draft.ts,DraftView.svelte), through Cloudflare Email Service. The Turnstile site key is public and lives in the code, chosen by host: the real one onesse.dev, the test one on localhost, where the real widget does not load. After a send or a failure the server writes a note for the model into the conversation, without a run, with the outcome and never the draft's text, so the agent knows how it went (#29).get_budget: the visitor's credits left today (the lowest of their own, their IP's and the site's budget, rounded like the page's counter), the daily credits and the reset time. The rules of the credits are in the prompt, so a question about them costs one call instead of a search in the site's code; it is the only tool allowed in the short chat mode, where Jev puts these questions.- Link to the agent:
?ask=writes a question into the input without sending it (the home uses it). Not?q=, which is the list's search. - Page with a conversation started:
data-agent-startedon[data-workspace]hides title and introduction (they stay for screen readers) and shows "new conversation" in the toolbar. Answers can link to any domain (#18). - The catalog the page shows with an empty conversation is the one the server
announces in
hello: a new tool appears by itself, the groups in the page order it. - Triage and limits: first the per-IP burst (binding
AGENT_RATE, Workers Rate Limiting), then the budget, then Jev (triage.ts), which stops off-topic and abuse and decides the language of the answer and of the notices. A chat message (intentchat) goes through, and if Jev is sure about it the prompt gets the short mode with no tools. Spending is deducted in real cost per visitor (in theSiteAgent), per daily IP fingerprint and for the whole site (both in theLedgerDurable Object), with the thresholds inbudget.ts; the IP is not stored, only the SHA-256 fingerprint of day and address. In the page the budget is shown in credits, only when it is about to run out. If Jev does not answer the message goes through and the cap applies. The thresholds are verified withpnpm eval:jevon a labeled set (tests/eval/jev-triage.json), which also contains attacks. Choices indocs/DECISIONS.md#12 and #17. - Transcript: Markdown passed through a sanitizing renderer (
markdown.ts), reasoning collapsed, Jev's verdict, tokens, cost and remaining budget for every answer. - States: a status line under the answer shows the phase of the work (Jev, thinking,
reasoning, the tool at work, writing, retry with countdown), derived in the browser by
phaseOf(phase.ts) from the session view and the message waiting for Jev, with no extra server events. Errors go througherrorKindand show by kind in the visitor's language, the raw text under "details"; a resumed conversation, a dropped connection, an agent that never answers and a message queued behind an answer have their own state. An empty snapshot after a conversation (it expired with the page open) resets what lives only in the browser. Before hydrationAgentFallback.astroholds the field in place. The streamed text glows and cools (phosphor.ts). Choices in #24. - Retention: a conversation expires 90 days after the last message (
retention.ts, #23). Every settled turn moves theexpirejob of the object's Lifecycle queue; when it runs, it disables the alarms, deletes the storage and resets the instance. "New conversation" does not delete. The privacy page states the same number.
The Worker code has its own tsconfig.worker.json: the Cloudflare runtime types
(worker-configuration.d.ts, generated by wrangler types) clash with the DOM ones.
Thin pages -> src/lib/content.ts (data) -> collection schemas (validation) ->
components (UI). The pure logic (i18n, SEO, list filters, reading metrics, showcase,
related items, OG layout, 404 suggestions) is in src/lib/ modules with no Astro
dependency, testable with vitest in Node. The same for the agent: the pure logic (budget,
triage, site index, transcript, phase of the work) lives in src/agent/ modules separate
from the Durable Object. The Svelte islands receive already serialized data (ListItem in
src/lib/listing.ts) and do not read content.