Timelines and dopesheets

Two blocks in this chapter draw time, and they draw different time. timeline draws calendar time: the 20th of February, the Mondays in March, the leap day in 2024. dopesheet draws animation time: frame 0, then frame 1, then frame 2, twelve of them a second. One is a dated axis computed by the build; the other is a window sliding over a sprite sheet in the reader's browser. They share a chapter because the question they answer looks the same and their answers have nothing in common — and because both are @native, for two different reasons. Two kinds of time at the end of this chapter is that comparison.

Both are diagram shapes. Neither is a page block: a timeline and a dopesheet are children of a diagram or a container, positioned with x / y like a rect, and connectable by edges. The diagram canvas covers the surface they sit on.

Every diagram on this page was built before it was written down. Type them and compare.

§ 1A dated axis

Here is a complete document. Save it as roadmap.wcl:

roadmap.wclwcl
import <wdoc.wcl>

site roadmap {
  title = "Roadmap"
}

page plan {
  title = "The 2026 plan"

  h1 "The 2026 plan"

  diagram { width = 560  height = 200
    timeline { width = 560.0  height = 200.0
      title = "2026 roadmap"
      start = "2026-01-01"
      end   = "2026-12-31"
      unit  = :months
      items = [
        { label: "Kickoff", on: "2026-02-20" },
        { label: "Beta",    on: "2026-05-10" },
        { label: "Release", on: "2026-09-20", side: :far },
      ]
      phases = [
        { label: "Build",  from: "2026-02-01", to: "2026-06-15" },
        { label: "Polish", from: "2026-06-15", to: "2026-11-01" },
      ]
    }
  }
}

Build it into a folder and read the result from a server:

text
$ wcl wdoc build roadmap.wcl --out _site
wrote 1 page

The page holds one diagram, and the diagram holds the timeline declared above — the same items and phases, rendered:

2026 roadmapBuildPolishJan 2026FebMarAprMayJunJulAugSepOctNovDecKickoffBetaRelease

Read the drawing against the source. The spine runs from start to end. The tick marks under it are real calendar months — the 1st of each, with the year printed on January. The three items are the dated markers, each with a lead line and a label, alternating above and below the axis until side: :far pins one. The two phases are the vertical dividers with a heading between them.

Everything after this section refines that one picture.

A timeline does not fill its diagram

width and height are the timeline's own, and they default to 480.0 × 200.0 whatever the enclosing diagram measures. A timeline with no size inside a 900 × 300 diagram draws 480 × 200, and the diagram's viewBox shrinks around it — the axis is not clipped, but it is not the diagram either. Write the diagram's numbers twice, as every example here does. Note the types: a diagram takes integers, a timeline takes floats.

§ 2Dates

There is no date type in WCL. Every date on a timeline is a plain string, and the timeline parses it. Four spellings are accepted:

WrittenMeans
"2026-03-15"midnight at the start of 15 March 2026
"2026-03-15 14:30"14:30 that day
"2026-03-15T14:30"the same instant, ISO-8601 spelling
"2026-03-15 14:30:05"seconds are optional, and parsed when given

Zero-padding is not required: "2026-3-1" parses as 1 March. There is no timezone and no offset — a timeline places instants on one unbroken scale, and reads every date on it the same way. Values and primitives covers the string forms themselves.

An unreadable date is dropped, not reported

A date the parser cannot read yields nothing at all: the item, the phase boundary or the card is skipped, the build prints no warning, and the page renders without it. "15/03/2026" and "March 15" both vanish this way. The same silence covers start and end — an unparseable end falls back to the fitted scale, so the axis quietly becomes an auto-fitted one. If a milestone is missing from a drawing, check its date string first.

§ 3The scale

start and end set the two ends of the axis. Both are optional, and what happens when you omit them is worth knowing exactly, because the timeline fits itself to your data:

What you writeThe scale runs
start and endexactly between them
one of the twofrom the one you gave to the fitted other end
neitherfrom the first dated event to the last
neither, and one event onlyfrom that instant to 24 hours later
nothing an end can be fitted froma bare spine — no ticks, nothing pinned

"Dated event" means every date the block carries: each item's on, each phase's from and to, and each card's on. A phase that reaches past the last milestone widens the axis, and so does a card.

The last row is the one to read twice. Both ends have to resolve before a single tick is drawn, so a start with nothing dated to fit the other end against draws the bare spine too — not a half-fitted axis. A timeline that has lost its ticks has usually lost its events.

The fitted scale has a consequence you can see. With no start, the earliest event sits exactly on the left end of the spine, with no room before it. Give a start a week earlier when a milestone needs breathing space:

Preview

Fitted — the first item is the left endMarAprMayKickoffBeta
Fitted — the first item is the left endMarAprMayKickoffBeta

Example

diagram {
  width = 520
  height = 150
  timeline {
    width = 520.0
    height = 150.0
    title = "Fitted — the first item is the left end"
    items = [{ label: "Kickoff", on: "2026-02-20" }, { label: "Beta", on: "2026-05-10" }]
  }
}

§ 4Ticks land on calendar boundaries

A tick is not the axis divided into equal parts. It is a real calendar boundary: the 1st of a month, a Monday, an hour. The unit field picks which boundary, and the timeline walks the calendar from the start of the scale to its end.

§ 4.1Choosing the unit

Omit unit and the timeline picks one from the length of the scale:

Scale longer thanUnit chosen
730 days:years
75 days:months
21 days:weeks
2 days:days
2 hours:hours
anything shorter:minutes

Write unit yourself when the automatic choice reads badly. A five-month plan is :months either way, but a three-week sprint auto-picks :weeks where you may want :days.

§ 4.2Tick labels

Each unit floors to its own boundary and prints its own label:

UnitBoundaryLabel
:minutesthe minute09:05
:hoursthe hour09:00
:daysmidnight5 Mar
:weeksMonday midnight9 Mar
:monthsthe 1stMar — and Jan 2026 on January
:years1 January2026

The year rides on the January tick and nowhere else, which is what makes a multi-year :months axis readable without printing 2026 twelve times.

The walk starts by flooring the scale's start to the unit, so it can begin before the axis does. A tick that lands outside start..end is not drawn. That is why the fitted timeline above, whose first item is 20 February, shows its first tick at 1 March: the 1 February boundary is off the left end.

§ 4.3The tick interval

every sets how many units apart the ticks are. Omit it and the timeline picks from a ladder per unit, taking the first interval that brings the count to twelve or fewer:

UnitIntervals tried
:minutes1, 2, 5, 10, 15, 30, 60
:hours1, 2, 3, 6, 12, 24
:days1, 2, 5, 7, 14, 28
:weeks1, 2, 4, 8
:months1, 2, 3, 6, 12
:years1, 2, 5, 10, 20, 50

If no interval on the ladder gets there, the last one is used and the axis simply carries more ticks. A nine-hour window therefore ticks hourly, and a two-week window ticks every second day:

Preview

Launch day09:0010:0011:0012:0013:0014:0015:0016:0017:0018:00FreezeDeployAll-clear
Launch day09:0010:0011:0012:0013:0014:0015:0016:0017:0018:00FreezeDeployAll-clear

Example

diagram {
  width = 520
  height = 140
  timeline {
    width = 520.0
    height = 140.0
    title = "Launch day"
    start = "2026-03-15 09:00"
    end = "2026-03-15 18:00"
    items = [{ label: "Freeze", on: "2026-03-15 10:30" }, { label: "Deploy", on: "2026-03-15 13:00" }, { label: "All-clear", on: "2026-03-15 16:45" }]
  }
}

The walk stops after 256 ticks

A tick loop that walked a calendar for ever would hang a build, so it stops at 256 boundaries and the rest of the spine stays bare. Two things get you there, and both involve overriding an automatic choice. An every far too fine for the span: unit = :years with every = 1 across 1600–2200 labels 1600 to 1855 and stops. And a unit far too fine for the span, because each ladder has a coarsest rung — unit = :months over the same 600 years exhausts its ladder at 12 months and stops at Jan 1855 too. An axis that chooses both for itself will not reach the cap. Coarsen the unit, or widen every, rather than shortening the axis.

§ 5Milestones

items is the list of point events. Each one is a label and a date:

wcl
items = [
  { label: "Kickoff", on: "2026-02-20" },
  { label: "Beta",    on: "2026-05-10" },
  { label: "Release", on: "2026-09-20", side: :far },
]

Each item draws three things: a small filled marker on the spine, a lead line out from it, and the label at the end of the lead. Items alternate: the first goes to the near side, the second to the far, the third back to the near, and so on down the list.

side overrides the alternation for one item. :near is above a horizontal axis and left of a vertical one; :far is below and right. :auto means the same as writing nothing.

The two spellings above are two variants of one union, and which one you get is decided by the shape of the record you wrote:

wcl
union TimelineItem {
  On      { label: utf8  on: utf8 }
  OnSided { label: utf8  on: utf8  side: symbol }
}

A bare { label: …, on: … } coerces to On; add a side: key and it coerces to OnSided. That is the ordinary bare-record rule — see Lists, tensors and records. The practical consequence is that there is no partial item. An item needs both a label and an on, and a record matching neither variant fails the build rather than leaving a marker silently out of the drawing. Drop the date from roadmap.wcl's first item and the build stops:

text
$ wcl wdoc build roadmap.wcl --out _site
wcl::eval::schema_violation

  × no variant of 'TimelineItem' matches the supplied shape
    ╭─[roadmap.wcl:18:7]
 17 │           unit  = :months
 18 │ ╭─▶       items = [
 19 │ │           { label: "Kickoff" },
 20 │ │           { label: "Beta",    on: "2026-05-10" },
 21 │ │           { label: "Release", on: "2026-09-20", side: :far },
 22 │ ├─▶       ]
    · ╰──── schema violation
 23 │           phases = [
    ╰────

1 schema violation

Contrast that with a date the parser cannot read, which is dropped in silence. The shape of an item is checked by the schema; the content of its date string is not.

§ 6Phases

A phase is a named stretch of the axis — discovery, build, launch. Each one is a label and two dates:

Preview

Project phasesDiscoveryDesignBuildLaunchJan 2026FebMarAprMayJunJulAugSepOctNovDecSign-offGA
Project phasesDiscoveryDesignBuildLaunchJan 2026FebMarAprMayJunJulAugSepOctNovDecSign-offGA

Example

diagram {
  width = 560
  height = 190
  timeline {
    width = 560.0
    height = 190.0
    title = "Project phases"
    start = "2026-01-01"
    end = "2026-12-31"
    unit = :months
    phases = [{ label: "Discovery", from: "2026-01-01", to: "2026-03-01" }, { label: "Design", from: "2026-03-01", to: "2026-05-15" }, { label: "Build", from: "2026-05-15", to: "2026-10-01" }, { label: "Launch", from: "2026-10-01", to: "2026-12-31" }]
    items = [{ label: "Sign-off", on: "2026-04-20" }, { label: "GA", on: "2026-11-10" }]
  }
}

A phase is drawn as two boundary dividers and a heading, not as a filled band. The dividers stand perpendicular to the axis at from and at to, and the label sits centred between them. Four back-to-back phases like the ones above share their boundaries, so the drawing reads as one divided ruler rather than four separate bars.

Each phase takes the next colour from the wdoc-series-1wdoc-series-8 palette, by its position in the list, wrapping after eight. Those are the same series classes the charts use, so a site theme recolours a timeline and a chart together — see Themes and styling. The rest of the drawing carries wdoc-axis and wdoc-axis-label on the spine and ticks, wdoc-timeline-divider and wdoc-timeline-phase-label on the phases, wdoc-timeline-marker, wdoc-timeline-connector and wdoc-timeline-label on the items, and wdoc-chart-title — the chart's, shared — on the title. A class block naming any of them restyles every timeline on the site.

A timeline's own class and id go nowhere

class and id are declared on Timeline — they are on the field table below — but the renderer emits neither. timeline { class = ["mine"] id = roadmap } produces no mine class and no id attribute anywhere in the SVG, and there is no error to tell you so. Style through the wdoc-* classes above, and reach for the site theme when a whole book needs one palette. The class a card child carries is emitted; it is only the timeline itself that drops them.

Phases may overlap, and nothing checks that from precedes to. A phase written backwards draws its two dividers in the same two places and its heading between them, which is rarely what you meant.

§ 7Event cards

A milestone label is one line of plain text. When an event needs a paragraph, a list or a callout, pin a card to the axis instead. A card is the ordinary rich-text diagram shape — Trees, node tables and cards covers it in full — and a timeline reads two of its fields to place it: on, the date it hangs from, and side.

Preview

Release historyJan 2026FebMarAprMayJunJulAugSepOctNovDec
1.0

First stable release. The public API is frozen.

1.4

Plugin system, and the wdoc generator.

2.0

Rewrite: lazy evaluation, schema checking, LSP.

Release historyJan 2026FebMarAprMayJunJulAugSepOctNovDec
1.0

First stable release. The public API is frozen.

1.4

Plugin system, and the wdoc generator.

2.0

Rewrite: lazy evaluation, schema checking, LSP.

Example

diagram {
  width = 700
  height = 300
  timeline {
    width = 700.0
    height = 300.0
    title = "Release history"
    unit = :months
    start = "2026-01-01"
    end = "2026-12-31"
    card {
      on = "2026-02-01"
      title = "1.0"
      width = 200.0
      height = 96.0
      text {
        span "First "
        span "**stable**"
        span " release. The public API is frozen."
      }
    }
    card {
      on = "2026-05-15"
      side = :far
      title = "1.4"
      width = 200.0
      height = 96.0
      text {
        span "Plugin system, and the "
        span "**wdoc**"
        span " generator."
      }
    }
    card {
      on = "2026-10-01"
      title = "2.0"
      width = 200.0
      height = 96.0
      text {
        span "Rewrite: lazy evaluation, schema checking, LSP."
      }
    }
  }
}

Cards carry their own width and height (160.0 × 90.0 by default) and alternate sides exactly as items do — but on their own count. The first card goes near whether or not an item sat there already, so a timeline mixing items and cards can put both on one side of the spine. Pin the ones that clash with side.

A card is clamped inside the timeline's box. A card near the end of the axis slides back in rather than hanging off the edge, which keeps the diagram tight but means a wide card near a boundary no longer sits centred on its date. Its lead line still points at the right place.

§ 8Vertical timelines

direction = :vertical turns the axis on its side: time runs down the page, and items and cards alternate left and right. Everything else — units, ticks, phases, cards — behaves identically. A vertical timeline suits a long release history in a narrow column, where a horizontal one would crush its labels together.

Preview

Release history1.x2.xSepOctNovDecJan 2026FebMarAprMayJunJulAug1.0 alpha1.02.0 beta2.0
Release history1.x2.xSepOctNovDecJan 2026FebMarAprMayJunJulAug1.0 alpha1.02.0 beta2.0

Example

diagram {
  width = 300
  height = 320
  timeline {
    width = 300.0
    height = 320.0
    direction = :vertical
    title = "Release history"
    unit = :months
    items = [{ label: "1.0 alpha", on: "2025-10-15" }, { label: "1.0", on: "2026-01-10" }, { label: "2.0 beta", on: "2026-05-01" }, { label: "2.0", on: "2026-07-10" }]
    phases = [{ label: "1.x", from: "2025-09-01", to: "2026-02-01" }, { label: "2.x", from: "2026-02-01", to: "2026-08-01" }]
  }
}

§ 9Timeline fields

PropertyTypeRequiredDescription
xf64noTimeline x position within the enclosing diagram.
yf64noTimeline y position within the enclosing diagram.
widthf64noTimeline width — match the enclosing diagram.
heightf64noTimeline height — match the enclosing diagram.
ididentifiernoOptional explicit HTML id.
classlist<utf8>noOptional style classes.
titleutf8noTimeline title.
directionsymbolnoAxis direction: :horizontal (default) or :vertical.
unitsymbolnoTick granularity: :minutes / :hours / :days / :weeks / :months / :years (auto from the span when omitted).
startutf8noISO date scale start (auto-fits from the items when omitted).
endutf8noISO date scale end (auto-fits from the items when omitted).
everyi64noOverride the tick interval in units (auto ~6–12 ticks when omitted).
phaseslist<TimelinePhase>noDated bands — list<TimelinePhase>, each { label, from, to }.
itemslist<TimelineItem>noDated point events — list<TimelineItem>, each { label, on } (auto-alternates side; add side: :near|:far to pin one).
connect_pointslist<AnchorSide>noDiagram edge-attach sides, like any shape.

Child blocks

SlotAcceptsMultipleDescription
cardscardyesRich-text event cards, each pinned to a date via on.

§ 10A sprite sheet, played

A dopesheet is the other half of this chapter, and it starts from a picture rather than a date. Give it a sprite sheet, tell it how big one frame is, and it plays the frames:

Preview

Example

diagram {
  width = 96
  height = 96
  dopesheet "../assets/pixel-coin.png" {
    frame_width = 12
    frame_height = 12
    scale = 6.0
    fps = 12.0
    x = 12.0
    y = 12.0
  }
}

That sheet is 72 × 12 pixels: six 12 × 12 frames in one row. Nothing in the block says "six" — the build reads the sheet's pixel dimensions off the file and divides. scale = 6.0 draws each 12-pixel frame at 72, and fps = 12.0 runs it at twelve frames a second. Click the coin to pause it, and click again to resume.

The sheet is named by an inline label, the way an image block names its source, and the path is relative to the build entry file — not to the page that declares it. That is why the snippet above reads ../assets/pixel-coin.png: this book's entry sits one folder below the assets it shares, and the page's own location has nothing to do with it. Images, videos and file assets covers the rule. The file is copied into the output's _wdoc/ folder and referenced by URL, so the frames appear when the output is served and not when a page is opened directly from disk.

A dopesheet with no frame size draws nothing

frame_width and frame_height have no defaults, and a dopesheet missing either — or carrying an empty source — renders as nothing at all. No error, no placeholder, no gap in the diagram. That is the same best-effort silence a failed shape lowering has. A missing sprite-sheet file is different: that one fails the build when the copy step reaches it.

§ 11Frame geometry

A sheet is a grid, and a few numbers describe it. The ones beyond the frame size handle sheets that are padded or gapped:

FieldDefaultWhat it measures
frame_width / frame_heightnone — requiredone frame, in sheet pixels
offset_x / offset_y0pixels before the first frame
stride_x / stride_ythe frame sizeorigin-to-origin step between frames
columnsas many whole strides as fit across the sheetframes per row

Stride is separate from frame size because a sheet may put a gutter between its frames. A 16-pixel frame drawn every 18 pixels is frame_width = 16 with stride_x = 18; the two pixels between are never sampled.

The default columns is a division, and it truncates:

text
columns = (sheet_width  - offset_x) / stride_x     # integer division, at least 1
rows    = (sheet_height - offset_y) / stride_y     # same, and never authored
total   = columns * rows

On the coin sheet that is (72 - 0) / 12 = 6 columns, one row, six frames. Add offset_x = 4 and stride_x = 14 and it becomes (72 - 4) / 14 = 4. Note what is being counted: whole strides, not whole frames. A fifth stride would have to start at pixel 60 and reach 74, past the right edge, so it is not counted — even though a fifth 12-pixel frame starting at 60 would have fitted. Say columns = 5 when you mean that. rows is always computed; there is no field for it.

A frame is addressed by one flat index, and the index walks the grid row by row:

text
column = index % columns                   # 0-based
row    = index / columns                   # integer division

window = ( offset_x + column * stride_x,
           offset_y + row    * stride_y,
           frame_width, frame_height )

So a 4-column sheet plays 0, 1, 2, 3 across the top row and then wraps to 4 at the start of the second. That arithmetic runs twice: once in the build, which windows the group on the from frame, and once in the player script, which advances it. Both read the same numbers, because the build stamps them onto the rendered group as data-dope-* attributes and the player reads them back.

columns is a claim, not a measurement

An explicit columns is trusted. Write columns = 10 for the six-frame coin sheet and the play range runs to frame 9, whose window lies past the right edge of the image and draws blank. Give columns when the build cannot measure the sheet — an http(s):// source, or a format whose header it cannot read — and let it be computed otherwise.

§ 12The play range and playback

from and to are flat frame indices, and the range they name is inclusive. Both are clamped to the sheet: from into 0..total - 1, and to into from..total - 1, so to = 99 on a six-frame sheet plays to frame 5 rather than failing. Omit to and it is the last frame; omit from and it is 0.

The dopesheet below plays only the first three frames, at half the speed, and starts stopped. Click it to play:

Preview

Example

diagram {
  width = 96
  height = 96
  dopesheet "../assets/pixel-coin.png" {
    frame_width = 12
    frame_height = 12
    from = 0
    to = 2
    fps = 6.0
    autoplay = false
    scale = 6.0
    x = 12.0
    y = 12.0
  }
}

Six more fields control the rest of the playback:

FieldDefaultEffect
fps12.0frames a second
looptruerestart at the end; false stops on the last frame
autoplaytruestart on load; false waits for a click
controlstruedraw the centred play glyph
scale1.0display size — a 12-pixel frame at 6.0 draws 72 wide
smoothfalsetrue opts into the browser's image smoothing

The glyph is while a dopesheet is paused and once a non-looping one has finished, and it is hidden while the animation runs. Pixel art is drawn with nearest-neighbour scaling by default, which is what smooth = false means. Set smooth = true for a sheet of photographs or smooth vector frames, where hard pixel edges would be an artefact rather than the style.

controls = false hides the glyph, not the click

The whole dopesheet group listens for a click, and controls = false only stops the play glyph being drawn. A reader can still pause a dopesheet that shows no control, and will then have no way of telling that it is paused. Reach for controls = false when a dopesheet is decorative and loops for ever, not when you want it uninterruptible.

§ 12.1Tracks and keys

The block is named after the animator's dope sheet — the grid of tracks down one axis and frames across the other, with a key marked in each cell that changes. A wdoc dopesheet is one of those tracks: the sprite sheet is the track, and its keys are the frame indices, one per frame with no gaps between them. There is no track child block and no per-key field.

Several tracks are several dopesheets in one diagram. Each carries its own sheet, its own range and its own fps, and each is placed with x / y like any shape — so a character, its shadow and a spinning pickup are three blocks that happen to overlap. Shapes are drawn in the order they are declared, so a dopesheet written later overlays one written earlier. That draw order is the whole compositing model: there is no z-index and no shared clock, and two dopesheets at different fps values drift apart as they run.

§ 13Dopesheet fields

PropertyTypeRequiredDescription
sourceutf8yesSpritesheet image path (the inline label), relative to the build entry file.
frame_widthi64yesSize of one frame in the sheet (pixel width).
frame_heighti64yesSize of one frame in the sheet (pixel height).
offset_xi64noPixel offset to the first frame's left edge (default 0).
offset_yi64noPixel offset to the first frame's top edge (default 0).
stride_xi64noOrigin-to-origin x step between frames (default = frame_width).
stride_yi64noOrigin-to-origin y step between frames (default = frame_height).
columnsi64noFrames per sheet row (default: fit from the sheet width).
fromi64noFirst frame index to play (default 0).
toi64noLast frame index to play, inclusive (default: the last frame).
fpsf64noPlayback rate in frames/second (default 12).
loopboolnoRestart at the end (default true).
autoplayboolnoStart playing on load (default true).
controlsboolnoClick play/pause toggle (default true).
smoothboolnoAnti-alias instead of the default image-rendering: pixelated.
scalef64noDisplay scale (default 1.0).
xf64noPosition x within the enclosing diagram / container.
yf64noPosition y within the enclosing diagram / container.
ididentifiernoOptional explicit HTML id.
classlist<utf8>noOptional style classes.
anchor_leftf64noDiagram anchor insets (left/right/top/bottom), like any SvgBlock.
connect_pointslist<AnchorSide>noDiagram edge-attach sides, like any SvgBlock.

§ 14Two kinds of time

Here is the comparison, now that both halves are on the table. The two blocks share a subject and almost nothing else:

timelinedopesheet
A position on it isa datea frame index
Time comes fromISO strings you authorcounting up from from at fps
Who computes itthe build, against a real calendarthe reader's browser, while the page is open
What reaches the pagefinished SVG — spine, ticks, markers, cardsone windowed <svg> plus the frame geometry
The scale is set bystart / end, or a fit to the eventsthe sheet's own pixel dimensions
In Markdown and PDFthe same drawingthe from frame, frozen
Reach for it whenthe reader needs to see whenthe reader needs to see motion

§ 14.1Why both are native

Most wdoc blocks are written in WCL. A block declares a lower function returning shapes, the renderer recurses until only leaves remain, and no Rust is involved — that is how the charts work, and how Writing your own blocks says you add your own. Both blocks in this chapter carry @native instead, which is the declaration that a kind is drawn by the renderer and has no lower at all. A wdoc block declares exactly one of the two; neither and both are build errors.

For the timeline, the missing piece is the calendar. Placing a tick on 1 March means knowing that February had 28 days this year and 29 the last. Stepping a month means adding a variable number of days. Flooring to a week means knowing which day was Monday. WCL has arithmetic and it has strings, but it has no date type and no calendar, so a WCL lower could not work out where the ticks go — there would be nothing for it to return. The renderer does the calendar work and hands back finished geometry.

For the dopesheet, the missing pieces are the file and the clock. The frame grid depends on the sheet's pixel dimensions, read off the image file while the site builds, and a WCL expression cannot open a file. The animation is not a drawing at all: it is a script advancing one viewBox on a timer, shipped once per site and wired to the data attributes the build stamped on the group. Neither is expressible as a value a lower could return.

The two reasons are worth keeping apart, because they generalise differently. A block is native when what it needs is knowledge the language does not have — a calendar, a font metric, the bytes of a file. It is also native when what it produces is not a shape — a script, an HTML fragment, a copied asset. timeline is the first kind and dopesheet is mostly the second. A block of your own that fits neither description should be lowering in WCL instead.

Native does not mean HTML-only

Both kinds declare coverage on all four backends, so neither needs an @except to survive a Markdown or PDF build. A timeline renders the same static drawing everywhere. A dopesheet renders its from frame and stops — the geometry is still stamped on the group, but no player runs beside a .md file or inside a PDF. Output targets covers what each backend does with a diagram.

§ 15Where to go next