diff --git a/index.html b/index.html
index f2089b2..f609966 100644
--- a/index.html
+++ b/index.html
@@ -9,7 +9,28 @@
-
+
diff --git a/package.json b/package.json
index 541d3c7..f0e1710 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"preview": "vite preview"
},
"devDependencies": {
+ "@types/less": "^3.0.8",
"oxlint": "^1.85.0",
"prettier": "^3.9.9",
"typescript": "~6.0.2",
@@ -20,6 +21,7 @@
},
"dependencies": {
"emmet-monaco-es": "^5.7.0",
+ "less": "^4.9.1",
"monaco-editor": "0.55.1"
}
}
diff --git a/src/editor.ts b/src/editor.ts
index cce20ce..0e262be 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -21,11 +21,39 @@ self.MonacoEnvironment = {
const MONACO_PARENT = '.code'
-export function createEditor() {
- return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
+/** Which buffer the single Monaco instance is currently showing. */
+export type EditorBuffer = 'slides' | 'style'
+
+let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null
+let slidesModel: monaco.editor.ITextModel | null = null
+let styleModel: monaco.editor.ITextModel | null = null
+let activeBuffer: EditorBuffer = 'slides'
+
+export function createEditor(initialStyle: string): monaco.editor.IStandaloneCodeEditor {
+ const editor = monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: 'test',
language: 'html',
theme: 'vs-dark',
automaticLayout: true
})
+
+ editorInstance = editor
+ slidesModel = editor.getModel()
+ styleModel = monaco.editor.createModel(initialStyle, 'less')
+ activeBuffer = 'slides'
+ return editor
+}
+
+export function getEditorBuffer(): EditorBuffer {
+ return activeBuffer
+}
+
+/** Swap the visible buffer. Slide text lives in its own model, so styles and
+ * slide markup never overwrite each other. */
+export function setEditorBuffer(buffer: EditorBuffer): void {
+ if (!editorInstance || !slidesModel || !styleModel || buffer === activeBuffer) {
+ return
+ }
+ activeBuffer = buffer
+ editorInstance.setModel(buffer === 'style' ? styleModel : slidesModel)
}
diff --git a/src/main.ts b/src/main.ts
index 29e7df9..af34e55 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,11 +1,56 @@
import './style.css'
-import { createEditor } from './editor'
+import { createEditor, getEditorBuffer, setEditorBuffer } from './editor'
import { createCodeThumbnail } from './code-to-element'
+import { compilePresentation } from './slides/compile'
+import { createRuntime, type Runtime } from './slides/runtime'
interface Slide {
code: string
}
+// Starter stylesheet for a new project. Authors write Less here and HTML in the
+// slides; `:thisSlide` compiles to each slide's own selector, so deck styles
+// stay scoped without inventing any extra CSS syntax.
+const DEFAULT_STYLE = `// Project styles. This is ordinary Less: variables, nesting,
+// mixins and imports all work as usual.
+//
+// :thisSlide compiles to the selector of the slide this stylesheet is being
+// compiled for, so it means "this slide", never "the visible slide".
+
+@bg: #101010;
+@fg: #f0f0f0;
+@accent: #8fc98f;
+
+:thisSlide {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 3rem;
+ background: @bg;
+ color: @fg;
+ transition: opacity 260ms ease, transform 260ms ease;
+}
+
+// The runtime moves slides through idle -> entering -> current -> leaving.
+// CSS owns the transition itself; JavaScript only sets data-ps-state.
+// idle and leaving deliberately share values so settling costs no jump.
+:thisSlide[data-ps-state='idle'],
+:thisSlide[data-ps-state='leaving'] {
+ opacity: 0;
+ transform: scale(0.98);
+}
+
+:thisSlide[data-ps-state='entering'],
+:thisSlide[data-ps-state='current'] {
+ opacity: 1;
+ transform: scale(1);
+}
+`
+
+let projectStyle = DEFAULT_STYLE
let globalSlide = ''
const slides: Slide[] = [
@@ -14,9 +59,14 @@ const slides: Slide[] = [
}
]
-const editor = createEditor()
+const editor = createEditor(projectStyle)
const slideList = document.querySelector
('.slide-list-items')!
const addSlideButton = document.querySelector('.add-slide')!
+const stage = document.querySelector('.stage')!
+const compileError = document.querySelector('.compile-error')!
+const slidesTab = document.querySelector('.buffer-tab-slides')!
+const styleTab = document.querySelector('.buffer-tab-style')!
+const presentButton = document.querySelector('.present-button')!
let currentSlide = 0
let draggedSlide: number | null = null
let globalSlideElement: HTMLElement | null = null
@@ -24,6 +74,18 @@ const slideElements = new Map()
const thumbnailRequests = new Map()
let thumbnailDebounceTimer: number | undefined
+// Project CSS lives in its own stylesheet so the preview can be swapped out
+// without touching the editor's own styles.
+const slideStyles = document.createElement('style')
+slideStyles.setAttribute('data-ps-slides', '')
+document.head.append(slideStyles)
+
+let runtime: Runtime | null = null
+let compileDebounceTimer: number | undefined
+// Set while we push text into Monaco ourselves, so programmatic changes are
+// never mistaken for the author typing.
+let applyingToEditor = false
+
function updateSelection() {
slideList.querySelectorAll('.slide-list-item').forEach((element) => {
const index = Number(element.dataset.slideId)
@@ -31,15 +93,87 @@ function updateSelection() {
})
}
+function syncBufferTabs() {
+ const showingSlides = getEditorBuffer() === 'slides'
+ slidesTab.classList.toggle('active', showingSlides)
+ styleTab.classList.toggle('active', !showingSlides)
+ slidesTab.setAttribute('aria-pressed', String(showingSlides))
+ styleTab.setAttribute('aria-pressed', String(!showingSlides))
+}
+
+function setEditorText(text: string) {
+ applyingToEditor = true
+ try {
+ editor.getModel()?.setValue(text)
+ } finally {
+ applyingToEditor = false
+ }
+}
+
+function showSlideInEditor(index: number) {
+ setEditorBuffer('slides')
+ syncBufferTabs()
+ setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? ''))
+}
+
function setSlide(index: number) {
- const slide = index !== -1 ? slides[index] : { code: globalSlide }
- if (!slide) {
+ if (index !== -1 && !slides[index]) {
return
}
currentSlide = index
- editor.getModel()?.setValue(slide.code)
+ showSlideInEditor(index)
updateSelection()
+ // The global slide is shared markup, not a step of the presentation, so it
+ // never moves the runtime.
+ if (index >= 0) {
+ runtime?.goTo(index)
+ }
+}
+
+function showCompileError(message: string) {
+ compileError.textContent = message
+ compileError.hidden = false
+}
+
+function scheduleCompile() {
+ if (compileDebounceTimer !== undefined) {
+ window.clearTimeout(compileDebounceTimer)
+ }
+ compileDebounceTimer = window.setTimeout(() => {
+ compileDebounceTimer = undefined
+ void rebuildPreview()
+ }, 250)
+}
+
+async function rebuildPreview(): Promise {
+ try {
+ const result = await compilePresentation({
+ slides: slides.map((slide) => slide.code),
+ global: globalSlide,
+ style: projectStyle
+ })
+
+ slideStyles.textContent = result.css
+ compileError.hidden = true
+ compileError.textContent = ''
+
+ if (runtime) {
+ runtime.setSlides(result.slides)
+ // Follow the slide being edited; the deck may just have grown.
+ if (currentSlide >= 0) {
+ runtime.goTo(currentSlide)
+ }
+ } else {
+ runtime = createRuntime(stage, result.slides, {
+ onCurrentChange: (index) => setSlide(index)
+ })
+ }
+ } catch (error) {
+ // Half-typed Less should not blank the preview: keep the last good
+ // output on screen and report what broke.
+ showCompileError(error instanceof Error ? error.message : String(error))
+ }
}
async function generateSlideThumbnail(slide: Slide, i: number) {
@@ -189,7 +323,8 @@ function moveSlide(from: number, to: number) {
}
updateSlideOrder()
- editor.getModel()?.setValue(slides[currentSlide]?.code ?? globalSlide)
+ showSlideInEditor(currentSlide)
+ scheduleCompile()
}
function deleteSlide(index: number) {
@@ -209,6 +344,7 @@ function deleteSlide(index: number) {
updateSlideOrder()
setSlide(currentSlide)
+ scheduleCompile()
}
addSlideButton.addEventListener('click', async () => {
@@ -218,6 +354,32 @@ addSlideButton.addEventListener('click', async () => {
slideList.append(slideElement)
updateSlideOrder()
setSlide(slides.length - 1)
+ scheduleCompile()
+})
+
+slidesTab.addEventListener('click', () => {
+ showSlideInEditor(currentSlide)
+})
+
+styleTab.addEventListener('click', () => {
+ setEditorBuffer('style')
+ syncBufferTabs()
+})
+
+presentButton.addEventListener('click', () => {
+ stage.requestFullscreen().catch(() => {
+ // Browsers can refuse fullscreen; presenting inside the panel still works.
+ })
+})
+
+document.addEventListener('fullscreenchange', () => {
+ if (document.fullscreenElement === stage) {
+ // Keys must reach the runtime, not the editor we just left behind.
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur()
+ }
+ stage.focus()
+ }
})
slideList.addEventListener('dragstart', (event) => {
@@ -252,6 +414,16 @@ slideList.addEventListener('dragend', () => {
})
editor.onDidChangeModelContent(() => {
+ if (applyingToEditor) {
+ return
+ }
+
+ if (getEditorBuffer() === 'style') {
+ projectStyle = editor.getValue()
+ scheduleCompile()
+ return
+ }
+
const code = editor.getValue()
const slideIndex = currentSlide
const slide = slideIndex === -1 ? { code } : slides[slideIndex]
@@ -261,6 +433,8 @@ editor.onDidChangeModelContent(() => {
slides[slideIndex].code = code
}
+ scheduleCompile()
+
if (thumbnailDebounceTimer !== undefined) {
window.clearTimeout(thumbnailDebounceTimer)
}
@@ -271,8 +445,10 @@ editor.onDidChangeModelContent(() => {
})
async function render() {
+ syncBufferTabs()
await setSlideList()
setSlide(currentSlide)
+ await rebuildPreview()
}
render()
diff --git a/src/slides/compile.ts b/src/slides/compile.ts
new file mode 100644
index 0000000..c654795
--- /dev/null
+++ b/src/slides/compile.ts
@@ -0,0 +1,87 @@
+import less from 'less'
+
+/**
+ * A slide's stable id. Deterministic for a given position, so recompiling the
+ * project (on every keystroke) never renames a slide underneath the runtime.
+ */
+function slideId(index: number): string {
+ return `slide-${index}`
+}
+
+function slideSelector(id: string): string {
+ return `[data-ps-slide="${id}"]`
+}
+
+const THIS_SLIDE = ':thisSlide'
+
+export interface CompiledSlide {
+ /** Stable id, used as the value of the slide's `data-ps-slide` attribute. */
+ id: string
+ index: number
+ html: string
+}
+
+export interface CompileInput {
+ /** HTML source of every slide, in presentation order. */
+ slides: string[]
+ /** HTML shared by every slide (the "global" slide in the editor). */
+ global?: string
+ /** Project Less source. `:thisSlide` resolves to the slide being compiled. */
+ style: string
+}
+
+export interface CompiledPresentation {
+ slides: CompiledSlide[]
+ css: string
+}
+
+function buildSlideHtml(markup: string, global: string): string {
+ return global ? `${global}\n${markup}` : markup
+}
+
+/**
+ * Compile a project into runtime-ready data.
+ *
+ * `:thisSlide` is replaced with the slide's own selector *before* Less runs, so
+ * Less keeps behaving like Less: nesting, variables, mixins and imports all work
+ * unchanged, and the output is deterministic — it never depends on which slide
+ * happens to be visible.
+ */
+export async function compilePresentation(input: CompileInput): Promise {
+ const global = input.global ?? ''
+ const slides: CompiledSlide[] = input.slides.map((markup, index) => ({
+ id: slideId(index),
+ index,
+ html: buildSlideHtml(markup, global)
+ }))
+
+ const css = await compileStyles(
+ input.style,
+ slides.map((slide) => slide.id)
+ )
+ return { slides, css }
+}
+
+async function compileStyles(source: string, ids: string[]): Promise {
+ if (!source.trim()) {
+ return ''
+ }
+
+ // Fast path: without `:thisSlide` every slide resolves to the same CSS, so
+ // compile once instead of once per slide.
+ if (!source.includes(THIS_SLIDE)) {
+ const { css } = await less.render(source)
+ return css
+ }
+
+ const perSlide = await Promise.all(
+ ids.map(async (id) => {
+ const selector = slideSelector(id)
+ const scoped = source.split(THIS_SLIDE).join(selector)
+ const { css } = await less.render(scoped)
+ return `/* ${selector} */\n${css}`
+ })
+ )
+
+ return perSlide.join('\n')
+}
diff --git a/src/slides/runtime.ts b/src/slides/runtime.ts
new file mode 100644
index 0000000..af4252a
--- /dev/null
+++ b/src/slides/runtime.ts
@@ -0,0 +1,352 @@
+import type { CompiledSlide } from './compile'
+
+/**
+ * Slide lifecycle. The runtime moves slides through these states; CSS owns how
+ * each transition looks. State is published as the `data-ps-state` attribute.
+ *
+ * idle -> entering -> current -> leaving -> idle
+ */
+export type SlideState = 'idle' | 'entering' | 'current' | 'leaving'
+
+export interface RuntimeSlide {
+ readonly id: string
+ readonly index: number
+ readonly element: HTMLElement
+ readonly state: SlideState
+}
+
+export interface RuntimeOptions {
+ /** Listen for arrow keys / space / Home / End. Defaults to true. */
+ keyboard?: boolean
+ /** Called whenever the current slide changes. */
+ onCurrentChange?: (index: number) => void
+}
+
+export interface Runtime {
+ /** Navigate directly. Out-of-range indexes clamp; repeats are ignored. */
+ goTo(index: number): void
+ next(): void
+ previous(): void
+ /** Swap the deck in place (after recompiling) without resetting the view. */
+ setSlides(slides: readonly CompiledSlide[]): void
+ readonly current: number
+ readonly slides: readonly RuntimeSlide[]
+ /** Remove the runtime, its listeners and its DOM. */
+ destroy(): void
+}
+
+/** Upper bound on how long a navigation may wait before it settles anyway. */
+const SETTLE_TIMEOUT_MS = 5000
+/**
+ * The current slide is remembered in the history entry rather than the URL, so
+ * back/forward steps through slides and a reload lands on the slide you were on.
+ */
+const HISTORY_KEY = 'psslides-slide'
+/** Images in the incoming slide get this long to load before we show it. */
+const ASSET_TIMEOUT_MS = 1000
+
+class SlideRecord {
+ readonly id: string
+ readonly index: number
+ readonly element: HTMLElement
+ html: string
+ state: SlideState = 'idle'
+
+ constructor(slide: CompiledSlide) {
+ this.id = slide.id
+ this.index = slide.index
+ this.html = slide.html
+ this.element = document.createElement('section')
+ this.element.setAttribute('data-ps-slide', slide.id)
+ this.element.setAttribute('data-ps-state', 'idle')
+ this.element.innerHTML = slide.html
+ }
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, ms)
+ })
+}
+
+/** Resolves after two frames, which is when CSS has started its transitions. */
+function nextFrames(): Promise {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => resolve())
+ })
+ })
+}
+
+function waitForImage(image: HTMLImageElement): Promise {
+ return new Promise((resolve) => {
+ if (image.complete) {
+ resolve()
+ return
+ }
+ image.addEventListener('load', () => resolve(), { once: true })
+ image.addEventListener('error', () => resolve(), { once: true })
+ })
+}
+
+/** Wait (briefly) for images in the incoming slide so it cannot pop in late. */
+async function waitForAssets(element: HTMLElement): Promise {
+ const pending = Array.from(element.querySelectorAll('img')).filter((image) => !image.complete)
+ if (pending.length === 0) {
+ return
+ }
+ await Promise.race([Promise.all(pending.map(waitForImage)), delay(ASSET_TIMEOUT_MS)])
+}
+
+/** Infinite animations (spinners) never finish, so they must not block a settle. */
+function isFiniteAnimation(animation: Animation): boolean {
+ return animation.effect?.getTiming().iterations !== Infinity
+}
+
+async function waitForAnimations(elements: readonly HTMLElement[]): Promise {
+ // Transitions are scheduled but not started until the next frame.
+ await nextFrames()
+ const animations = elements
+ .flatMap((element) => element.getAnimations({ subtree: true }))
+ .filter(isFiniteAnimation)
+ if (animations.length === 0) {
+ return
+ }
+ await Promise.race([
+ Promise.all(animations.map((animation) => animation.finished.catch(() => undefined))),
+ delay(SETTLE_TIMEOUT_MS)
+ ])
+}
+
+/** Slide index carried by the current history entry, if it has one. */
+function storedIndex(): number | null {
+ const value = (window.history.state as Record | null)?.[HISTORY_KEY]
+ return typeof value === 'number' && Number.isInteger(value) ? value : null
+}
+
+/** Record the slide in the history entry: push a new one, or amend this one. */
+function writeIndex(index: number, push: boolean): void {
+ const state = {
+ ...(window.history.state as Record | null),
+ [HISTORY_KEY]: index
+ }
+ if (push) {
+ window.history.pushState(state, '')
+ } else {
+ window.history.replaceState(state, '')
+ }
+}
+
+function ignoresKeyboard(event: KeyboardEvent): boolean {
+ const target = event.target
+ if (!(target instanceof HTMLElement)) {
+ return false
+ }
+ // Never steal keys from the editor, form controls or focused controls.
+ return target.closest('input, textarea, select, button, a, [contenteditable]') !== null
+}
+
+/**
+ * Mount every slide in the DOM at once and drive them through their lifecycle.
+ *
+ * The runtime never rebuilds the scene during navigation: all slides stay
+ * mounted, and only `data-ps-state` changes. It deliberately has no animation
+ * engine, no event bus and no knowledge of editing or Less — CSS describes how
+ * slides look, this moves them between states.
+ */
+export function createRuntime(
+ container: HTMLElement,
+ deck: readonly CompiledSlide[],
+ options: RuntimeOptions = {}
+): Runtime {
+ const records: SlideRecord[] = deck.map((slide) => new SlideRecord(slide))
+ const restored = storedIndex()
+ let current =
+ restored === null ? 0 : Math.min(Math.max(restored, 0), Math.max(records.length - 1, 0))
+ let generation = 0
+ let destroyed = false
+
+ container.setAttribute('data-psslides-runtime', '')
+
+ function setState(record: SlideRecord, state: SlideState): void {
+ if (record.state === state) {
+ return
+ }
+ record.state = state
+ record.element.setAttribute('data-ps-state', state)
+ }
+
+ /** Every navigation ends by normalising: target current, everything else idle. */
+ async function settle(generationAtCall: number, target: SlideRecord): Promise {
+ const involved = records.filter((record) => record.state !== 'idle')
+ await Promise.all([
+ waitForAssets(target.element),
+ waitForAnimations(involved.map((record) => record.element))
+ ])
+ // A newer navigation took over while we waited; it owns the settle now.
+ if (destroyed || generationAtCall !== generation) {
+ return
+ }
+ for (const record of records) {
+ setState(record, record === target ? 'current' : 'idle')
+ }
+ }
+
+ function goTo(index: number): void {
+ moveTo(index, false)
+ }
+
+ /** `fromHistory` skips pushing a new entry (back/forward already made it). */
+ function moveTo(index: number, fromHistory: boolean): void {
+ if (destroyed || records.length === 0) {
+ return
+ }
+ const target = Math.min(Math.max(index, 0), records.length - 1)
+ if (target === current) {
+ return
+ }
+
+ // Anything on screen (or still mid-flight) starts leaving at once.
+ // Rapid navigation therefore never queues up: one generation, one settle.
+ generation++
+ for (const record of records) {
+ if (record.state !== 'idle') {
+ setState(record, 'leaving')
+ }
+ }
+ const targetRecord = records[target]
+ setState(targetRecord, 'entering')
+ current = target
+ if (!fromHistory) {
+ writeIndex(target, true)
+ }
+ options.onCurrentChange?.(target)
+
+ void settle(generation, targetRecord)
+ }
+
+ function setSlides(deckUpdate: readonly CompiledSlide[]): void {
+ if (destroyed) {
+ return
+ }
+
+ const previousRecords = records.slice()
+ const nextRecords: SlideRecord[] = deckUpdate.map((slide, index) => {
+ const existing = previousRecords[index]
+ if (existing && existing.id === slide.id) {
+ if (existing.html !== slide.html) {
+ existing.element.innerHTML = slide.html
+ existing.html = slide.html
+ }
+ return existing
+ }
+ return new SlideRecord(slide)
+ })
+
+ const structureChanged =
+ nextRecords.length !== previousRecords.length ||
+ nextRecords.some((record, index) => record !== previousRecords[index])
+ records.splice(0, records.length, ...nextRecords)
+
+ if (records.length > 0 && current > records.length - 1) {
+ current = records.length - 1
+ writeIndex(current, false)
+ }
+
+ if (structureChanged) {
+ // Rebuilding the DOM restarts transitions, so drop in-flight work and
+ // settle into an explicit state instead of leaving a half-transition.
+ generation++
+ container.replaceChildren(...records.map((record) => record.element))
+ for (const record of records) {
+ setState(record, record.index === current ? 'current' : 'idle')
+ }
+ }
+ }
+
+ function next(): void {
+ goTo(current + 1)
+ }
+
+ function previous(): void {
+ goTo(current - 1)
+ }
+
+ function onKeyDown(event: KeyboardEvent): void {
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
+ if (ignoresKeyboard(event)) return
+
+ switch (event.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case 'PageDown':
+ case ' ':
+ case 'Spacebar':
+ next()
+ break
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'PageUp':
+ previous()
+ break
+ case 'Home':
+ goTo(0)
+ break
+ case 'End':
+ goTo(records.length - 1)
+ break
+ default:
+ return
+ }
+ event.preventDefault()
+ }
+
+ // Initialise: the restored (or first) slide is current, the rest idle.
+ writeIndex(current, false)
+ records.forEach((record, index) => {
+ if (index === current) {
+ record.state = 'current'
+ record.element.setAttribute('data-ps-state', 'current')
+ }
+ container.append(record.element)
+ })
+
+ function onPopState(): void {
+ const index = storedIndex()
+ if (index !== null) {
+ moveTo(index, true)
+ }
+ }
+
+ const keyboard = options.keyboard !== false
+ if (keyboard) {
+ window.addEventListener('keydown', onKeyDown)
+ }
+ window.addEventListener('popstate', onPopState)
+
+ return {
+ goTo,
+ next,
+ previous,
+ setSlides,
+ get current() {
+ return current
+ },
+ get slides() {
+ return records
+ },
+ destroy() {
+ if (destroyed) {
+ return
+ }
+ destroyed = true
+ generation++
+ if (keyboard) {
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ window.removeEventListener('popstate', onPopState)
+ container.replaceChildren()
+ container.removeAttribute('data-psslides-runtime')
+ }
+ }
+}
diff --git a/src/style.css b/src/style.css
index fbc461e..e620f63 100644
--- a/src/style.css
+++ b/src/style.css
@@ -105,6 +105,7 @@ body {
.preview {
grid-area: preview;
+ position: relative;
min-width: 0;
display: flex;
align-items: center;
@@ -113,6 +114,76 @@ body {
background-color: var(--bg);
}
+/* The presentation surface. The runtime mounts every slide inside it. */
+.stage {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background-color: var(--bg-sunken);
+}
+
+.stage:focus {
+ outline: none;
+}
+
+.stage:fullscreen {
+ width: 100vw;
+ height: 100vh;
+}
+
+.compile-error {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ max-height: 40%;
+ overflow: auto;
+ padding: 0.5rem 0.75rem;
+ background-color: #7a1f1f;
+ color: #ffffff;
+ font-family: monospace;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+}
+
+.menubar-actions {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ margin-left: auto;
+}
+
+.buffer-tabs {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.buffer-tab,
+.present-button {
+ padding: 0.25rem 0.7rem;
+ border: 1px solid rgba(255, 255, 255, 0.55);
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ border-radius: 3px;
+}
+
+.buffer-tab.active {
+ background: var(--accent-text);
+ color: var(--accent);
+}
+
+.buffer-tab:hover,
+.buffer-tab:focus-visible,
+.present-button:hover,
+.present-button:focus-visible {
+ background: rgba(255, 255, 255, 0.2);
+ outline: none;
+}
+
.slide-list-item {
position: relative;
display: grid;