Skip to content

SlideShow.kt

src/jvmMain/kotlin/afield/ui/slides/SlideShow.kt · 869 lines

package and imports — 52 lines, from line 1
package afield.ui.slides

import afield.app.protocol.AudienceContent
import afield.app.protocol.PresentationState
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import afield.app.protocol.Execute
import afield.app.protocol.put
import afield.app.server.documents
import afield.app.server.home
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.notes
import afield.runtime.execution.plus
import afield.runtime.lifecycle.map
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.custom
import afield.ui.notation.string
import afield.ui.notation.with
import afield.ui.visualization.h2

import afield.app.protocol.HideSlideNote
import afield.app.protocol.Message
import afield.app.protocol.SelectAndEditBox
import afield.app.protocol.ShowSlideNote
import afield.app.protocol.SlideAnnotation
import afield.app.protocol._send
import afield.app.protocol.payload
import afield.audio.synth.*
import afield.data.table.get
import afield.document.graph.Box
import afield.document.graph.box
import afield.document.graph.templateId
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.ui.output.markdown
import afield.ui.output.outputDocument
import afield.ui.output.templates.knownValues
import afield.ui.output.templates.replaceTemplate
import afield.ui.output.templates.templates
import afield.app.diagnostics.logging.lprintln
import afield.document.properties.Delegation
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.MarkdownParser
import java.util.UUID

SlideShow - A DSL for creating guided tours through Field documents

This system supports creating interactive guides with: - Step-by-step navigation - Custom link callbacks that execute Kotlin code - Template system for displaying live data - Annotations and visual highlighting

Example usage:

// Set up properties that templates can reference
var counter = 0
this.properties["counter"] = counter

slideShow {
    step("Introduction") {
        note("""
            <h2>Welcome!</h2>
            <p>Counter value: {{counter}}</p>
            <p><a href="#custom:increment">Click to increment</a></p>
            <p><a href="#slideshow:next">Continue →</a></p>
        """)

        // Register a callback for the custom link
        linkCallback("increment") {
            counter++
            here.properties["counter"] = counter
            // Refresh to show new value
            show.goToStep(show.currentStepIndex)
        }

        selectBox(myBox)
    }
}

Custom Links: Use #custom:callbackName in href attributes, then register with linkCallback("callbackName") { ... }

Templates: Use {{propertyName}} to reference box properties or Kotlin variables in the guide's scope

Navigation Links: - #slideshow:next - Go to next step - #slideshow:previous - Go to previous step - #slideshow:home - Go to first step - #slideshow:stop - Stop the slideshow

var Box.slideShow by Delegation<SlideShowInstance?>()

Extension property to store slideshow on a box

fun Box.slideShow(init: SlideShowBuilder.() -> Unit): SlideShowInstance {

Build a slideshow, store it on this box, and return it. Call SlideShowInstance.start to show it.

fun Box.slideShow(init: SlideShowBuilder.() -> Unit): SlideShowInstance {
    val builder = SlideShowBuilder(this)
    builder.init()
    val instance = builder.build()
    this.slideShow = instance
    return instance
}

class SlideShowBuilder(val hostBox: Box, private val presentationOptions: PresentationOptions? = null)

Builder for creating slideshows

class SlideShowBuilder(val hostBox: Box, private val presentationOptions: PresentationOptions? = null) {
    private val steps = mutableListOf<SlideStep>()
    private var slideshowId: String? = null
    private val sectionPath = mutableListOf<String>()

fun step(title: String, id: String? = null, init: SlideStepBuilder.() -> Unit) {

Defines a step in the slideshow

    fun step(title: String, id: String? = null, init: SlideStepBuilder.() -> Unit) {
        val builder = SlideStepBuilder(title, hostBox)
        builder.init()
        val key = id ?: "step-${steps.size + 1}"
        require(key.isNotBlank() && key.length <= 256 && steps.none { it.id == key }) { "Step IDs must be nonempty and unique: $key" }
        steps.add(builder.build().copy(sectionPath = sectionPath.toList(), id = key))
    }

fun section(title: String, init: SlideShowBuilder.() -> Unit) {

Group steps in the presenter outline. Sections may nest; playback stays in source order.

    fun section(title: String, init: SlideShowBuilder.() -> Unit) {
        require(title.isNotBlank() && title.length <= 4096) { "A section needs a title" }
        require(sectionPath.size < 16) { "Sections may nest up to 16 levels" }
        sectionPath.add(title)
        try { init() } finally { sectionPath.removeAt(sectionPath.lastIndex) }
    }

fun slideshowId(id: String)

Set a custom slideshow ID (optional, will generate one if not set)

    fun slideshowId(id: String) {
        this.slideshowId = id
    }

fun build(): SlideShowInstance

    fun build(): SlideShowInstance {
        return SlideShowInstance(hostBox, steps, slideshowId, presentationOptions)
    }
}

class SlideStepBuilder(val title: String, val hostBox: Box)

Builder for individual slide steps

internal var audienceContent: AudienceContent? = null

internal var audienceProvider: (() -> AudienceContent)? = null

    private val cues = mutableListOf<SlideStep>()
    private var checkpointTitle: String = ""
    private var checkpointReset: (suspend () -> Unit)? = null

fun cue(title: String, init: SlideStepBuilder.() -> Unit) {

A reveal within this slide. onEnter runs once on forward entry; Back only changes the view.

    fun cue(title: String, init: SlideStepBuilder.() -> Unit) {
        require(title.isNotBlank() && title.length <= 4096 && cues.size < 100) { "Use at most 100 cues with short nonempty titles" }
        val cue = SlideStepBuilder(title, hostBox).apply(init).build()
        require(cue.cues.isEmpty() && cue.reset == null && cue.onExit == null) { "Cues cannot nest or declare checkpoints/onExit" }
        cues += cue
    }

fun checkpoint(title: String, reset: suspend () -> Unit) {

Explicit presenter reset. Restoring declared state is separate from navigation.

    fun checkpoint(title: String, reset: suspend () -> Unit) {
        require(title.isNotBlank() && title.length <= 4096) { "A checkpoint needs a short nonempty title" }
        checkpointTitle = title
        checkpointReset = reset
    }
    private var noteHtml: String? = null
    private var noteAnchorBox: Box? = null
    private var targetBox: Box? = null
    private var targetProperty: String = "code"
    private var targetLine: Int? = null
    private var highlightLine: Int? = null
    private var persistent: Boolean = false
    private var animated: Boolean = false
    private var withChat: Boolean = false
    private var customChatId: String? = null
    private val annotations = mutableListOf<SlideAnnotation>()
    private var onEnter: (suspend () -> Unit)? = null
    private var onExit: (suspend () -> Unit)? = null
    private val linkCallbacks = mutableMapOf<String, suspend LinkCallbackContext.() -> Unit>()

fun note(html: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false)

Sets the HTML note to display for this step

    fun note(html: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false) {
        this.noteHtml = html
        this.persistent = persistent
        // Only an explicit anchor overrides: `anchorNoteTo(box)` before or after `note(...)` both work.
        if (anchorBox != null) this.noteAnchorBox = anchorBox
        this.animated = animated
    }

fun anchorNoteTo(box: Box)

Anchors the note to a specific box (defaults to the slideshow host box)

    fun anchorNoteTo(box: Box) {
        this.noteAnchorBox = box
    }

fun selectBox(box: Box, property: String = "code", line: Int? = null, highlight: Int? = null)

Selects a box and optionally scrolls to a specific line

    fun selectBox(box: Box, property: String = "code", line: Int? = null, highlight: Int? = null) {
        this.targetBox = box
        this.targetProperty = property
        this.targetLine = line
        this.highlightLine = highlight
    }

fun selectBoxByName(name: String, property: String = "code", line: Int? = null, highlight: Int? = null)

Selects a box by name (searches through children recursively) and optionally scrolls to a specific line

    fun selectBoxByName(name: String, property: String = "code", line: Int? = null, highlight: Int? = null) {
        // Search for box with matching name in all children recursively
        fun findBoxByName(box: Box, targetName: String): Box? {
            if (box.name == targetName) return box
            for (child in box.children) {
                val found = findBoxByName(child, targetName)
                if (found != null) return found
            }
            return null
        }

        val found = findBoxByName(hostBox.root, name)
        if (found != null) {
            this.targetBox = found
            this.targetProperty = property
            this.targetLine = line
            this.highlightLine = highlight
        } else {
            lprintln("warning,slideshow", "Warning: Could not find box with name '$name'")
        }
    }

fun onEnter(action: suspend () -> Unit) {

Code to execute when entering this step

    fun onEnter(action: suspend () -> Unit) {
        this.onEnter = action
    }

fun onExit(action: suspend () -> Unit) {

Code to execute when leaving this step

    fun onExit(action: suspend () -> Unit) {
        this.onExit = action
    }

fun linkCallback(name: String, action: suspend LinkCallbackContext.() -> Unit) {

Register a callback for a custom link in the note HTML. Use in markdown as: <a href="#custom:linkName">Click me</a> or in markdown link syntax: [Click me](#custom:linkName)

The callback receives a LinkCallbackContext with: - here: the box hosting the slideshow - show: the current slideshow instance (non-null)

    fun linkCallback(name: String, action: suspend LinkCallbackContext.() -> Unit) {
        linkCallbacks[name] = action
    }

fun withChat(id: String? = null)

Enable chat/discussion for this slide. When enabled, a collapsible "Discussion" section appears below the slide content where users can post messages and questions.

@param id Optional custom chat ID. If not provided, uses slideshow-specific ID. Use "templateId" to share chat across all instances from the same template, or provide any custom string to create a shared chat space.

    fun withChat(id: String? = null) {
        this.withChat = true
        this.customChatId = id
    }

fun annotate

Add an annotation line from the guide text to a box

    fun annotate(
        id: String,
        targetBox: Box,
        color : String = "rgba(255, 180, 100, 0.8)",
        strokeWidth: Double = 2.0,
        dashed: Boolean = true
    ) {
        annotations.add(
            SlideAnnotation(
                id = id,
                targetUid = targetBox.id,
                color = color,
                strokeWidth = strokeWidth,
                dashed = dashed
            )
        )
    }

fun annotateToLine

Add an annotation line from the guide text to a specific line in a box's editor

    fun annotateToLine(
        id: String,
        targetBox: Box,
        property: String = "code",
        line: Int,
        color: String = "rgba(128, 255, 164, 0.7)",
        strokeWidth: Double = 2.0,
        dashed: Boolean = false
    ) {
        annotations.add(
            SlideAnnotation(
                id = id,
                targetUid = targetBox.id,
                targetProperty = property,
                targetLine = line,
                color = color,
                strokeWidth = strokeWidth,
                dashed = dashed
            )
        )
    }

fun build(): SlideStep

    fun build(): SlideStep {
        return SlideStep(
            title = title,
            noteHtml = noteHtml,
            noteAnchorBox = noteAnchorBox ?: hostBox,
            targetBox = targetBox,
            targetProperty = targetProperty,
            targetLine = targetLine,
            highlightLine = highlightLine,
            persistent = persistent,
            animated = animated,
            withChat = withChat,
            customChatId = customChatId,
            annotations = annotations.toList(),
            onEnter = onEnter,
            onExit = onExit,
            linkCallbacks = linkCallbacks.toMap(),
            audience = audienceContent,
            audienceProvider = audienceProvider,
            cues = cues.toList(), checkpoint = checkpointTitle, reset = checkpointReset
        )
    }
}

class LinkCallbackContext

Context provided to link callbacks

class LinkCallbackContext(
    val here: Box,
    val show: SlideShowInstance
)

data class SlideStep

Represents a single step in a slideshow

data class SlideStep(
    val title: String,
    val noteHtml: String?,
    val noteAnchorBox: Box?,
    val targetBox: Box?,
    val targetProperty: String,
    val targetLine: Int?,
    val highlightLine: Int?,
    val persistent: Boolean,
    val animated: Boolean,
    val withChat: Boolean,
    val customChatId: String?,
    val annotations: List<SlideAnnotation>,
    val onEnter: (suspend () -> Unit)?,
    val onExit: (suspend () -> Unit)?,
    val linkCallbacks: Map<String, suspend LinkCallbackContext.() -> Unit> = emptyMap(),
    val audience: AudienceContent? = null,
    val sectionPath: List<String> = emptyList(),
    val id: String = "",
    val cues: List<SlideStep> = emptyList(),
    val checkpoint: String = "",
    val reset: (suspend () -> Unit)? = null,
    val audienceProvider: (() -> AudienceContent)? = null
)

object SlideMarkdown

Markdown renderer for slideshow notes with nav shortcuts.

object SlideMarkdown {
    private val flavour = CommonMarkFlavourDescriptor()
    private val parser = MarkdownParser(flavour)
    private val navPattern = Regex("""\[\[\s*(next|previous|prev|back|home|start|stop|finish)(?:\s*:\s*([^\]]+))?]]""", RegexOption.IGNORE_CASE)

fun render(markdown: String): String

    fun render(markdown: String): String {
        val mdWithNav = injectNavShortcuts(markdown)
        val parsedTree = parser.buildMarkdownTreeFromString(mdWithNav)
        return HtmlGenerator(mdWithNav, parsedTree, flavour).generateHtml()
    }

    private fun injectNavShortcuts(markdown: String): String {
        return navPattern.replace(markdown) { m ->
            val keyword = m.groupValues[1].lowercase()
            val target = when (keyword) {
                "next", "start" -> "next"
                "previous", "prev", "back" -> "previous"
                "home" -> "home"
                "stop", "finish" -> "stop"
                else -> keyword
            }
            val defaultKey = when (keyword) {
                "start" -> "start"
                "finish" -> "finish"
                else -> target
            }
            val label = m.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() } ?: defaultLabel(defaultKey)
            "[${label}](#slideshow:$target)"
        }
    }

    private fun defaultLabel(target: String): String = when (target) {
        "start" -> "Start →"
        "next" -> "Next →"
        "previous" -> "← Back"
        "home" -> "Home"
        "stop", "finish" -> "Finish"
        else -> target
    }
}

class SlideShowInstance

A running instance of a slideshow

class SlideShowInstance(
    val hostBox: Box,
    val steps: List<SlideStep>,
    customId: String? = null,
    var presentationOptions: PresentationOptions? = null
) {

internal var resumeStep: Int = -1

internal var resumeStepId: String? = null

internal var resumeCue: Int = 0

var currentCueIndex: Int

    var currentCueIndex: Int = 0
        private set
    private val completedCueActions = mutableSetOf<Pair<Int, Int>>()
    private val commandMutex = Mutex()
    private var searchTextsCache: List<String>? = null

val slideshowId: String = customId ?: "slideshow_${hostBox.id}_${System.currentTimeMillis()}"

Generate unique slideshow ID (use custom ID if provided, otherwise generate based on host box)

var currentStepIndex

    var currentStepIndex = -1
        private set

companion object

    companion object {
        private val activeByRoot = java.util.WeakHashMap<Box, SlideShowInstance>()

fun activeIn(root: Box): SlideShowInstance? = synchronized(activeByRoot) { activeByRoot[root] }

The slideshow whose step is on screen in the document rooted at root, or null. Note links (#slideshow:next, #custom:name) are routed here, so a document may hold several guide boxes and the links drive the one the reader is looking at.

        private fun rootOf(box: Box): Box? = if (box.isInsideInitialized()) box.root else null
    }

    private fun markActive(active: Boolean) {
        val root = rootOf(hostBox) ?: return
        synchronized(activeByRoot) {
            if (active) activeByRoot[root] = this
            else if (activeByRoot[root] === this) activeByRoot.remove(root)
        }
    }

val currentStep: SlideStep?

    val currentStep: SlideStep?
        get() = if (currentStepIndex >= 0 && currentStepIndex < steps.size) steps[currentStepIndex] else null

Process templates in HTML, replacing {{propertyName}} with box properties/variables

    private fun processTemplates(html: String): String = slideTemplateValues(hostBox, html)

suspend fun goToStep(index: Int, cueIndex: Int = 0)

Goes to a specific step

body — 77 lines, from line 493
    suspend fun goToStep(index: Int, cueIndex: Int = 0) {
        if (index < 0 || index >= steps.size) {
            lprintln("warning,slideshow", "Step $index out of bounds (0..${steps.size - 1})")
            return
        }

        // Exit current step
        currentStep?.onExit?.invoke()

        currentStepIndex = index
        currentCueIndex = cueIndex.coerceIn(0, steps[index].cues.size)
        markActive(true)
        val step = steps[index]

        // Execute onEnter
        step.onEnter?.invoke()

        renderCurrent()
    }

    private fun visibleStep(): SlideStep? = currentStep?.let { step -> step.cues.getOrNull(currentCueIndex - 1) ?: step }

    private fun renderCurrent() {
        val base = currentStep ?: return
        val visible = visibleStep() ?: return
        val step = visible.copy(noteHtml = visible.noteHtml ?: base.noteHtml,
            targetBox = visible.targetBox ?: base.targetBox)
        val index = currentStepIndex
        publishPresentation()

        // Show note if present
        if (step.noteHtml != null) {
            // Process templates in the HTML before sending to browser
            val processedHtml = processTemplates(step.noteHtml)

            // Determine chat ID to use
            val chatId = when {
                !step.withChat -> "" // No chat
                step.customChatId == "templateId" -> hostBox.templateId ?: slideshowId // Use template ID if available
                step.customChatId != null -> step.customChatId // Use custom ID
                else -> slideshowId // Default to slideshow ID
            }

            fun note(html: String, animated: Boolean) = ShowSlideNote(
                html = html,
                uid = step.noteAnchorBox?.id ?: hostBox.id,
                persistent = step.persistent,
                animated = animated,
                annotations = step.annotations,
                slideshowId = if (step.withChat) chatId else "",
                slideNumber = if (step.withChat) index else -1
            )
            sendToHostDocument(hostBox, Message("show-slide-note").payload(note(processedHtml, step.animated)))
            // A variable the running code is still declaring is not visible to templates until that
            // code has finished. Look again once it has, and redraw the note if a slot filled in.
            afield.runtime.lifecycle.inMain {
                repeat(3) { kotlinx.coroutines.yield() }
                if (currentStepIndex != index) return@inMain
                val again = processTemplates(step.noteHtml)
                if (again != processedHtml) sendToHostDocument(hostBox, Message("show-slide-note").payload(note(again, false)))
            }
        } else {
            sendToHostDocument(hostBox, Message("hide-slide-note").payload(HideSlideNote()))
        }

        // Select and scroll to box if specified
        if (step.targetBox != null) {
            sendToHostDocument(hostBox, Message("select-and-edit-box").payload(
                SelectAndEditBox(
                    uid = step.targetBox.id,
                    property = step.targetProperty,
                    scrollToLine = step.targetLine,
                    highlightLine = step.highlightLine
                )
            ))
        }
    }

suspend fun executeCallback(callbackName: String): Boolean

Execute a custom link callback by name

    suspend fun executeCallback(callbackName: String): Boolean {
        val callback = visibleStep()?.linkCallbacks?.get(callbackName) ?: currentStep?.linkCallbacks?.get(callbackName)
        if (callback != null) {
            val context = LinkCallbackContext(hostBox, this)
            callback.invoke(context)
            return true
        }
        return false
    }

suspend fun next()

Goes to the next step

    suspend fun next() {
        val step = currentStep
        if (step != null && currentCueIndex < step.cues.size) {
            val next = currentCueIndex + 1
            val key = currentStepIndex to next
            if (key !in completedCueActions) {
                step.cues[next - 1].onEnter?.invoke()
                completedCueActions += key
            }
            currentCueIndex = next
            renderCurrent()
        } else goToStep(currentStepIndex + 1)
    }

suspend fun previous()

Goes to the previous step

    suspend fun previous() {
        if (currentCueIndex > 0) {
            currentCueIndex--
            renderCurrent()
        } else if (currentStepIndex > 0) {
            goToStep(currentStepIndex - 1, steps[currentStepIndex - 1].cues.size)
        }
    }

suspend fun home()

Jump back to the first step

    suspend fun home() {
        goToStep(0)
    }

suspend fun start()

Starts the slideshow from the beginning

    suspend fun start() {
        goToStep(0)
    }

suspend fun stop()

Stops the slideshow and hides notes

    suspend fun stop() {
        currentStep?.onExit?.invoke()
        currentStepIndex = -1
        currentCueIndex = 0
        completedCueActions.clear()
        markActive(false)
        publishPresentation()
        sendToHostDocument(hostBox, Message("hide-slide-note").payload(HideSlideNote()))
    }

suspend fun command(command: String)

Execute a navigation request in order; callbacks may navigate within the same command.

    suspend fun command(command: String) = Box.withBoxContext(hostBox) {
        commandMutex.withLock {
            when {
                command == "next" -> if (hasNext()) next()
                command == "previous" -> previous()
                command == "start" || command == "home" -> home()
                command == "stop" -> stop()
                command == "present" -> present()
                command == "refresh" -> if (currentStepIndex >= 0) {
                    searchTextsCache = null
                    renderCurrent()
                }
                command == "reset-checkpoint" -> {
                    val step = currentStep
                    step?.reset?.invoke()
                    if (step?.reset != null) {
                        completedCueActions.removeAll { it.first == currentStepIndex }
                        currentCueIndex = 0
                        renderCurrent()
                    }
                }
                command == "run-cue" -> {
                    visibleStep()?.takeIf { currentCueIndex > 0 }?.onEnter?.invoke()
                    if (currentCueIndex > 0) completedCueActions += currentStepIndex to currentCueIndex
                    renderCurrent()
                }
                command.startsWith("callback:") -> { executeCallback(command.substringAfter("callback:")); Unit }
                else -> command.toIntOrNull()?.let { goToStep(it) } ?: Unit
            }
        }
    }

internal fun presentationState(includeSearchText: Boolean = true): PresentationState?

    internal fun presentationState(includeSearchText: Boolean = true): PresentationState? {
        val options = presentationOptions ?: return null
        val step = currentStep
        val visible = visibleStep()
        val content = visible?.audienceProvider?.invoke() ?: visible?.audience ?: step?.audienceProvider?.invoke() ?: step?.audience ?: (visible?.noteHtml ?: step?.noteHtml)?.let { AudienceContent(html = processTemplates(it)) }
            ?: step?.let { AudienceContent() }
        return PresentationState(slideshowId, hostBox.id, options.title, currentStepIndex,
            steps.map { it.title }, content, options.transitionMs, steps.map { it.sectionPath },
            steps.map { it.id }, currentCueIndex, step?.cues?.map { it.title }.orEmpty(),
            currentCueIndex > 0 && visible?.onEnter != null, step?.checkpoint.orEmpty(),
            if (!includeSearchText || currentStepIndex < 0) emptyList() else searchTextsCache ?: steps.map {
                PresentationSearch.text(it)
            }.also { searchTextsCache = it })
    }

    private fun publishPresentation() {
        val state = presentationState() ?: return
        sendToHostDocument(hostBox, Message("presentation-state").payload(state))
    }

fun hasNext(): Boolean

Returns true if there is a next step

    fun hasNext(): Boolean = currentCueIndex < (currentStep?.cues?.size ?: 0) || currentStepIndex < steps.size - 1

fun hasPrevious(): Boolean = currentCueIndex > 0 || currentStepIndex > 0

Returns true if there is a previous step

internal fun sendToHostDocument(box: Box, message: Message)

Send a slideshow message only to the browsers showing box's document. Before 2026-10-13 notes and selections were broadcast to every open document, so a guide in one document put its note over another. A box with no document (a unit test) falls back to the broadcast.

internal fun sendToHostDocument(box: Box, message: Message) {
    val document = outputDocument(box)
    if (document.isNotBlank()) _send(message, document = Regex.escape(document)) else _send(message)
}

Convenience extension functions for Box

fun Box.selectAndEdit(property: String = "code", line: Int? = null, highlight: Int? = null)

Quick select and edit a box with optional line navigation

fun Box.selectAndEdit(property: String = "code", line: Int? = null, highlight: Int? = null) {
    sendToHostDocument(this, Message("select-and-edit-box").payload(
        SelectAndEditBox(
            uid = this.id,
            property = property,
            scrollToLine = line,
            highlightLine = highlight
        )
    ))
}

fun SlideStepBuilder.markdownNote(markdown: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false)

fun SlideStepBuilder.markdownNote(markdown: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false) {
    val html = SlideMarkdown.render(markdown)
    note(html, persistent, anchorBox, animated)
}

fun Box.showNote

Show a slide note

fun Box.showNote(
    html: String,
    persistent: Boolean = false,
    anchorBox: Box? = this,
    animated: Boolean = false,
    annotations: List<SlideAnnotation> = emptyList()
) {
    // Process templates before displaying
    val processedHtml = slideTemplateValues(this, html)

    sendToHostDocument(this, Message("show-slide-note").payload(
        ShowSlideNote(
            html = processedHtml,
            uid = anchorBox?.id ?: this.id,
            persistent = persistent,
            animated = animated,
            annotations = annotations
        )
    ))
}

fun Box.showMarkdownNote

fun Box.showMarkdownNote(
    markdown: String,
    persistent: Boolean = false,
    anchorBox: Box? = this,
    animated: Boolean = false,
    annotations: List<SlideAnnotation> = emptyList()
) {
    val html = SlideMarkdown.render(markdown)
    showNote(html, persistent, anchorBox, animated, annotations)
}

fun hideNote()

Hide slide notes

fun hideNote() {
    _send(Message("hide-slide-note").payload(HideSlideNote()))
}

fun Box.withChat(id: String? = null)

Add a shared chat interface to this box's output area.

@param id The chat ID to use. Special values: - "templateId": Share chat across all boxes from the same template - Any other string: Custom shared chat ID If not provided, uses the box's own ID (non-shared).

Example usage:

// Share chat across all instances from the same template
withChat(id = "templateId")

// Create a custom shared chat
withChat(id = "myProject-mainDiscussion")

// Box-specific chat (not shared)
withChat()

body — 56 lines, from line 797
fun Box.withChat(id: String? = null) {
    // Determine the chat ID to use
    val chatId = when {
        id == "templateId" -> this.templateId ?: this.id // Use template ID if available
        id != null -> id // Use custom ID
        else -> this.id // Default to box ID
    }

    val containerId = "shared-chat-${UUID.randomUUID()}"
    fun escapeAttr(value: String): String {
        return value
            .replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace("\"", "&quot;")
    }
    val escapedChatId = escapeAttr(chatId)

    // Display chat in the "chat" channel using the same UI as slideshow notes.
    this.display("chat", """
        <div class="shared-chat-host" id="$containerId" data-chat-id="$escapedChatId" data-chat-slide="0"></div>
        <script>
        (function attachSharedChat(attempt) {
            const hostId = "$containerId";
            const host = document.getElementById(hostId);
            const mount = (window.afield && window.afield.mountSharedChat) || window.mountSharedChat;
            if (!host) {
                console.warn("Shared chat host missing:", hostId);
                return;
            }
            if (typeof mount !== "function") {
                if (attempt < 5) {
                    setTimeout(() => attachSharedChat(attempt + 1), 50);
                } else {
                    console.warn("Shared chat mount function is not ready");
                }
                return;
            }
            const chatId = host.dataset.chatId || "";
            const slideNumber = Number(host.dataset.chatSlide || "0");
            mount(hostId, chatId, Number.isNaN(slideNumber) ? 0 : slideNumber, false, false);
        })(0);
        </script>
    """.trimIndent())
}

private fun escapeSlideTemplateValue(text: String): String = buildString(text.length) {
    for (c in text) when (c) {
        '&' -> append("&amp;")
        '<' -> append("&lt;")
        '>' -> append("&gt;")
        '"' -> append("&quot;")
        '\'' -> append("&#39;")
        else -> append(c)
    }
}

internal fun slideTemplateValues(box: Box, html: String): String

Fills {{name}} in note HTML from box: its properties, then its script variables, then the global knownValues / templates. Property and variable values are data, so they are HTML-escaped (a value of <b> shows as <b>, and cannot inject markup or script into the note); knownValues and templates entries are registered by code and inserted as HTML.

internal fun slideTemplateValues(box: Box, html: String): String = replaceTemplate(html) { placeholder, _ ->
    box.properties[placeholder]?.let { return@replaceTemplate escapeSlideTemplateValue(it.toString()) }
    try {
        box.ki.context.vars[placeholder]?.value?.let { return@replaceTemplate escapeSlideTemplateValue(it.toString()) }
    } catch (e: Exception) {
        // no scripting context yet
    }
    knownValues[placeholder] ?: templates[placeholder]?.invoke(placeholder, html) ?: ""
}