Built in pure Scheme

Everything beside this is live rendered by Goeteia.
The Scheme below is compiled to WebAssembly in your browser and mounted live.

booting the compiler…

No server compiles this — the page carries the whole compiler (goeteia.wasm, ~50 KB gzipped, cached after first load), and each Run recompiles the source above in about 80 ms.

01 · The language

R6RS and syntax-case, complete — in the page

Not a toy subset: hygienic syntax-rules and procedural syntax-case with fenders, nested ellipses and datum->syntax, compiled to Wasm GC right here in your browser.

The whole standard, running above

Exact bignums and rationals, call/cc and dynamic-wind on Wasm's own exception handling, and every tail call a return_call — a hundred-million-iteration loop runs in constant stack.

The expander is a compile-time interpreter with hygiene by renaming: a macro's bindings and yours can never collide. Paste any of this into the editor above and press Run.

(define-syntax while              ; syntax-rules
  (syntax-rules ()
    ((_ c body ...)
     (let loop ()
       (when c body ... (loop))))))

(define-syntax inc!               ; procedural syntax-case
  (lambda (x)
    (syntax-case x ()
      ((_ v)   #'(set! v (+ v 1)))
      ((_ v n) #'(set! v (+ v n))))))

(fact 20)  ; => 2432902008176640000 -- exact
(/ 1 3)    ; => 1/3 -- a rational, not 0.333…
02 · The web as list

Macros expand into HTML and CSS

A document is a tree; a stylesheet is a list of rules — the exact shapes s-expressions were made for. (web html) and (web css) are two pure functions over one representation, and (web sx) is a macro over it.

The page you're reading is the proof

Every element and every CSS rule of this site expands from Scheme — site/index.ss is the whole page; the "Built in pure Scheme" badge shows you the source. A colour is one binding shared by styles and code; DRY is append.

The sx template macro splits at expansion time: the static tree is built once, each unquote becomes a hole wired to a signal — one text node updates, and nothing re-renders. No virtual DOM, no diffing.

;; one form: the markup AND its css -- this is the
;; real definition of the cards further down this page
(define-component (card title . body)
  (style (background (var bg2))
         (border-radius (px 10))
         ("h3" (color (var lapis))))   ; descendants,
  (div (h3 ,title) (p ,@body)))       ; :hover, @media…

;; equal style sets intern to ONE generated class:
;; nine cards below share a single rule

(sx (button (@ (on-click ,bump!))  ; live holes: the
      "clicked " ,(signal-ref n)))  ; tree builds ONCE,
                                    ; holes become effects
03 · Text & the DOM

Typesetting without a layout engine

(web dom) wraps the browser in ordinary procedures. (web typeset) — after pretext — takes the layout engine out of the browser: measure each distinct code point once, then layout is a pure function from metrics to line boxes.

Layout you can compute, not await

Heights are known before anything touches the DOM — virtual scrolls and streaming chat stop guessing — and text can be set where no layout engine exists at all: canvas and WebGL scenes. Greedy first-fit breaking with CJK kinsoku — closing punctuation never starts a line.

It is at work on this site: the hero's subtitle and the Why Scheme? page's headings are set glyph by glyph by (web typeset) — which is why they can dodge your cursor.

;; (web dom): the browser, as ordinary calls
(define el (create-element "span"))
(set-text! el "the glyph")
(append-child! (get-element-by-id "live") el)

;; (web typeset), after pretext: measure once,
;; then layout is pure arithmetic -- no DOM
(define l
  (layout (prepare text (canvas-measurer font))
          max-width line-height))

(layout-height l)   ; known BEFORE anything renders
(for-each place-line! (layout-lines l))
04 · Graphics

Textures in Scheme, frames on the GPU

A pure-Scheme KTX2/Basis transcoder — ETC1S and UASTC, written from the Khronos specs — decodes compressed textures in ~12 KB gzipped, where the official C++ one ships 462 KB. And rendering is GPU-driven: a compute shader culls the frustum, compacts the survivors, and issues one drawIndexedIndirect per geometry — the whole frame is decided on the card.

3D, from s-expressions

It is the sky of the skybox.ss tab in the live editor — switch to it, edit a form, press Run. The relativistic accretion disk of blackhole.ss and the WebGPU fire of particles.ss — a hundred thousand particles whose physics is a compute shader — are tabs beside it.

Because a shader is a datum, macros can write shaders: the hero's twelve thousand particles run their physics in a vertex shader assembled by Scheme.

(define sky-p
  (fx-program!
   '((attribute vec3 a_pos)
     (uniform mat4 u_vp)
     (varying vec3 v_dir)
     (define (main) void
       (set! v_dir a_pos)
       (local vec4 p (* u_vp (vec4 a_pos (fl 0))))
       (set! gl_Position p.xyww)))  ; the sky never moves
   '((precision mediump float)
     (uniform samplerCube u_sky)
     (varying vec3 v_dir)
     (define (main) void
       (set! gl_FragColor (textureCube u_sky v_dir))))))
05 · Networking

S-expressions on the wire, continuations over it

When the backend is also Scheme (Igropyr), requests and replies are s-expressions — there is no protocol to design, read and write are the codec. And with continuations on the server, a whole multi-request dialogue is ordinary control flow.

The dialogue is a process

A wizard, a booking, a transfer — the flow runs as one process whose local bindings are the conversation state. “The user is at the confirm step” means the process is parked at that line — a step order the code cannot express cannot happen.

Death for any reason answers gone — proof the transaction rolled back. On this side it is all (web rpc): datum in, datum out, exact rationals intact; (web ws) and (web sse) push datum streams; (web json) covers every other backend.

;; (web rpc): the wire carries a datum
(rpc "/rpc" '(add 1 2 1/2))     ; => (ok 7/2) -- exact
(ws-connect! "/live" (lambda (msg) ...))

;; the server (Igropyr): a dialogue is ONE process,
;; parked at a line by its continuation
(conversation-start!
  (lambda (req suspend!)
    (let ((req2 (suspend! confirm-page)))  ; round-trip;
      (commit!)                            ; resumes HERE
      done))
  req)

(conversation-resume! id req2)  ; => reply | 'gone
;; 'gone means: rolled back. guaranteed.
06 · Fallback

The page carries its own retreat — generated, not maintained

One mount point, two artifacts: define-wasm-js compiles the same Scheme to WebAssembly and to plain JavaScript, and inlines the loader that probes the engine and picks. No WasmGC — an older Safari, a restricted embedded engine — and the page runs the JS twin. Nobody hand-maintains a second implementation, so nothing drifts.

Two questions, two answers

Engine fallback is automatic: the twin is compiled from the same source, so a differential test can hold the two backends to identical behaviour.

Capability degradation — no WebGL2, no layout box, a 4.6 MB module that should not even be fetched — is application logic, so it is written, not generated: another mount point, define-js, probing and revealing and rolling back in Scheme, reaching the loader through the handle the glue publishes. The page ends up with zero hand-written JavaScript.

;; one source -> wasm AND its JS fallback, loader inlined
(define-wasm-js (app "app.wasm")
  (import (app))
  (run-app))

;; capability gate: another mount point, in Scheme too
(define-js hero-gate
  (import (web js) (web dom))
  (when (webgl2? canvas)               ; probe, reveal, measure
    (js-call (js-get (js-global) "__goeteia_load")
             (js-undefined) "/anim.wasm")))

What's inside

Self-hosting, to the byte

The compiler is written in the Scheme subset it compiles. The self-hosted build recompiles itself and the output is byte-identical — the fixpoint is checked in CI fashion on every change, and every test runs through both stages.

Native Wasm GC objects

Fixnums are unboxed i31refs, pairs and records are GC structs, eq? is one ref.eq. No shadow heap in JavaScript: the host supplies two byte-stream imports and nothing else.

Hygienic macros

syntax-rules and procedural syntax-case with fenders, nested ellipses and datum->syntax, running in a compile-time interpreter with hygiene by renaming.

Real closures, real tail calls

Typed function references with a fast per-arity entry and a generic entry per closure — variadic procedures and apply are cheap, and every tail call is a return_call. A 100M-iteration loop runs in constant stack, in ~150ms.

call/cc & dynamic-wind

Escape continuations ride the Wasm exception-handling proposal: capture is O(1), the normal path costs one try block, and winders unwind inner-to-outer on the way out.

A reactive web stack

(web sx) templates over fine-grained (web reactive) signals, an (web html) renderer, and a (web js) FFI that reaches straight into the host — this page is built with it.

3D and WebGL

(gfx gl) drives WebGL 2 through a command buffer with shaders as s-expressions in (gfx glsl), rendered to either GLSL dialect from the same forms. Shadow maps, PBR, HDR bloom, SSAO, instancing, skeletal animation from (gfx gltf) assets, and transform-feedback particles whose physics is the vertex shader — the title above is twelve thousand of them dodging your cursor.

Scheme-to-Scheme, no codec

When the backend is also Scheme (Igropyr), requests and replies are s-expressions — (rpc "/rpc" '(add 1 2 1/2)) comes back (ok 7/2), the exact ratio intact. (web fetch) makes it direct-style over Wasm JSPI; (web ws) / (web sse) push datum streams; (web json) handles everyone else.

Libraries

R6RS-style (library ...) files with (import (math utils)) resolution, dependencies first; exports are advisory because unused code is pruned anyway.

Quick start

$ git clone https://github.com/guenchi/Goeteia
$ cd Goeteia
$ ./run-tests.sh                # every test, both compiler stages
$ ./build-self.sh               # rebuild the compiler with itself

$ echo '(define (fact n) (if (zero? n) 1 (* n (fact (- n 1)))))
(fact 20)' > fact.ss
$ node rt/compile.mjs goeteia.wasm fact.ss fact.wasm
$ node rt/run.mjs fact.wasm
2432902008176640000

Compiled modules run on any engine with Wasm GC and tail calls: Node 22+, current Chrome / Firefox / Safari, wasmtime. Bootstrapping from source needs Chez Scheme; the checked-in compiler wasm works without it.

site/index.ss×
;; index.html — the homepage shell, authored in Scheme, rendered by
;; Goeteia. The hero inside #live is compiled and mounted live in the
;; browser from hero.ss (see index.js); everything else is static.
(import (web html) (web css) (web component) (chrome) (hl))

;; a titled box: markup and its css in ONE form; every card on the
;; page interns to the same generated class
(define-component (card title . body)
  (style (background (var bg2)) (border (px 1) solid (var line))
         (border-radius (px 10)) (padding (em 1 10) (em 1 20))
         (box-shadow "0 1px 3px rgba(16,20,42,.06)")
         ("h3" (margin 0 0 (em 0 40)) (font-size (em 1))
               (color (var lapis)) (font-weight 600))
         ("p" (margin 0) (color (var dim)) (font-size (em 0 92)))
         ("code" (font-family (var mono)) (color (var lapis))
                 (font-size (em 0 90))))
  (div (h3 ,title) (p ,@body)))

;; the Run button: its css lives on it, pseudo-classes included
(define-component (run-button)
  (style (font-size (px 15)) (padding (em 0 45) (em 2))
         (border-radius (px 6)) (background (var lapis))
         (color "#fff") (border none) (font-weight 600)
         (cursor pointer) (min-width (em 7))
         (:hover (filter "brightness(1.1)"))
         (:disabled (opacity (dec 0 50)) (cursor default)))
  (button (@ (id "run") (disabled #t)) "Run"))

;; ---- the numbered feature showcases: kicker, title, lead, then a
;; text column beside a code block (flip? alternates the sides) ----
(define-component (show num kick title lead flip? txt code)
  ;; the showcase block's whole shape -- kicker, title, the text|code
  ;; feature grid, its responsive single-column fallback -- lives with
  ;; it; the six calls intern to one class
  (style
    (padding (em 1 80) 0 (em 0 80))
    (".kicker" (font-family (var mono)) (font-size (em 0 76)) (font-weight 600) (letter-spacing (em 0 16)) (text-transform uppercase) (color (var azure)))
    ("h2" (font-size (em 1 55)) (margin (em 0 25) 0 (em 0 40)) (letter-spacing "-.01em"))
    (".slead" (color (var dim)) (font-size (em 1 5)) (max-width (em 44)) (margin 0))
    (".slead code" (font-family (var mono)) (color (var lapis)) (font-size (em 0 88)) (background "#eef1f9") (padding (em 0 5) (em 0 35)) (border-radius (px 4)))
    (".feature .txt code" (font-family (var mono)) (color (var lapis)) (font-size (em 0 88)) (background "#eef1f9") (padding (em 0 5) (em 0 35)) (border-radius (px 4)))
    (".feature" (display grid) (grid-template-columns "1fr 1fr") (gap (em 2 60)) (align-items center) (margin-top (em 1 40)))
    (".feature > *" (min-width 0))
    (".feature.flip .txt" (order 2))
    (".feature h3" (font-size (em 1 12)) (margin 0 0 (em 0 50)))
    (".feature p" (color (var dim)) (font-size (em 0 95)) (margin 0 0 (em 0 70)))
    (".feature p b" (color (var ink)))
    (".feature pre" (background (var bg2)) (border (px 1) solid (var line)) (border-radius (px 10)) (padding (em 1 10) (em 1 20)) (overflow-x auto) (font-family (var mono)) (font-size (px 12 50)) (line-height (dec 1 55)) (color (var ink)) (margin 0) (box-shadow 0 (px 1) (px 3) (rgba 16 20 42 (dec 0 6))))
    ,@(hl-styles ".feature pre")
    (@media 64
      (".feature" (grid-template-columns "1fr") (gap (em 1 20)))
      (".feature.flip .txt" (order 0))))
  (div
    (div (@ (class "kicker")) ,(string-append num " · " kick))
    (h2 ,title)
    (p (@ (class "slead")) ,@lead)
    (div (@ (class ,(if flip? "feature flip" "feature")))
      (div (@ (class "txt")) ,@txt)
      (pre ,(raw code)))))

;; the code samples, as plain text; (hl)'s build-time highlighter
;; paints them with the same token classes the live editor uses
(define r6rs-code (highlight
"(define-syntax while              ; syntax-rules
  (syntax-rules ()
    ((_ c body ...)
     (let loop ()
       (when c body ... (loop))))))

(define-syntax inc!               ; procedural syntax-case
  (lambda (x)
    (syntax-case x ()
      ((_ v)   #'(set! v (+ v 1)))
      ((_ v n) #'(set! v (+ v n))))))

(fact 20)  ; => 2432902008176640000 -- exact
(/ 1 3)    ; => 1/3 -- a rational, not 0.333…"))

(define webdsl-code (highlight
";; one form: the markup AND its css -- this is the
;; real definition of the cards further down this page
(define-component (card title . body)
  (style (background (var bg2))
         (border-radius (px 10))
         (\"h3\" (color (var lapis))))   ; descendants,
  (div (h3 ,title) (p ,@body)))       ; :hover, @media…

;; equal style sets intern to ONE generated class:
;; nine cards below share a single rule

(sx (button (@ (on-click ,bump!))  ; live holes: the
      \"clicked \" ,(signal-ref n)))  ; tree builds ONCE,
                                    ; holes become effects"))

(define typeset-code (highlight
";; (web dom): the browser, as ordinary calls
(define el (create-element \"span\"))
(set-text! el \"the glyph\")
(append-child! (get-element-by-id \"live\") el)

;; (web typeset), after pretext: measure once,
;; then layout is pure arithmetic -- no DOM
(define l
  (layout (prepare text (canvas-measurer font))
          max-width line-height))

(layout-height l)   ; known BEFORE anything renders
(for-each place-line! (layout-lines l))"))

(define shader-code (highlight
"(define sky-p
  (fx-program!
   '((attribute vec3 a_pos)
     (uniform mat4 u_vp)
     (varying vec3 v_dir)
     (define (main) void
       (set! v_dir a_pos)
       (local vec4 p (* u_vp (vec4 a_pos (fl 0))))
       (set! gl_Position p.xyww)))  ; the sky never moves
   '((precision mediump float)
     (uniform samplerCube u_sky)
     (varying vec3 v_dir)
     (define (main) void
       (set! gl_FragColor (textureCube u_sky v_dir))))))"))

(define fallback-code (highlight
";; one source -> wasm AND its JS fallback, loader inlined
(define-wasm-js (app \"app.wasm\")
  (import (app))
  (run-app))

;; capability gate: another mount point, in Scheme too
(define-js hero-gate
  (import (web js) (web dom))
  (when (webgl2? canvas)               ; probe, reveal, measure
    (js-call (js-get (js-global) \"__goeteia_load\")
             (js-undefined) \"/anim.wasm\")))"))

(define rpc-code (highlight
";; (web rpc): the wire carries a datum
(rpc \"/rpc\" '(add 1 2 1/2))     ; => (ok 7/2) -- exact
(ws-connect! \"/live\" (lambda (msg) ...))

;; the server (Igropyr): a dialogue is ONE process,
;; parked at a line by its continuation
(conversation-start!
  (lambda (req suspend!)
    (let ((req2 (suspend! confirm-page)))  ; round-trip;
      (commit!)                            ; resumes HERE
      done))
  req)

(conversation-resume! id req2)  ; => reply | 'gone
;; 'gone means: rolled back. guaranteed."))

(define body
  (list
   `(div (@ (class "cols"))
      (div (@ (id "live")))                 ; the hero mounts here
      (section (@ (id "editor"))
        (p (@ (class "lead"))
           (b "Everything beside this is live rendered by Goeteia.") (br)
           "The Scheme below is compiled to WebAssembly " (em "in your browser")
           " and mounted live.")
        (div (@ (class "tabs") (id "tabs")))
        (div (@ (class "code"))
          (pre (@ (class "hl") (id "hl") (aria-hidden "true")))
          (textarea (@ (id "src") (rows "18") (spellcheck "false")
                       (autocapitalize "off") (autocorrect "off")) "loading…"))
        (div (@ (class "bar"))
          ,(run-button)
          (span (@ (id "status") (class "status")) "booting the compiler…"))
        (p (@ (class "hint"))
           "No server compiles this — the page carries the whole compiler ("
           (code "goeteia.wasm") ", ~50 KB gzipped, cached after first load), "
           "and each Run recompiles the source above in about 80 ms.")))

   `(section (@ (id "showcase"))
      ,(show "01" "The language" "R6RS and syntax-case, complete — in the page"
         '("Not a toy subset: hygienic " (code "syntax-rules") " and "
           "procedural " (code "syntax-case") " with fenders, nested "
           "ellipses and " (code "datum->syntax") ", compiled to Wasm GC "
           "right here in your browser.")
         #f
         '((h3 "The whole standard, running above")
           (p "Exact bignums and rationals, " (code "call/cc") " and "
              (code "dynamic-wind") " on Wasm's own exception handling, "
              "and every tail call a " (code "return_call") " — a "
              "hundred-million-iteration loop runs in "
              (b "constant stack") ".")
           (p "The expander is a compile-time interpreter with hygiene by "
              "renaming: a macro's bindings and yours can never collide. "
              "Paste any of this into the editor above and press Run."))
         r6rs-code)
      ,(show "02" "The web as list" "Macros expand into HTML and CSS"
         '("A document is a tree; a stylesheet is a list of rules — the "
           "exact shapes s-expressions were made for. " (code "(web html)")
           " and " (code "(web css)") " are two pure functions over one "
           "representation, and " (code "(web sx)") " is a macro over it.")
         #t
         '((h3 "The page you're reading is the proof")
           (p "Every element and every CSS rule of this site expands from "
              "Scheme — " (code "site/index.ss") " is the whole page; the "
              (b "\"Built in pure Scheme\"") " badge shows you the source. "
              "A colour is one binding shared by styles and code; DRY is "
              (code "append") ".")
           (p "The " (code "sx") " template macro splits at expansion "
              "time: the static tree is built once, each unquote becomes a "
              "hole wired to a signal — one text node updates, and "
              (b "nothing re-renders") ". No virtual DOM, no diffing."))
         webdsl-code)
      ,(show "03" "Text & the DOM" "Typesetting without a layout engine"
         '((code "(web dom)") " wraps the browser in ordinary procedures. "
           (code "(web typeset)") " — after "
           (a (@ (href "https://www.pretext.cool")) "pretext")
           " — takes the layout engine " (em "out") " of the browser: "
           "measure each distinct code point once, then layout is a pure "
           "function from metrics to line boxes.")
         #f
         '((h3 "Layout you can compute, not await")
           (p "Heights are known " (b "before") " anything touches the DOM "
              "— virtual scrolls and streaming chat stop guessing — and "
              "text can be set where no layout engine exists at all: "
              "canvas and WebGL scenes. Greedy first-fit breaking with CJK "
              "kinsoku — closing punctuation never starts a line.")
           (p "It is at work on this site: the hero's subtitle and the "
              (b "Why Scheme?") " page's headings are set glyph by glyph "
              "by " (code "(web typeset)") " — which is why they can dodge "
              "your cursor."))
         typeset-code)
      ,(show "04" "Graphics" "Textures in Scheme, frames on the GPU"
         '("A pure-Scheme KTX2/Basis transcoder — ETC1S and UASTC, "
           "written from the Khronos specs — decodes compressed textures "
           "in ~12 KB gzipped, where the official C++ one ships 462 KB. "
           "And rendering is GPU-driven: a compute shader culls the "
           "frustum, compacts the survivors, and issues one "
           (code "drawIndexedIndirect") " per geometry — the whole frame "
           "is decided on the card.")
         #t
         '((h3 "3D, from s-expressions")
           (p "It is the sky of the " (code "skybox.ss") " tab in the live "
              "editor — switch to it, edit a form, press Run. The relativistic "
              "accretion disk of " (code "blackhole.ss") " and the WebGPU fire of "
              (code "particles.ss") " — a hundred thousand particles whose "
              "physics is a compute shader — are tabs beside it.")
           (p "Because a shader is a datum, " (b "macros can write shaders")
              ": the hero's twelve thousand particles run their physics in "
              "a vertex shader assembled by Scheme."))
         shader-code)
      ,(show "05" "Networking" "S-expressions on the wire, continuations over it"
         '("When the backend is also Scheme ("
           (a (@ (href "https://igropyr.dev")) "Igropyr")
           "), requests and replies are s-expressions — there is no "
           "protocol to design, " (code "read") " and " (code "write")
           " are the codec. And with continuations on the server, a whole "
           "multi-request dialogue is ordinary control flow.")
         #f
         '((h3 "The dialogue is a process")
           (p "A wizard, a booking, a transfer — the flow runs as "
              (b "one process") " whose local bindings are the "
              "conversation state. “The user is at the confirm "
              "step” means the process is parked " (b "at that line")
              " — a step order the code cannot express cannot happen.")
           (p "Death for any reason answers " (code "gone") " — proof the "
              "transaction rolled back. On this side it is all "
              (code "(web rpc)") ": datum in, datum out, exact rationals "
              "intact; " (code "(web ws)") " and " (code "(web sse)")
              " push datum streams; " (code "(web json)") " covers every "
              "other backend."))
         rpc-code)

      ,(show "06" "Fallback" "The page carries its own retreat — generated, not maintained"
         '("One mount point, two artifacts: " (code "define-wasm-js")
           " compiles the same Scheme to WebAssembly " (em "and")
           " to plain JavaScript, and inlines the loader that probes the "
           "engine and picks. No WasmGC — an older Safari, a restricted "
           "embedded engine — and the page runs the JS twin. Nobody "
           "hand-maintains a second implementation, so nothing drifts.")
         #t
         '((h3 "Two questions, two answers")
           (p (b "Engine fallback") " is automatic: the twin is compiled "
              "from the same source, so a differential test can hold the "
              "two backends to " (em "identical") " behaviour.")
           (p (b "Capability degradation") " — no WebGL2, no layout box, "
              "a 4.6 MB module that should not even be fetched — is "
              "application logic, so it is written, not generated: "
              "another mount point, " (code "define-js") ", probing and "
              "revealing and rolling back in Scheme, reaching the "
              "loader through the handle the glue publishes. "
              "The page ends up with zero hand-written JavaScript."))
         fallback-code))

   (section* "features" "What's inside"
      `(div (@ (class "grid"))
        ,(card "Self-hosting, to the byte"
           "The compiler is written in the Scheme subset it compiles. "
           "The self-hosted build recompiles itself and the output is "
           "byte-identical — the fixpoint is checked in CI fashion on "
           "every change, and every test runs through both stages.")
        ,(card "Native Wasm GC objects"
           "Fixnums are unboxed " '(code "i31ref") "s, pairs and records "
           "are GC structs, " '(code "eq?") " is one " '(code "ref.eq") ". "
           "No shadow heap in JavaScript: the host supplies two byte-stream "
           "imports and nothing else.")
        ,(card "Hygienic macros"
           '(code "syntax-rules") " and procedural "
           '(code "syntax-case") " with fenders, nested ellipses and "
           '(code "datum->syntax") ", running in a compile-time "
           "interpreter with hygiene by renaming.")
        ,(card "Real closures, real tail calls"
           "Typed function references with a fast per-arity entry and a "
           "generic entry per closure — variadic procedures and "
           '(code "apply") " are cheap, and every tail call is a "
           '(code "return_call") ". A 100M-iteration loop runs in "
           "constant stack, in ~150ms.")
        ,(card "call/cc & dynamic-wind"
           "Escape continuations ride the Wasm exception-handling "
           "proposal: capture is O(1), the normal path costs one try "
           "block, and winders unwind inner-to-outer on the way out.")
        ,(card "A reactive web stack"
           '(code "(web sx)") " templates over fine-grained "
           '(code "(web reactive)") " signals, an " '(code "(web html)")
           " renderer, and a " '(code "(web js)") " FFI that reaches straight "
           "into the host — this page is built with it.")
        ,(card "3D and WebGL"
           '(code "(gfx gl)") " drives WebGL 2 through a command buffer "
           "with shaders as s-expressions in "
           '(code "(gfx glsl)") ", rendered to either GLSL dialect from "
           "the same forms. Shadow maps, PBR, HDR bloom, SSAO, "
           "instancing, skeletal animation from " '(code "(gfx gltf)")
           " assets, and transform-feedback particles whose physics "
           '(em "is") " the vertex shader — the title above is twelve "
           "thousand of them dodging your cursor.")
        ,(card "Scheme-to-Scheme, no codec"
           "When the backend is also Scheme (" '(code "Igropyr")
           "), requests and replies are s-expressions — "
           '(code "(rpc \"/rpc\" '(add 1 2 1/2))") " comes back "
           '(code "(ok 7/2)") ", the exact ratio intact. "
           '(code "(web fetch)") " makes it direct-style over Wasm JSPI; "
           '(code "(web ws)") " / " '(code "(web sse)") " push datum streams; "
           '(code "(web json)") " handles everyone else.")
        ,(card "Libraries"
           "R6RS-style " '(code "(library ...)") " files with "
           '(code "(import (math utils))") " resolution, dependencies "
           "first; exports are advisory because unused code is pruned "
           "anyway.")))

   `(section (@ (id "quickstart"))
      (h2 "Quick start")
      (pre (code "$ git clone https://github.com/guenchi/Goeteia
$ cd Goeteia
$ ./run-tests.sh                # every test, both compiler stages
$ ./build-self.sh               # rebuild the compiler with itself

$ echo '(define (fact n) (if (zero? n) 1 (* n (fact (- n 1)))))
(fact 20)' > fact.ss
$ node rt/compile.mjs goeteia.wasm fact.ss fact.wasm
$ node rt/run.mjs fact.wasm
2432902008176640000"))
      (p (@ (class "hint"))
         "Compiled modules run on any engine with Wasm GC and "
         "tail calls: Node 22+, current Chrome / Firefox / Safari, wasmtime. "
         "Bootstrapping from source needs Chez Scheme; the checked-in "
         "compiler wasm works without it."))))

;; the page's own stylesheet, as (web css) data -- the raw
;; site/index.css is gone; markup and styles are one language now
(define index-styles
  `(;; the live-rendered hero (built by Goeteia, mounted into #live)
    ("#live" (min-height (em 15)) (padding (em 3 50) 0 (em 1)))
    (".hero" (text-align center))
    (".hero h1"
     (font-family "\"Times New Roman\", Times, serif")
     (font-size (em 4)) (margin 0) (font-weight 400) (letter-spacing (em 0 2))
     (background "linear-gradient(120deg, var(--lapis), var(--azure))")
     (-webkit-background-clip text) (background-clip text) (color transparent))
    (".hero .tagline" (color (var dim)) (font-size (em 1 20)) (margin-top (em 0 70)))
    (".hero .gname" (color (var azure)) (font-weight 600))
    (".hero .sub" (color (var dim)) (margin-top (em 0 20)))
    (".hero .cmd"
     (display inline-block) (margin (em 1 40) auto 0) (padding (em 0 70) (em 1 20))
     (background "#eef1f9") (border (px 1) solid (var line)) (border-radius (px 8))
     (font-family (var mono)) (font-size (px 14)) (color (var ink)))
    (".hero .links" (margin-top (em 1 60)))
    (".hero .btn"
     (display inline-block) (margin 0 (em 0 40)) (padding (em 0 55) (em 1 50))
     (border (px 1) solid (var line)) (border-radius (px 6)) (color (var ink)) (font-weight 600))
    (".hero .btn.primary" (background (var lapis)) (color "#fff") (border-color (var lapis)))
    (".hero .btn:hover" (text-decoration none) (border-color (var lapis)))
    ;; the editor: the page's own source
    ("#editor" (padding (em 2) 0 (em 4)) (border-top (px 1) solid (var line)) (scroll-margin-top (em 4)))
    (".lead" (color (var dim)) (font-size (em 0 95)) (margin 0 0 (em 1)))
    (".lead b" (color (var ink)))
    ;; a colored <pre> under a transparent <textarea>; identical box
    ;; metrics keep the glyphs aligned
    (".tabs" (display flex) (gap (em 0 40)) (margin 0 0 (px -1)))
    (".tabs .tab"
     (flex 1 1 0) (min-width 0) (text-align center)
     (white-space nowrap) (overflow hidden) (text-overflow ellipsis)
     (font-family (var mono)) (font-size (px 12)) (padding (em 0 30) (em 0 80))
     (border (px 1) solid (var line)) (border-radius (px 6) (px 6) 0 0)
     (background "#eef1f9") (color (var dim)) (cursor pointer))
    (@media "(max-width: 36em)"
      (".tabs" (gap (em 0 25)))
      (".tabs .tab" (font-size (px 10 50)) (padding (em 0 30) (em 0 25))))
    (".tabs .tab.active"
     (background "#fff") (color (var ink)) (border-bottom-color "#fff") (font-weight 600))
    (".code" (position relative))
    (".code textarea, .code .hl"
     (margin 0) (padding (em 1)) (border (px 1) solid transparent) (border-radius (px 8))
     (font-family (var mono)) (font-size (px 13)) (line-height (dec 1 50)) (tab-size 2)
     (white-space pre-wrap) (overflow-wrap break-word) (word-break break-word))
    (".code .hl"
     (position absolute) (inset 0) (z-index 0) (overflow hidden)
     (border-color (var line)) (background (var bg2)) (color (var ink))
     (pointer-events none) (box-shadow inset 0 (px 1) (px 3) (rgba 16 20 42 (dec 0 6))))
    (".code textarea"
     (position relative) (z-index 1) (width (pct 100)) (display block)
     (background transparent) (color transparent) (caret-color (var ink))
     (resize vertical))
    (".code textarea:focus" (outline none))
    (".code:focus-within .hl" (border-color (var azure)))
    ,@(hl-styles ".hl")
    (".bar" (display flex) (gap (em 0 90)) (align-items center) (margin (em 0 90) 0 0) (flex-wrap wrap))
    (".status" (font-family (var mono)) (font-size (em 0 82)) (color (var dim)))
    (".status.err" (color "#b0483f"))
    (".hint" (color (var dim)) (font-size (em 0 85)) (margin-top (em 0 50)))
    (".hint code" (background "#eef1f9") (padding (em 0 10) (em 0 40)) (border-radius (px 5)) (font-family (var mono)))
    ;; the numbered feature showcases: kicker + title + text|code
    ("#showcase" (border-top (px 1) solid (var line)) (padding (em 1 20) 0 (em 2)) (max-width (em 66)) (margin 0 auto))
    ;; informational sections below the hero
    ("#features, #quickstart" (padding (em 2 50) 0) (border-top (px 1) solid (var line)) (scroll-margin-top (em 4)))
    (h2 (font-size (em 1 50)) (font-weight 600))
    (".grid" (display grid) (grid-template-columns "repeat(auto-fit, minmax(16em, 1fr))") (gap (em 1)) (margin-top (em 1 40)))
    (@media "(min-width: 64em)" (".grid" (grid-template-columns "repeat(3, 1fr)")))
    ("#quickstart p code" (font-family (var mono)) (color (var lapis)) (font-size (em 0 90)))
    ("#quickstart pre"
     (background "#eef1f9") (border (px 1) solid (var line)) (padding (em 0 90))
     (border-radius (px 8)) (white-space pre-wrap) (font-family (var mono))
     (font-size (px 13)) (overflow-x auto) (color (var ink)))
    ;; wide screens: hero on the left, the live editor on the right
    (@media "(min-width: 64em)"
      (".wrap" (max-width (em 84)))
      (".cols" (display grid) (grid-template-columns "minmax(0, 1fr) minmax(0, 1.1fr)") (gap 0 (em 3)) (align-items start))
      (".cols > *" (min-width 0))
      ("#live" (text-align left) (padding-top (em 9)))
      (".hero" (text-align left))
      (".hero .cmd" (margin-left 0))
      ("#editor" (border-top none) (padding-top (em 3 40))))))

(write-file "index.html"
  (render-page "Goeteia — a page that compiles itself"
               (string-append "The Goeteia homepage renders itself: its Scheme "
                              "source is compiled to WebAssembly in your browser and "
                              "mounted live. Edit the source, press Run, and the page "
                              "below re-renders.")
               (string-append
                 (css->string (base-styles 60))
                 (css->string index-styles)
                 (css->string (styled-css))   ; the element-attached styles
                 (css->string (footer-styles)))
               'index "site/index.ss" body
               (list '(script (@ (type "module") (src "index.js"))))))