Showcase

Specular authors mix markdown prose, plain Scala / ZIO values, and ascent UI in the same DocSpec.

This page is the power-user tour: headings, lists, quotes, tables, value examples, and CSS-in-Scala layouts, all from one source that also runs as tests. For the adoption story and wiring, start at Why Specular and Getting started.

Markdown palette

Inline styles

Emphasis with italics, bold, and inline code. Link out to ascent.

Lists

  • Prose via md"..." → commonmark → ascent UI

  • UI examples via example / exampleIO with source capture

  • Values and effects via exampleValue / exampleZIO (same ValueExample node)

  • Interactive mounts via .interactive

  1. Write a DocSpec

  2. Run sbt test

  3. Build the site

Quote

Same AST, two interpreters: tests keep docs honest; the site shows them.

Table

ConstructWhere it livesOutput
md"..."ProseSSR HTML
exampleExamplesource + UI snap
exampleValue / exampleZIOValueExamplesource + result
expectFailFailExamplesource + diagnostics
expectCrashCrashExamplesource + failure
.assertzio-test bridgeCI green/red
.interactiveclient registrylive mount

Raw HTML in markdown is dropped (no XSS footgun):

Plain Scala

Not every docs example is a UI. exampleValue captures a plain expression: source panel plus the printed result. Assert the value the same way you would in zio-test.

{
  val xs = List(1, 2, 3, 4)
  xs.filter(_ % 2 == 0).sum
}
6

Plain ZIO

Effects use the same ValueExample node: exampleZIO stores a success-typed URIO. Site and tests run the body under Scope and print / assert the result.

for
  a <- ZIO.succeed(21)
  b <- ZIO.succeed(2)
yield a * b
42

Compile-fail examples

Guide pages that teach what must not compile use expectFail with a self-contained string snippet (Saferis / zio-test typeCheckErrors style). The site shows the source and the real diagnostics; .assert fails CI if the snippet unexpectedly typechecks.

val x: Int = "nope"
Found:    ("nope" : String)
Required: Int
  val x: Int = "nope"

Runtime-fail examples

expectCrash captures a fallible effect (not URIO). The site renders the source plus the pretty-printed failure; tests fail if the effect succeeds.

ZIO.fail(new IllegalArgumentException("demo failure")): ZIO[Scope, Throwable, Nothing]
java.lang.IllegalArgumentException: demo failure
	at specular.docs.Showcase$.doc$$anonfun$7(Showcase.scala:97)
	at zio.ZIO$.fail$$anonfun$1(ZIO.scala:3251)
	at zio.ZIO$.failCause$$anonfun$1(ZIO.scala:3262)
	at specular.docs.Showcase.doc(Showcase.scala:97)
	at specular.site.SiteBuilder.Live.renderNode(SiteBuilder.scala:294)
	at specular.site.SiteBuilder.Live.renderNode(SiteBuilder.scala:322)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:190)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:194)
	at specular.site.SiteBuilder.Live.renderNode(SiteBuilder.scala:227)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:190)
	at specular.site.SiteBuilder.Live.renderNodes(SiteBuilder.scala:194)
	at specular.site.SiteBuilder.Live.renderPageBody(SiteBuilder.scala:110)
	at specular.site.SiteBuilder.Live.renderOne(SiteBuilder.scala:97)
	at specular.site.SiteBuilder.Live.buildSite(SiteBuilder.scala:58)
	at specular.site.SiteBuilder.Live.buildSite(SiteBuilder.scala:63)
	at specular.site.DocsSite.build(DocsSite.scala:68)
	at specular.site.DocsSite.build(DocsSite.scala:69)
	at specular.site.DocsSite.run(DocsSite.scala:74)

CSS-in-Scala layouts

Examples are real ascent trees: define CssClasses with the typed S catalog, then apply them like any other attr:

{
  val ink    = Color.hex("#1a1a1a")
  val accent = Color.hex("#0b5fff")

  object Callout
      extends CssClass(
        S.padding(1.rem, 1.25.rem),
        S.borderLeft(Border.solid(4.px, accent)),
        S.background(Color.hex("#eef4ff")),
        S.color(ink),
        S.borderRadius(0.px, 8.px, 8.px, 0.px),
        S.margin(0.75.rem, 0.px),
      )

  object Badge
      extends CssClass(
        S.display.inlineBlock,
        S.padding(0.15.rem, 0.55.rem),
        S.fontSize(0.75.rem),
        S.fontWeight(600),
        S.letterSpacing(0.04.em),
        S.textTransform.uppercase,
        S.color(accent),
        S.background(Color.hex("#e8f0ff")),
        S.borderRadius.px(999),
      )

  E.div(
    Callout,
    E.span(Badge, "tip"),
    E.p("Callouts, badges, and cards are ordinary ", E.code("CssClass"), " values, not a separate docs DSL."),
  )
}
tip

Callouts, badges, and cards are ordinary CssClass values, not a separate docs DSL.

{
  val ink = Color.hex("#1a1a1a")

  object Row
      extends CssClass(
        S.display.flex,
        S.gap(1.rem),
        S.flexWrap.wrap,
        S.alignItems.stretch,
      )

  object Card
      extends CssClass(
        S.display.grid,
        S.gap(0.5.rem),
        S.padding(1.25.rem),
        S.background(Color.hex("#ffffff")),
        S.color(ink),
        S.border(Border.solid(1.px, Color.hex("#e2e2e2"))),
        S.borderRadius.px(10),
        S.boxShadow(Shadow(0.px, 1.px, 2.px, Color.rgba(0, 0, 0, 0.04))),
      )

  E.div(
    Row,
    E.div(Card, E.h3("Tests"), E.p("Every ", E.code(".assert"), " example fails CI when it drifts.")),
    E.div(Card, E.h3("Site"), E.p("SSR via ascent-html: same Mount engine as the browser.")),
    E.div(Card, E.h3("Live"), E.p("Interactive examples remount into ", E.code("#<slug>-ex-*"), " wrappers.")),
  )
}

Tests

Every .assert example fails CI when it drifts.

Site

SSR via ascent-html: same Mount engine as the browser.

Live

Interactive examples remount into #<slug>-ex-* wrappers.

Interactive + styled

Combine CssClass with sq state: still one exampleIO block:

{
  val ink    = Color.hex("#1a1a1a")
  val accent = Color.hex("#0b5fff")

  object Card
      extends CssClass(
        S.display.grid,
        S.gap(0.5.rem),
        S.padding(1.25.rem),
        S.background(Color.hex("#ffffff")),
        S.color(ink),
        S.border(Border.solid(1.px, Color.hex("#e2e2e2"))),
        S.borderRadius.px(10),
        S.boxShadow(Shadow(0.px, 1.px, 2.px, Color.rgba(0, 0, 0, 0.04))),
      )

  object AccentButton
      extends CssClass(
        S.padding(0.45.rem, 0.9.rem),
        S.border.none,
        S.borderRadius.px(6),
        S.background(accent),
        S.color(Color.hex("#ffffff")),
        S.fontWeight(600),
        S.cursor.pointer,
        Selector(PseudoClass.hover, S.background(Color.hex("#094acc"))),
      )

  for on <- sq(false)
  yield E.div(
    Card,
    E.p(on.map(v => if v then "Status: on" else "Status: off")),
    E.button(
      AccentButton,
      Events.onClick(_ => on.update(!_)),
      on.map(v => if v then "Turn off" else "Turn on"),
    ),
  )
}

Status: off