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
}
// The starter slide opens with a <style> tag. Less written inside any <style>
// tag in the deck becomes the project stylesheet, and `:thisSlide` compiles to
// each slide's own selector, so deck styles stay scoped without inventing any
// extra CSS syntax.
// The starter slide opens with a <style> tag. Less written inside a slide's
// <style> tag is scoped to that slide, the global slide's styles are shared
// by every slide, and `:thisSlide` is the slide's own section — no extra CSS
// syntax beyond that.
const STARTER_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".
// A slide's own style tag is scoped to that slide: every rule in it applies
// 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;
@fg: #f0f0f0;

View file

@ -209,12 +209,13 @@ function isGlobalTemplate(global: string): boolean {
* Compile a project into runtime-ready data.
*
* Every `<style>` tag in the deck contributes to one project stylesheet (the
* global slide first, then each slide in order), and `:thisSlide` is rewritten
* per style block *before* the blocks are joined: the global block to any
* slide, each slide's block to that slide's own section. The joined source
* then compiles exactly once — so Less keeps behaving like Less, the output
* never depends on which slide is visible, and one slide's scoped rules can
* never bind to its neighbours.
* global slide first, then each slide in order). The global block is the
* shared layer and compiles deck-wide; each slide's block is *wrapped* in its
* section selector before the blocks are joined, so a slide's stylesheet
* styles that slide and nothing else — token or not — with `:thisSlide`
* resolving to the section itself. The joined source compiles exactly once:
* 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> {
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)
}))
// Bind `:thisSlide` while each block still belongs to exactly one slide:
// the global block means "whoever is showing", a slide block means that
// slide. Rebinding after the join (the old per-slide passes) rebound every
// block on every pass, so each slide's scoped rules matched all slides.
// Scope each block while it still belongs to exactly one slide: the
// global block means "everywhere", a slide block means that slide. Only
// rewriting the `:thisSlide` token (the previous fix) still left every
// untokened rule deck-wide — the whole block now carries the selector.
const sources = [
bindThisSlide(global.style, ANY_SLIDE_SELECTOR),
...extracted.map((slide, index) =>
bindThisSlide(slide.style, slideSelector(slideId(index)))
)
...extracted.map((slide, index) => scopeBlock(slide.style, slideSelector(slideId(index))))
]
const source = sources.join('\n')
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 {
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}`
}