A standalone FoldKit tech radar, from markdown
This is a plan only. It describes a complete tech radar site, apps/tech-radar, built the
way this monorepo builds apps: Astro as the imperative shell and FoldKit apps as the
functional core. It is hosted on Cloudflare, with the infrastructure in
Alchemy.
- The data is today’s radar dataset (32 entries, 4 editions, 16 relationships), written
as markdown instead of
fixture.ts. - The radar is the effect-three radar, with time travel and deep links.
- Each blip opens a full page, like the React and Astro radar’s blip page in tech-radar-web.
This supersedes tech-radar-web/docs/plans/2026-10-28-astro-3d.md, which planned a 3D app
inside tech-radar-web, reading that repo’s data.
The assessment
This repo is the right home. Every dependency is already a workspace package here (effect-three, effect-three-foldkit, astro-foldkit, foldkit-viz and the radar), with one version of Astro, Effect and FoldKit. There’s no package to publish and no release.
- Standalone means its own app, its own content and its own deploy, not its own repository. It can be lifted out later, because it depends only on workspace packages.
Most of the radar already exists. The 3D radar has the timeline, deep links, tours,
six themes, relationships, the minimap, search, filters, touch handling and the eggs. The
2D FoldKit radar (apps/web/src/apps/functional-ai-radar) reads the same RadarDataset.
The original 3D radar is not changed. Its code in packages/effect-three-samples,
/effect-three/radar, /functional-ai-radar and three-promo all stay as they are.
- The new app starts from a copy of the 3D and 2D radars, which it then owns and changes.
- It reads the original only to prove its data matches: the round-trip test imports
fixture.ts.
Server rendering on Cloudflare keeps deep links exact.
- The radar page renders on each request and reads the query string on the server, as
/effect-three/radaralready does. A shared link opens exactly there on first paint. - The copy keeps its
linkprop and needs no client-side link reading. - The full pages, edition pages and category pages come from build-time markdown, so they are prerendered and served as static assets from the same Worker.
The work divides into four parts:
- Content: the dataset as markdown, decoded at build time into the same
RadarDataset, and proved identical to today’s. - The app’s own radar: a copy that takes the dataset and the full-page address as props.
- The site: the radar, the full blip pages, edition pages and category pages.
- The infrastructure: an Alchemy stack that builds and deploys the Astro app to a Cloudflare Worker, with remote state and a deploy workflow.
The dataset needs no new fields, and the eggs keep working. It is the same dataset, so the Futhark saucer, the CAP moons, the constellations, the Monolith, the magic words and the invaders all stay.
The infrastructure: Alchemy on Cloudflare
What Alchemy v2 needs
These were read from alchemy.run and npm on 2026-10-28.
- Alchemy v2 is Effect-based and still in beta (
2.0.0-beta.84). The stack is anAlchemy.Stackbuilt inEffect.gen. Cloudflare.Website.Astrobuilds the Astro app and deploys it as a Worker with assets.- Alchemy supplies the Cloudflare adapter.
astro.configmust not declare one: declaring@astrojs/cloudflarefails the build. The output defaults toserver. - A page that exports
prerender = trueis served as a static asset. alchemy devruns Astro’s dev server with server rendering in workerd. Plainastro devstill works too, because the config has no adapter.- Astro sessions get a KV namespace by default. This site has no sessions, so pass
sessionKVBindingName: false.
- Alchemy supplies the Cloudflare adapter.
- State:
Cloudflare.state()keeps it in a Worker on the Cloudflare account, backed by a Durable Object.- It’s bootstrapped once with
alchemy state bootstrap --backend cloudflare. CI fails if the store is missing. - The store’s token lives in the account’s Secrets Store, so the CI token needs
Secrets Store Write.
- It’s bootstrapped once with
- Stages:
prodfrommain,pr-<number>previews, anddev_$USERlocally. Each stage is an isolated Worker.
The Effect version clash, and the fix
- Alchemy’s packages peer on
effect ^4.0.5. FoldKit 0.167.0 peers on exactlyeffect 4.0.0, which is the repo’s catalog version. - So the stack can’t share a workspace with the app.
- The fix is a workspace for the stack alone:
infra/tech-radar(@opsydyn/tech-radar-infra), pinned through a newcatalogs.infrato:effect 4.0.7alchemy,@alchemy.run/cloudflareand@alchemy.run/frontend-frameworks, all at exactly2.0.0-beta.84
- Each side keeps its own Effect.
apps/tech-radarstays on the main catalog (effect 4.0.0, FoldKit, Astro). The stack points at the app throughrootDir. - Repo changes: add
infra/*to the workspaces, and maketools/check-catalog.tsaccept the new catalog. - Phase 6 proves it. It checks that Alchemy’s Astro build runs with two Effect
versions in the install, and that the app’s bundle contains only 4.0.0.
- If it doesn’t, the fallback is to wait for a FoldKit release whose Effect peer is a range.
- Alternatively, deploy with wrangler as apps/web does, which gives up Alchemy.
The stack (sketch)
export const Website = Cloudflare.Website.Astro('TechRadar', { rootDir: '../../apps/tech-radar', sessionKVBindingName: false,});
export default Alchemy.Stack( 'tech-radar', { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const site = yield* Website; return { url: site.url }; }),);- No bindings in v1: no KV, D1 or secrets. The site is the Worker and its assets.
- The URL: v1 uses the
workers.devURL. A custom domain is a later, one-resource change. Done 2026-10-31: production is https://techradar.opsydyn.xyz/. - Scripts in the infra workspace:
deploy:alchemy deployplan:alchemy plandev:alchemy devdestroy:alchemy destroy- The root gets
bun dev:tech-radar.
Deploying from CI (Actions minutes are limited)
.github/workflows/deploy-tech-radar.ymldeploysprodon pushes tomain.- It runs only on changes to
apps/tech-radar/**,infra/tech-radar/**, the workspace packages the app imports, orbun.lock, asdeploy-cf.ymldoes. - It also has a
workflow_dispatchtrigger.
- It runs only on changes to
- PR previews are opt-in, not on every push:
- A
previewlabel or a manual dispatch deployspr-<number>and comments the URL. - Closing the PR destroys the stage.
- The destroy job refuses
prod.
- A
- One-time setup by you, since it needs your Cloudflare login:
- Run
alchemy state bootstrap --backend cloudflare. - Create a Cloudflare API token with Workers Scripts, Workers KV, Durable Objects and Secrets Store permissions.
- Store it as the repo secrets
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID, or as newTECH_RADAR_*secrets, so apps/web’s token keeps its narrower scope.
- Run
The markdown
One file per entry, with its history in the frontmatter (settled)
apps/tech-radar/src/content/ radar.md # title, current edition date and label; body: intro editions/2025-10-14.md # label; body: what this edition was about editions/2026-02-14.md editions/2026-06-14.md entries/algebraic-data-types.md # one per entry; body: the full-page write-upAn entry looks like this:
---name: Algebraic data typescategory: techniquesring: adopt # the current editionsummary: Build domain values from alternatives and products rather than unrelated flags.source: https://www.haskell.org/onlinereport/haskell2010/haskellch4.htmlhistory: # earlier editions this entry was on, and its ring then - { edition: 2025-10-14, ring: trial } - { edition: 2026-02-14, ring: adopt }related: - { to: effect, type: complements, note: … }---
The write-up shown on the full page.Why this shape:
- It matches
RadarDatasetone to one. An entry is on an edition if that edition is in itshistoryor is the current one.editions[].ringsis every listed ring that differs from the current ring.editions[].absentis every edition the entry isn’t listed in.
- It’s lossless, so a round-trip test can prove the markdown is today’s data.
- Authors write each entry once. The full page is its body, the card shows its summary, and a new edition touches only what moved.
The alternative, not chosen, was tech-radar-web’s shape: a folder per edition, with a snapshot file for every blip.
- It suits the case where every edition is reviewed as one self-contained change.
- It costs about 110 files for 32 entries over 4 editions, with each write-up copied forward every edition.
Decoding
- A pure assembler (
src/radar/dataset.ts) turns the collections into aRadarDataset.- It derives
ringsandabsentfrom each entry’shistory. - It collects
relatedintorelationships. - It then runs the existing
decodeRadarDataset. AFailurestops the build with the reason and the entry file’s name.
- It derives
- Schemas are Effect Schema, matching the radar. To verify in Phase 2: Astro 7 collections accept a Standard Schema, and Effect Schema produces one. If not, a custom loader decodes with Effect, and Astro’s schema stays a thin pass-through.
- The data reaches the Worker at build time. The SSR radar page imports the assembled dataset, so a request never reads markdown.
- The proof:
assemble(markdown)deep-equalsdemoRadarfromfixture.ts.- That test is what makes it “the same data set”.
fixture.tsstays for apps/web and three-promo, and the test stops the two drifting.
Write-ups
The fixture has one-line summaries, not articles.
- v1: each full page shows the summary, the source and everything derived (movement, relationships, the radar position). The body slot is there, and empty bodies render nothing.
- Write-ups as content work (Phase 8): original prose, sources checked, a few at a time. I can draft them, or you can write them.
The app’s own radar (a copy; the original stays as it is)
- The copy:
- Copy
packages/effect-three-samples/src/radarintoapps/tech-radar/src/radar/three. - Copy
apps/web/src/apps/functional-ai-radarintoapps/tech-radar/src/radar/flat. - Copy the radar’s tests too. The first commit is the copy alone, with every copied test passing, so later diffs show only what changed.
- Copy
- What the copies share with the original: only libraries (effect-three, effect-three-foldkit, foldkit-viz). Library fixes, such as the touch tolerance, reach both.
- What they don’t share: radar-level fixes, such as a new theme or a sidebar fix, would have to be ported by hand in either direction. That’s the price of keeping the original untouched. It’s worth recording in both READMEs.
- The guard: every PR for this app shows no diff under
packages/effect-three-samples/orapps/web/src/. Phase 1’s PR states it. The original’s own tests and the Chromium check of/effect-three/radarshow it still behaves the same.
The changes to the copy follow. Astro serialises island props, so every new input is data, not a function.
dataset: RadarDatasetfor the 3D radar and the 2D radar, required. The copy drops itsfixture.ts. Its data only ever comes from the markdown.- The full-page address as templates:
entryPage: '/blip/{id}/'editionEntryPage: '/edition/{edition}/blip/{id}/'
- “Open full page” in the details card, under the summary. It’s a real link, so
middle-click, keyboard and touch all work.
- It opens the page for the edition being viewed: time travel to the second edition, and the link opens that edition’s view of the blip.
- The “Source” link stays beside it.
- The demo notice goes, since this is the radar’s own data. A
titleprop is added. - Deep links are unchanged: the page passes
Astro.url.searchParamsas thelinkprop, exactly asradar.astrodoes today. - Tests:
- A dataset prop renders the radar.
- The page link follows the edition being viewed.
- A link given to
initopens pinned, in the right edition and theme, as in the original’s tests.
The site (apps/tech-radar)
Astro 7, @opsydyn/astro-foldkit, trailingSlash: 'always', and no adapter in the config
(Alchemy supplies it). The layout and styling follow three-promo.
| Page | Rendered | What it is | FoldKit islands |
|---|---|---|---|
/ | per request | The 3D radar, opening on the query’s deep link, with the intro from radar.md | 3D radar |
/2d/ | per request | The same radar in 2D, with the same links. It’s the fallback when WebGL is missing | 2D radar |
/blip/[id]/ | prerendered | The full page at the current edition | Movement chart, mini-radar |
/edition/[date]/ | prerendered | The edition’s notes, what changed, and its entries by category | — |
/edition/[date]/blip/[id]/ | prerendered | The full page as the blip stood in that edition | Movement chart, mini-radar |
/category/[id]/ | prerendered | Every entry in a category, by ring | — |
404 | prerendered | Served for unknown paths (assets.notFoundHandling: '404-page') | — |
The full blip page, matched to tech-radar-web’s
- Header: the name, the ring and category as chips, and the edition (“Second edition · 2026-02-14”) when it isn’t the current one.
- Body: the markdown write-up, rendered by Astro.
- Movement history:
- A small foldkit-viz step chart of the ring across the editions, with a gap where it was absent.
- A table of the same values sits beside it, so colour is never alone.
- This replaces the React app’s
BlipHistory.
- Related technologies: each relationship with its type label, note and a link to that entry’s page, both directions.
- Mini-radar: the 2D radar, pinned on this entry and read-only, with “View on the 3D
radar” linking to
/?entry=<id>(plus&edition=for older editions). - Source link.
- Navigation: previous and next within the category, and back to the category page.
- Each page is complete without JavaScript. The islands enhance the history and the mini-radar.
Reused rather than rewritten
- The edition “what changed” list reuses the copied radar’s timeline functions.
- The mini-radar on each full page is the copied 2D radar.
Phases
2D radar removed (2026-10-28): the app shows the 3D radar only. /2d/, the 2D copy
(src/radar/flat), its Vitest tests and its vanilla-extract tooling are gone, and so are
the follow-ups that needed it (a mini 2D radar on each full page, deep links on /2d/).
The original 2D radar in apps/web is unchanged. The sections below describe the plan as
first written.
Progress (2026-10-28): Phases 1 to 7 are done and merged (#79, #80, and the docs
PR that follows them). Production is
https://tech-radar-techradar-prod-p2g6lgx4bmhwwuk5.opsydyn.workers.dev/, deployed from
main by .github/workflows/deploy-tech-radar.yml with the TECH_RADAR_CLOUDFLARE_*
secrets. three-promo links to it, and CLAUDE.md and the README list both new workspaces.
The personal stage is live_alancurrie (Alchemy’s default stage name, not dev_$USER).
What the first deploy taught:
-
Alchemy builds with
output: 'server', so every page but/declaresexport const prerender = true. -
assets.notFoundHandling: '404-page'answers browser navigations that match no file without running the Worker, which 404’d/. It is left out; Astro serves its 404 page. -
@alchemy.run/frontend-frameworksmust also be a dev dependency of the app, where it resolves Effect 4.0.0. -
The Cloudflare account already had Alchemy’s state store, which was adopted. Two changes from the plan below:
-
/2d/was built ahead, not rendered per request, and the full pages link to the 3D radar rather than show a mini-radar. The 2D radar has since been removed.
Local development comes first. The Cloudflare and Alchemy work waits until the site runs locally, so nothing needs your Cloudflare login until Phase 6.
- Scaffold and copy.
- Scaffold
apps/tech-radarand copy both radars and their tests in, unchanged. All copied tests pass. - The original shows no diff.
- Scaffold
- Content.
- Write the markdown from the fixture with a one-off script, checked in for audit and then deleted.
- Add the assembler. The round-trip test passes.
- The copy’s inputs. The dataset, page-template and title props, “Open full page”, and the demo notice removed.
- The radar pages:
/and/2d/, rendered per request with deep links.- Locally these run under
astro dev, which needs no adapter. A production build of per-request pages waits for Alchemy’s adapter in Phase 6.
- Locally these run under
- The full pages: blip, edition-blip, edition and category pages, prerendered, with the movement chart and the mini-radar.
- Infra.
- Create
infra/tech-radarwith its own Effect throughcatalogs.infra, and deploy to adev_$USERstage. - Check that the app’s bundle has only
effect 4.0.0, prerendered pages are served as assets, andalchemy destroycleans up. - Needs: your Cloudflare login for
alchemy state bootstrapand the first deploy, and the deploy token. - If Alchemy can’t build it, the fallback is wrangler, as apps/web deploys.
- Create
- Ship.
- The deploy workflow (prod on
main, previews by label), with the token and secrets set up by you. - A link from three-promo, the README, and the CLAUDE.md workspace and command tables.
- The deploy workflow (prod on
- Final infra stage: review
@alchemy.run/domain(below). Done: not yet, with the conditions to revisit. - Later: write-ups; a custom domain; optional ADR, author and tag fields with a page section; a “Take the tour” per edition from a checked-in tour file.
Each phase is its own branch and PR, committed locally and pushed only when asked.
Final infra stage: reviewing @alchemy.run/domain
Read on 2026-10-28 from alchemy.run/domain, /domain/foldkit and /domain/cloudflare.
What it is. Not custom domains: @alchemy.run/domain is Alchemy’s domain-driven
design library.
- Commands request changes, aggregates decide them, and events record what happened.
- Views, projections, policies and sagas answer reads, feed stores and decide next steps.
Storytests describe behaviour in business terms against an in-memory model.- On Cloudflare each aggregate, view key and policy becomes a Durable Object, created when the Worker starts. A command’s events, state and reply commit together.
bun alchemy devruns the same Worker and Durable Objects locally.
How it meets FoldKit.
- Its client sends commands over HTTP, mapped to FoldKit Commands that return
Succeeded*orFailed*Messages with typed rejections. - A view’s
watchopens a WebSocket, mapped to a FoldKit Subscription. - Alchemy also has
Cloudflare.Website.Foldkit, beside theWebsite.Astrothis plan uses. The API Worker shares the site’s origin throughassets.runWorkerFirst.
Where it could fit the radar. v1 needs none of it: the radar is markdown, assembled at build time, and read-only. It becomes relevant once the radar takes input, for example:
- proposals and votes on blips, or comments;
- an editorial workflow, where moving a blip is a command and an edition is the event history up to its date (the timeline would read events rather than markdown);
- live updates to an open radar through a watched view.
What the review found (2026-10-28, @alchemy.run/domain 2.0.0-beta.84):
- Maturity. It was first published on 2026-10-21, and
2.0.0-beta.84is its only real release. The example on Alchemy’smainalready uses a newer Story API than the published package (call/succeedagainstexpectCall/resolve), so the API is still moving. It is Apache-2.0 and about 1 MB unpacked. - Peers.
effect ^4.0.5andalchemy, andalchemyis not optional. Alchemy’s own FoldKit app (examples/cloudflare-domain/packages/web) listsalchemyas a dependency of the browser package. - Effect: the blocker. The domain client is browser code that must share FoldKit’s
Effect, and FoldKit pins exactly
4.0.0. Alchemy’s repository makes FoldKit and the domain work together by overriding Effect for the whole workspace (overrides: effect: 4.0.5, withfoldkit ^0.169.0), which runs FoldKit on an Effect it doesn’t declare.- A spike installed the package with Effect forced to 4.0.0: its source typechecks
against 4.0.0. The only errors were in
alchemy’s own declarations, which want@effect/platform-nodeand Node’s types. - Whether it runs on 4.0.0 is unproven. The example on
maindoesn’t match the published API, and running the tagged example’s tests was not done.
- A spike installed the package with Effect forced to 4.0.0: its source typechecks
against 4.0.0. The only errors were in
- Testing. Stories run in memory under Vitest with
@effect/vitest(it.effect), notbun test. apps/web already uses Vitest, so this fits, as a second runner in the app. - Hosting. The docs host a model in its own Worker class, with aggregates, views and
policies as Durable Objects created at start-up and no bindings declared. The site’s
Worker binds the model and routes
/apito it withassets.runWorkerFirst. OnlyWebsite.Foldkitis documented; using it besideWebsite.Astrois untested. - FoldKit integration. Commands go over HTTP and become FoldKit Commands with typed
rejections.
watchandeventsare WebSockets to the owning Durable Object and become Subscriptions. The local-first guide adds an offline intent queue inlocalStorage, with deduplication by command id. All of this matches how this repo writes FoldKit apps.
Where it would fit the radar. Only once the radar takes input. The best first feature keeps markdown as the source of truth:
- Proposals and votes: a
Proposalaggregate (ProposeEntry,ProposeMove,Vote,Accept,Reject) and views per entry and per open edition, watched live on the radar. - An accepted proposal becomes a markdown pull request through an
ExternalSystem(GitHub), so editions are still reviewed and built from markdown. - Every write needs sign-in first: the
cloudflare-domain-better-authexample and the sessions guide cover it.
Making events the source of editions, in place of the markdown, is possible but reverses the content decision, so it is not proposed.
Decision: not yet. Revisit when all three hold:
- One Effect can serve both, either because FoldKit widens its
effectpeer to include>=4.0.5, or because this repository adopts a repo-wide override after the full test suite passes on it. An override would also remove the infra workspace’s separate Effect. @alchemy.run/domainhas a few releases behind it, with a stable Story API.- The radar has a named write feature, starting with proposals and votes.
The cheapest next step, if wanted: a spike branch that overrides Effect to 4.0.7 across the repository and runs every suite, to learn whether FoldKit already works on it.
Correction, 2026-10-30: condition 1 is not met. Alchemy 2.0.0-beta.84 declares
effect ^4.0.5 but imports effect/Crypto, which Effect 4.1 removed, so the Alchemy CLI
cannot run on 4.1: the move below broke every tech radar deploy until infra went back to
its own Effect 4.0.7. One Effect for FoldKit and Alchemy needs an Alchemy release that works
on 4.1. The proposals spike’s Stories passed on 4.1 only because they never load Alchemy’s
CLI or auth code.
Update, 2026-10-29: condition 1 is met. FoldKit 0.170 peers on Effect ~4.1.0, which
Alchemy’s ^4.0.5 accepts, so the repository runs on one Effect 4.1.0, infra included, with
no override (docs/releases/foldkit-0.170.md). The overlap is 4.1.x only: if Alchemy needs
4.2 before FoldKit does, they split again. Conditions 2 and 3 still stand:
@alchemy.run/domain is still on its only release, 2.0.0-beta.84, and the radar has no
write feature. One new cost: its Stories run under @effect/vitest, whose 4.1 release needs
Vitest 5, and the repository is on Vitest 4.
Decisions
Settled:
apps/tech-radarin this monorepo, as a standalone app. The original 3D radar is not changed.- Cloudflare hosting, server-rendered where links need it, deployed with Alchemy v2.
- One markdown file per entry, with its history and relationships in the frontmatter.
- Local development first. The state bootstrap and the deploy token wait until Phase 6.
Still to confirm:
- Write-ups: whether I draft them in Phase 8, or you write them.
- The Cloudflare token (Phase 6): a new
TECH_RADAR_*token (recommended), or widening apps/web’s existingCLOUDFLARE_API_TOKEN.
Verification
- Tests: the round-trip test (markdown equals the fixture), assembler failures named by file, and the radar-prop tests.
- Checks:
bun run check(including the catalog check), the full test suites,astro check, andalchemy planagainstprodshowing only the expected resources. - A local Worker:
alchemy dev, so the SSR radar runs in workerd as it will in production. - Chromium pass on a
dev_$USERstage, at 1440×820 and 375px:- the radar, pinning a blip and opening its full page from the card
- time travel to the first edition, then the full page shows that edition’s ring
- a
?entry=futhark&theme=blueprintlink opening exactly there on first paint /2d/, and a full page with JavaScript disabled
- The original is unchanged:
git diff main -- packages/effect-three-samples apps/web/srcis empty on every PR.- Phase 1 moved apps/web’s vanilla-extract and vitest versions into the root catalog, unchanged, because the 2D copy uses them too. That is apps/web’s only change.
- The original’s tests pass as before.
/effect-three/radarand/functional-ai-radarlook and behave as before.