Interactive examples

An interactive example is a keyed DOM mount. The site SSRs a placeholder carrying data-specular-mount="<key>"; the browser client scans for those and hands the live element to whatever Mounter you registered under that key.

That is the entire contract, so anything that can write into a DOM node is a first-class specular example: preact, laminar, slinky, tyrian, raw DOM, or ascent. Ascent is not special: it is one adapter (Mounter.fromAscent) over the same hook, wired for you by .interactive.

The two authoring forms

You are documentingUseSource panel comes from
An ascent UI sampleexample/exampleIO + .interactivethe captured expression (macro)
An ascent UI that is the pageillustration/illustrationIO + .livenone
Anything elseexampleDom(key).fromSource(...)a real file, read at build time

.interactive is unchanged from before the hook: it assigns the mount key from the example's id, so SpecularClient.fromPages(pages*) registers it with no work from you. .live is the same assignment for an illustration.

exampleDom is the general form. Its body lives in your Scala.js project, which the JVM DocSpec cannot see (let alone typecheck), so the DocSpec names the file instead of embedding a string. The site build reads it. Nothing shown to a reader was hand-retyped, and a file that moves fails the build rather than silently rotting.

{
  val ref = exampleDom("counter").fromSource("docs/client/src/main/scala/acme/Counter.scala", "demo")
  (ref.mountKey, ref.source.describe)
}
(counter,docs/client/src/main/scala/acme/Counter.scala#demo)

Marking a region

fromSource(path) shows the whole file minus its leading package / import header (a mid-file import inside a block survives; only the header is trimmed).

fromSource(path, marker) shows just the region between two comments:

// specular:begin counter
def mount(el: dom.Element): RIO[Scope, Unit] = ???
// specular:end

The marker is matched as an exact token, so counter never picks up counter-2's region, and a duplicate // specular:begin counter is an error rather than a silent "first one wins". Regions may nest or overlap for different keys: each resolves to its own text, and marker comments never leak into a panel.

Regions are dedented, so marking code inside a method body still reads flush-left.

Writing a Mounter

Mounter has one method. Mounter.sync covers the common foreign case (render(node, el)); Mounter.effect is for setup that needs ZIO.

// preact, via preactile
Mounter.sync(el => Preact.render(MyWidget(), el))

// laminar
Mounter.sync(el => render(el, myElement))

Two guarantees a mounter can rely on, both of which the obvious implementation gets wrong:

  • The Scope is the page's. Anything you acquireRelease (a listener, a subscription, a websocket) lives as long as the page. Returning from mount means "setup finished", not "example finished".

  • Failures are contained. A mounter that fails or dies gets an error box on its own example; the rest of the page still mounts. Mounts are also forked, so a mounter that never returns cannot starve the ones after it.

Mounter speaks org.scalajs.dom.Element because that is what the foreign frameworks already use. If your code speaks ascent's facade instead, specular.client.DomInterop converts either way at zero cost (they are the same runtime object behind two unrelated Scala types).

Mounter, SpecularClient and DomInterop are Scala.js-only, so they live in your client project. This page is a JVM DocSpec and cannot import them, which is exactly why exampleDom names a file rather than capturing an expression.

Registering it in the client

One call in your Scala.js ClientMain covers both kinds:

object ClientMain extends ZIOAppDefault:
  private val pages = Vector(MyPage.doc, OtherPage.doc)

  def run = ZIO.scoped {
    SpecularClient.mountAll(
      SpecularClient.fromPages(pages*) ++ Map("counter" -> Counter.mounter)
    ) *> ZIO.never
  }

fromPages handles every .interactive ascent example and every .live illustration. exampleDom keys are yours to register: specular cannot invent a mounter for code it does not import.

ZIO.scoped around the whole thing on purpose: that scope is the page lifetime the mounters share. ZIO.never keeps it open.

Illustration, not a sample

example always wraps the tree in figure.specular-example with a source panel. That is the right chrome for a copy-paste sample. It is the wrong chrome for a poster, an anatomy widget, or a page that is an Ascent document.

illustration / illustrationIO SSR the tree (and .live remounts it) without that chrome: no source panel, no copy button, a quiet div. Same ids and data-specular-mount path as examples. .assert is still optional. Do not hide .specular-code with CSS; that is a leak of Specular internals into the library docs.

This is the document

No source panel. No copy button. The tree SSRs as itself.

0

A live example, with no UI library

The counter below is plain document.createElement and an acquireReleased click listener: no ascent, no framework. It is RawDomDemo.scala in this repo's docs client, and the panel is that file's counter region, read at build time:

private def mount(el: dom.Element): RIO[Scope, Unit] =
  for
    clicks <- Ref.make(0)
    output = el.ownerDocument.createElement("output")
    button = el.ownerDocument.createElement("button")
    runtime <- ZIO.runtime[Any]
    _       <- ZIO.succeed {
      output.textContent = "0 clicks"
      button.textContent = "Click me"
      el.appendChild(button)
      el.appendChild(el.ownerDocument.createTextNode(" "))
      el.appendChild(output)
    }
    // acquireRelease, so the finalizer runs when the *page* scope closes rather than when
    // setup returns. A per-mount scope would remove the listener before the first click.
    _ <- ZIO.acquireRelease {
      ZIO.succeed {
        val listener: dom.MouseEvent => Unit = _ =>
          Unsafe.unsafe { implicit u =>
            runtime.unsafe.run(clicks.updateAndGet(_ + 1).map(render(output, _))).getOrThrow()
          }
        button.addEventListener("click", listener)
        listener
      }
    }(listener => ZIO.succeed(button.removeEventListener("click", listener)))
  yield ()

private def render(output: dom.Element, n: Int): Unit =
  output.textContent = if n == 1 then "1 click" else s"$n clicks"

This example runs in your browser; enable JavaScript to see it.

If it counts your clicks, a non-ascent mounter ran. That the button keeps working proves the listener's finalizer did not fire when mount returned.

What goes red, and when

Four guards, cheapest first:

MistakeCaught by
File moved, renamed, or marker deletedsbt test: every exampleDom emits a source-resolution test
Two examples claiming one mount keythe site build (validatePages)
Declared key with no registered mountera JS-side drift spec, if you write one; otherwise the browser
Anything still slipping througha visible error box in the example, plus console.error

The first row is the one worth dwelling on. exampleDom is the only node kind that produces a test without .assert, because its correctness depends on the filesystem: a moved file has to go red under a plain test run, not only when someone happens to rebuild the site.

For the third row, SpecularClient.requiredKeys(pages*) is every key the pages declare. Compare it with your registry in a spec that cross-compiles:

test("no drift") {
  assertTrue((SpecularClient.requiredKeys(pages*) -- registry.keySet).isEmpty)
}

Note the asymmetry: a registered key with no node on the current page is silent, because other pages' mount points are absent by design.

{
  val p = page("Demo")(
    example { E.div("an ascent example") }.interactive,
    exampleDom("counter").fromSource("some/File.scala"),
  )
  p.children.collect {
    case e: Example[?] => e.id -> e.mountKey
    case d: DomExample => d.id -> Some(d.mountKey)
  }
}
Vector((demo-ex-1,Some(demo-ex-1)), (demo-ex-2,Some(counter)))

Illegal keys fail loudly

A mount key becomes an HTML attribute value and a client-side map key, so it is restricted to [A-Za-z0-9._-]+ and 128 characters. A bad one throws at construction, which means it fails both sbt test and the site build rather than degrading into an example that quietly never mounts:

zio.ZIO.attempt(exampleDom("\" onload=\"alert(1)"))
java.lang.IllegalArgumentException: specular mount key may contain only letters, digits, '.', '_' and '-', got: " onload="alert(1)
	at specular.MountKey$.validated(Doc.scala:233)
	at specular.Doc$package$.exampleDom(Doc.scala:313)
	at specular.docs.Interactive$.doc$$anonfun$4(Interactive.scala:206)
	at zio.ZIOCompanionVersionSpecific.attempt$$anonfun$1(ZIOCompanionVersionSpecific.scala:100)
	at zio.ZIO$.suspendSucceed$$anonfun$1(ZIO.scala:4915)
	at specular.docs.Interactive.doc(Interactive.scala:206)
	at specular.site.SiteBuilder.Live.renderNode(SiteBuilder.scala:358)
	at specular.site.SiteBuilder.Live.renderNode(SiteBuilder.scala:386)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:204)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:208)
	at specular.site.SiteBuilder.Live.renderNode(SiteBuilder.scala:241)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:204)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:208)
	at specular.site.SiteBuilder.Live.renderPageBody(SiteBuilder.scala:124)
	at specular.site.SiteBuilder.Live.renderOne(SiteBuilder.scala:111)
	at specular.site.SiteBuilder.Live.buildSite(SiteBuilder.scala:58)
	at specular.site.SiteBuilder.Live.buildSite(SiteBuilder.scala:66)
	at specular.site.DocsSite.build(DocsSite.scala:70)
	at specular.site.DocsSite.build(DocsSite.scala:71)
	at specular.site.DocsSite.run(DocsSite.scala:76)

Path rules

Source paths are repo-relative, resolved against specularSourceRoot (default: your build's base directory, passed to the site builder as -Dspecular.source.root). It has to be passed explicitly because projectMatrix starts forked JVMs under .sbt/matrix/<id>, so the builder cannot infer it from its working directory.

Reads are confined to that root, and confinement compares real paths: an absolute path, a .. escape, and a symlink pointing out of the tree are all rejected. A case-only mismatch is rejected too, so a path that works on macOS cannot break Linux CI. Files over 64 KiB are refused rather than inlined into a page.