1
0
Fork 0
forked from jan/psslides

fix: scope every rule in a slide's own style block to that slide

Binding only the `:thisSlide` token still left every untokened rule in a
slide's tag compiling deck-wide, so one slide's styles kept applying to
all slides. Wrap each slide's whole style block in its section selector
instead, with `:thisSlide` resolving to `&` — Less hoists `@keyframes`/
`@font-face` out of the wrapper itself and keeps `@media` nested inside,
so only rules end up scoped. The global block stays the shared layer and
the deck still compiles exactly once.
This commit is contained in:
navi 2026-09-28 15:40:31 +02:00
parent 48e06bb689
commit eeabb70855
2 changed files with 31 additions and 19 deletions

View file

@ -14,15 +14,17 @@ interface Slide {
code: string code: string
} }
// The starter slide opens with a <style> tag. Less written inside any <style> // The starter slide opens with a <style> tag. Less written inside a slide's
// tag in the deck becomes the project stylesheet, and `:thisSlide` compiles to // <style> tag is scoped to that slide, the global slide's styles are shared
// each slide's own selector, so deck styles stay scoped without inventing any // by every slide, and `:thisSlide` is the slide's own section — no extra CSS
// extra CSS syntax. // syntax beyond that.
const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nesting, const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nesting,
// mixins and imports all work as usual. // mixins and imports all work as usual.
// //
// :thisSlide compiles to the selector of the slide this stylesheet is being // A slide's own style tag is scoped to that slide: every rule in it applies
// compiled for, so it means "this slide", never "the visible slide". // to that slide alone. The global slide's styles are shared by all slides.
// :thisSlide is the slide's own section, never "the visible slide" — handy
// for state rules: :thisSlide[data-ps-state='current'] { ... }.
@bg: #101010; @bg: #101010;
@fg: #f0f0f0; @fg: #f0f0f0;

View file

@ -209,12 +209,13 @@ function isGlobalTemplate(global: string): boolean {
* Compile a project into runtime-ready data. * Compile a project into runtime-ready data.
* *
* Every `<style>` tag in the deck contributes to one project stylesheet (the * Every `<style>` tag in the deck contributes to one project stylesheet (the
* global slide first, then each slide in order), and `:thisSlide` is rewritten * global slide first, then each slide in order). The global block is the
* per style block *before* the blocks are joined: the global block to any * shared layer and compiles deck-wide; each slide's block is *wrapped* in its
* slide, each slide's block to that slide's own section. The joined source * section selector before the blocks are joined, so a slide's stylesheet
* then compiles exactly once — so Less keeps behaving like Less, the output * styles that slide and nothing else — token or not — with `:thisSlide`
* never depends on which slide is visible, and one slide's scoped rules can * resolving to the section itself. The joined source compiles exactly once:
* never bind to its neighbours. * Less stays Less, the output never depends on which slide is visible, and
* one slide's rules can never reach its neighbours.
*/ */
export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> { export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> {
const global = extractStyles(input.global ?? '') const global = extractStyles(input.global ?? '')
@ -227,15 +228,13 @@ export async function compilePresentation(input: CompileInput): Promise<Compiled
html: globalIsTemplate ? slide.html : buildSlideHtml(slide.html, global.html) html: globalIsTemplate ? slide.html : buildSlideHtml(slide.html, global.html)
})) }))
// Bind `:thisSlide` while each block still belongs to exactly one slide: // Scope each block while it still belongs to exactly one slide: the
// the global block means "whoever is showing", a slide block means that // global block means "everywhere", a slide block means that slide. Only
// slide. Rebinding after the join (the old per-slide passes) rebound every // rewriting the `:thisSlide` token (the previous fix) still left every
// block on every pass, so each slide's scoped rules matched all slides. // untokened rule deck-wide — the whole block now carries the selector.
const sources = [ const sources = [
bindThisSlide(global.style, ANY_SLIDE_SELECTOR), bindThisSlide(global.style, ANY_SLIDE_SELECTOR),
...extracted.map((slide, index) => ...extracted.map((slide, index) => scopeBlock(slide.style, slideSelector(slideId(index))))
bindThisSlide(slide.style, slideSelector(slideId(index)))
)
] ]
const source = sources.join('\n') const source = sources.join('\n')
const css = source.trim() ? await renderLess(source) : '' const css = source.trim() ? await renderLess(source) : ''
@ -246,3 +245,14 @@ export async function compilePresentation(input: CompileInput): Promise<Compiled
function bindThisSlide(source: string, selector: string): string { function bindThisSlide(source: string, selector: string): string {
return source.split(THIS_SLIDE).join(selector) return source.split(THIS_SLIDE).join(selector)
} }
/**
* Scope a slide's whole style block to its section: wrap the block in the
* slide's selector and let `:thisSlide` become `&` — the section itself.
* Less hoists `@keyframes`/`@font-face` out of the wrapper on its own and
* keeps `@media` nested inside it, so only rules end up scoped.
*/
function scopeBlock(source: string, selector: string): string {
if (!source.trim()) return ''
return `${selector} {\n${source.split(THIS_SLIDE).join('&')}\n}`
}