Fix IDS theme heading and bar layout

The orange bar never reached the slide's top left corner and the headings
stayed centred, because several separate things were fighting:

- reveal.js keeps 4% of the display empty around the content (config
  `margin`, default 0.04). No CSS inside the slide can escape that box.
  Default it to 0 and add the breathing room back as slide padding.

- Draw the bar as two ::before segments, one on the title and one on the
  subtitle, each sized as a percentage of its own heading box. The bar
  then always ends at the bottom of the last heading, follows a wrapped
  heading, and needs no fixed heights. Slides without a subtitle reserve
  the room one would take, so the bar does not change height between
  slides.

- Reaching the window's physical left edge needs viewport / scale, and
  CSS cannot divide a length by a length. The template now publishes the
  letter-/pillarbox bands as --ids-band-left / --ids-band-top and the bar
  shifts out by that much; it falls back to 0px, which is already correct
  whenever the deck and display aspect ratios agree.

- Headings rendered in a regular-weight, regular-width fallback because
  korap.ids-mannheim.de/font/fira-condensed.css returns a 404 page rather
  than CSS. Load Fira Sans Extra Condensed from Google Fonts, weight 700.

- Paint the bar as flat #f6a800. orange_bar.png is two-toned, so
  stretching it over each segment left a pale seam where they met.

- Left-align body content with the heading text and put the IDS logo's
  top on the heading's cap line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I199cb4f0c3b766c0774351922d05f171fdbd4e79
diff --git a/R/revealjs_presentation.R b/R/revealjs_presentation.R
index 47f4073..7d8c082 100644
--- a/R/revealjs_presentation.R
+++ b/R/revealjs_presentation.R
@@ -216,6 +216,15 @@
   # use history
   args <- c(args, pandoc_variable_arg("history", "true"))
 
+  # The IDS corporate design puts the orange bar flush against the top left
+  # corner of the slide. reveal.js defaults to margin = 0.04, i.e. it keeps 4%
+  # of the display empty around the content, which would leave a white border
+  # above and to the left of the bar. Default to 0 instead, unless the document
+  # asks for a margin of its own via reveal_options.
+  if (!is.list(reveal_options) || is.null(reveal_options[["margin"]])) {
+    args <- c(args, pandoc_variable_arg("margin", "0"))
+  }
+
   # additional reveal options
   if (is.list(reveal_options)) {
     for (option in names(reveal_options)) {
diff --git a/inst/reveal.js-5.1.0/css/theme/source/ids.scss b/inst/reveal.js-5.1.0/css/theme/source/ids.scss
index e2ba974..2f758c4 100644
--- a/inst/reveal.js-5.1.0/css/theme/source/ids.scss
+++ b/inst/reveal.js-5.1.0/css/theme/source/ids.scss
@@ -12,8 +12,13 @@
 
 
 // Include theme-specific fonts
+// NB: https://korap.ids-mannheim.de/font/{fira-condensed,libertinus}.css both
+// return 404, so the condensed and bold faces never loaded and headings fell
+// back to a regular-width, regular-weight face. Google Fonts serves the real
+// Fira Sans Condensed incl. weights 700/800 (this is where the IDS reference
+// decks get it from too); the others are kept for locally installed copies.
+@import url('https://fonts.googleapis.com/css2?family=Fira+Sans+Condensed:wght@300;400;700;800&family=Fira+Sans:wght@200;300;400;700&family=Fira+Sans+Extra+Condensed:wght@300;700&display=swap');
 @import url('https://code.cdn.mozilla.net/fonts/fira.css');
-@import url('https://korap.ids-mannheim.de/font/fira-condensed.css');
 @import url('https://korap.ids-mannheim.de/font/libertinus.css');
 
 $idsOrange: #f6a800;
@@ -26,11 +31,11 @@
 
 $mainFontSize: 42px;
 $mainFont: 'Libertinus Serif', Helvetica, sans-serif;
-$headingFont: 'Fira Sans Condensed', 'Fira Sans', Helvetica, sans-serif;
+$headingFont: 'Fira Sans Extra Condensed', 'Fira Sans Condensed', 'Fira Sans', Helvetica, sans-serif;
 $headingTextShadow: none;
 $headingLetterSpacing: 0.05ex;
 $headingTextTransform: uppercase;
-$headingFontWeight: 600;
+$headingFontWeight: 700;
 $linkColor: #2a76dd;
 $linkColorHover: lighten( $linkColor, 15% );
 $selectionBackgroundColor: lighten( $linkColor, 25% );
@@ -50,12 +55,163 @@
 @import "../template/theme";
 // ---------------------------------------------
 
-body {
-  background-image: url('https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/IDS-Logo-2019.svg'),
-    url('https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/orange_bar.png') !important;
-  background-repeat: no-repeat !important;
-  background-position: top 20px right 20px, top 20px left !important;
-  background-size: 25vH auto, 4vH 16vH !important;
+// Bar/logo sizes, expressed as fractions of the slide's own height (reveal.js
+// exposes the configured slide size as --slide-width / --slide-height custom
+// properties). Sizing relative to the slide rather than the raw viewport (vh
+// units) keeps things pinned to the true slide edges instead of drifting
+// into the letter-/pillarboxed margins when the window's aspect ratio
+// doesn't exactly match the configured one.
+$idsBarWidthRatio: 0.05;
+$idsLogoWidthRatio: 0.25;
+$idsBarWidth: calc(var(--slide-height) * #{$idsBarWidthRatio});
+$idsHeadingGap: 24px;
+
+// Vertical gap between the title and the subtitle. The subtitle's bar segment
+// reaches up by this much to bridge it, so the two segments read as one bar.
+$idsHeadingSpacing: 8px;
+
+// Rendered height of one subtitle line. Slides without a subtitle reserve
+// exactly this much, so the bar and the start of the body content stay put
+// from slide to slide instead of jumping. Must stay in sync with
+// $heading3Size (0.9em) and $headingLineHeight (1.2, from template/settings).
+$idsSubtitleHeight: 0.9 * $mainFontSize * 1.2;
+
+// The two bar segments abut at a fractional pixel, which browsers round into a
+// visible hairline. Overlapping them slightly makes the join seamless.
+$idsBarOverlap: 2px;
+
+// reveal.js centres the scaled slide in the viewport, so whenever the window's
+// aspect ratio does not match the deck's, an empty band is left beside (or
+// above) the slide and the bar would not reach the window's physical edge.
+// Working out how wide that band is needs viewport / scale, and CSS cannot
+// divide a length by a length -- which is why the hand-written IDS decks solve
+// this in JavaScript. revealjs_presentation()'s template therefore publishes
+// the band widths, in slide coordinates, as --ids-band-left / --ids-band-top,
+// and the bar simply shifts out by that much. Falling back to 0px keeps this
+// correct whenever the aspect ratios do match, and if the script never runs.
+$idsBandLeft: var(--ids-band-left, 0px);
+
+// revealjs_presentation() sets reveal.js' own `margin` option to 0, so that the
+// slide box is the whole viewport and the orange bar can sit flush in its top
+// left corner. That would otherwise leave the body content running edge to
+// edge, so the breathing room reveal.js' margin used to provide is added back
+// here as slide padding; the bar and the headings reach back out to the edge
+// with matching negative margins below. The inset is the bar plus its gap, so
+// body content lines up with the heading text rather than with the bar.
+$idsContentInset: calc(#{$idsBarWidth} + #{$idsHeadingGap});
+
+.reveal .slides section:not(.stack) {
+  box-sizing: border-box !important;
+  padding-left: #{$idsContentInset} !important;
+  padding-right: #{$idsContentInset} !important;
+}
+
+// Body content is left aligned with the headings; the title frame keeps its
+// own centered layout.
+.reveal .slides section:not(.stack):not(.title-frame) {
+  text-align: left !important;
+}
+
+// reveal.js centers lists by making them inline-block; make them ordinary
+// blocks so they line up with the headings, with the markers hanging in the
+// padding so the marker column itself aligns with the heading text.
+.reveal .slides section:not(.title-frame) ul,
+.reveal .slides section:not(.title-frame) ol {
+  display: block !important;
+  text-align: left !important;
+  margin-left: 0 !important;
+  padding-left: 0.7em !important;
+}
+
+// reveal.js centres code blocks (width: 90%; margin: auto); make them fill the
+// slide's text column so they start on the same left edge as the headings.
+.reveal .slides section:not(.title-frame) pre {
+  width: auto !important;
+  margin-left: 0 !important;
+  margin-right: 0 !important;
+}
+
+// The IDS logo stays a slide-wide watermark (top right of every slide,
+// including the title page). The top offset is chosen so the logo's cap line
+// meets the top of the heading text rather than sitting below it; the SVG
+// carries a little transparent padding of its own, which this accounts for.
+$idsLogoTop: 10px;
+$idsLogoRight: 8px;
+
+.reveal .slides {
+  background-image: url('https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/IDS-Logo-2019.svg');
+  background-repeat: no-repeat;
+  background-position: top #{$idsLogoTop} right #{$idsLogoRight};
+  background-size: calc(var(--slide-height) * #{$idsLogoWidthRatio}) auto;
+}
+
+// The orange bar is drawn as a ::before on the headings rather than on the
+// slide, in two segments: one behind the title and one behind the subtitle.
+// Because each segment is sized as a percentage of its own heading's box, the
+// bar always ends exactly at the bottom of the last heading -- it follows a
+// subtitle when there is one, stops at the title when there isn't, and grows
+// by itself when a heading wraps onto a second line. No fixed heights, and no
+// JavaScript. Headings carry no vertical padding, so they sit as high on the
+// slide as the bar does. Every rule is !important because this theme has to
+// win against plugin- and document-level CSS loaded after it.
+.reveal .slides section:not(.title-frame) > h1:first-child,
+.reveal .slides section:not(.title-frame) > h2:first-child {
+  position: relative !important;
+  display: block !important;
+  text-align: left !important;
+  margin: 0 calc(-1 * #{$idsContentInset}) #{$idsHeadingSpacing} calc(-1 * #{$idsContentInset}) !important;
+  padding: 0 0 0 #{$idsContentInset} !important;
+  font-weight: var(--r-heading-font-weight) !important;
+}
+
+.reveal .slides section:not(.title-frame) > h1:first-child::before,
+.reveal .slides section:not(.title-frame) > h2:first-child::before,
+.reveal .slides section:not(.title-frame) > h2:first-child + h3::before {
+  content: "" !important;
+  position: absolute !important;
+  width: #{$idsBarWidth} !important;
+  // A flat fill rather than orange_bar.png: that image is two-toned (a lighter
+  // band across its top), so stretching it over each segment separately left a
+  // pale seam where the two met. The IDS reference decks draw a flat #f6a800.
+  background: #{$idsOrange} !important;
+}
+
+// Title segment: pulled out to the window's physical left edge.
+.reveal .slides section:not(.title-frame) > h1:first-child::before,
+.reveal .slides section:not(.title-frame) > h2:first-child::before {
+  top: 0 !important;
+  left: calc(-1 * #{$idsBandLeft}) !important;
+  height: 100% !important;
+}
+
+// A slide without a subtitle reserves the room one would have taken, so the
+// bar keeps the same height and the body content starts at the same place on
+// every slide. :has() is what lets us ask "is a subtitle coming?" in CSS; where
+// it is unsupported such slides simply keep a title-height bar.
+.reveal .slides section:not(.title-frame) > h1:first-child:not(:has(+ h3)),
+.reveal .slides section:not(.title-frame) > h2:first-child:not(:has(+ h3)) {
+  margin-bottom: calc(#{$idsHeadingSpacing} + #{$idsSubtitleHeight} + #{$blockMargin}) !important;
+}
+
+.reveal .slides section:not(.title-frame) > h1:first-child:not(:has(+ h3))::before,
+.reveal .slides section:not(.title-frame) > h2:first-child:not(:has(+ h3))::before {
+  height: calc(100% + #{$idsHeadingSpacing} + #{$idsSubtitleHeight}) !important;
+}
+
+// The subtitle sits in the slide's normal text column, so its segment has to
+// reach back out to the slide edge, and up over the gap to the title.
+.reveal .slides section:not(.title-frame) > h2:first-child + h3 {
+  position: relative !important;
+  text-align: left !important;
+  margin-top: 0 !important;
+  margin-left: 0 !important;
+  margin-bottom: $blockMargin !important;
+}
+
+.reveal .slides section:not(.title-frame) > h2:first-child + h3::before {
+  top: calc(-1 * (#{$idsHeadingSpacing} + #{$idsBarOverlap})) !important;
+  left: calc(-1 * (#{$idsContentInset} + #{$idsBandLeft})) !important;
+  height: calc(100% + #{$idsHeadingSpacing} + #{$idsBarOverlap}) !important;
 }
 
 // Define additional color effects based on Dracula spec
@@ -155,17 +311,20 @@
   transition: opacity 800ms ease-in-out;
 }
 
-.title-frame div.slide-background-content {
-  text-align: right !important;
-  background-image: url("https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/Mitglied_WGL_transparent.svg") !important;
+// Like the orange bar/IDS logo above, this is painted on the slide itself
+// (rather than on .slide-background-content, which spans the raw,
+// unscaled viewport) so it stays pinned to the title slide's true
+// bottom-right corner instead of the browser window's.
+.reveal .slides section.title-frame {
+  background-image: url("https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/Mitglied_WGL_transparent.svg");
   background-repeat: no-repeat;
-  background-position: right 15px bottom 5px !important;
-  background-size: 7vH auto !important;
+  background-position: right 15px bottom 5px;
+  background-size: calc(var(--slide-height) * 0.07) auto;
 }
 
 @media only screen {
-  .title-frame div.slide-background-content {
-    background-position: right 45px bottom 5px !important;
+  .reveal .slides section.title-frame {
+    background-position: right 45px bottom 5px;
   }
 }
 
@@ -223,10 +382,6 @@
   margin-top: -0.5em !important;
 }
 
-.reveal h1:last-child {
-  margin-top: 250px;
-}
-
 .title-frame h1,
 .title-frame h2 {
     margin: 0 0 0 0;
diff --git a/inst/reveal.js-5.1.0/dist/theme/ids.css b/inst/reveal.js-5.1.0/dist/theme/ids.css
index 3ee7189..96bc70f 100644
--- a/inst/reveal.js-5.1.0/dist/theme/ids.css
+++ b/inst/reveal.js-5.1.0/dist/theme/ids.css
@@ -4,7 +4,7 @@
  * By Marc Kupietz
  */
 @import url("https://code.cdn.mozilla.net/fonts/fira.css");
-@import url("https://korap.ids-mannheim.de/font/fira-condensed.css");
+@import url("https://fonts.googleapis.com/css2?family=Fira+Sans+Condensed:wght@300;400;700;800&family=Fira+Sans:wght@200;300;400;700&family=Fira+Sans+Extra+Condensed:wght@300;700&display=swap");
 @import url("https://korap.ids-mannheim.de/font/libertinus.css");
 
 section.has-dark-background, section.has-dark-background h1, section.has-dark-background h2, section.has-dark-background h3, section.has-dark-background h4, section.has-dark-background h5, section.has-dark-background h6 {
@@ -21,13 +21,13 @@
   --r-main-color: #222;
   --r-block-margin: 20px;
   --r-heading-margin: 0 0 20px 0;
-  --r-heading-font: Fira Sans Condensed, Fira Sans, Helvetica, sans-serif;
+  --r-heading-font: Fira Sans Extra Condensed, Fira Sans Condensed, Fira Sans, Helvetica, sans-serif;
   --r-heading-color: #f6a800;
   --r-heading-line-height: 1.2;
   --r-heading-letter-spacing: 0.05ex;
   --r-heading-text-transform: uppercase;
   --r-heading-text-shadow: none;
-  --r-heading-font-weight: 600;
+  --r-heading-font-weight: 700;
   --r-heading1-text-shadow: none;
   --r-heading1-size: 2em;
   --r-heading2-size: 1.35em;
@@ -361,11 +361,85 @@
   }
 }
 
-body {
-  background-image: url("https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/IDS-Logo-2019.svg"), url("https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/orange_bar.png") !important;
-  background-repeat: no-repeat !important;
-  background-position: top 20px right 20px, top 20px left !important;
-  background-size: 25vH auto, 4vH 16vH !important;
+.reveal .slides section:not(.stack) {
+  box-sizing: border-box !important;
+  padding-left: calc(calc(var(--slide-height) * 0.05) + 24px) !important;
+  padding-right: calc(calc(var(--slide-height) * 0.05) + 24px) !important;
+}
+
+.reveal .slides section:not(.stack):not(.title-frame) {
+  text-align: left !important;
+}
+
+.reveal .slides section:not(.title-frame) ul,
+.reveal .slides section:not(.title-frame) ol {
+  display: block !important;
+  text-align: left !important;
+  margin-left: 0 !important;
+  padding-left: 0.7em !important;
+}
+
+.reveal .slides section:not(.title-frame) pre {
+  width: auto !important;
+  margin-left: 0 !important;
+  margin-right: 0 !important;
+}
+
+.reveal .slides {
+  background-image: url("https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/IDS-Logo-2019.svg");
+  background-repeat: no-repeat;
+  background-position: top 10px right 8px;
+  background-size: calc(var(--slide-height) * 0.25) auto;
+}
+
+.reveal .slides section:not(.title-frame) > h1:first-child,
+.reveal .slides section:not(.title-frame) > h2:first-child {
+  position: relative !important;
+  display: block !important;
+  text-align: left !important;
+  margin: 0 calc(-1 * calc(calc(var(--slide-height) * 0.05) + 24px)) 8px calc(-1 * calc(calc(var(--slide-height) * 0.05) + 24px)) !important;
+  padding: 0 0 0 calc(calc(var(--slide-height) * 0.05) + 24px) !important;
+  font-weight: var(--r-heading-font-weight) !important;
+}
+
+.reveal .slides section:not(.title-frame) > h1:first-child::before,
+.reveal .slides section:not(.title-frame) > h2:first-child::before,
+.reveal .slides section:not(.title-frame) > h2:first-child + h3::before {
+  content: "" !important;
+  position: absolute !important;
+  width: calc(var(--slide-height) * 0.05) !important;
+  background: #f6a800 !important;
+}
+
+.reveal .slides section:not(.title-frame) > h1:first-child::before,
+.reveal .slides section:not(.title-frame) > h2:first-child::before {
+  top: 0 !important;
+  left: calc(-1 * var(--ids-band-left, 0px)) !important;
+  height: 100% !important;
+}
+
+.reveal .slides section:not(.title-frame) > h1:first-child:not(:has(+ h3)),
+.reveal .slides section:not(.title-frame) > h2:first-child:not(:has(+ h3)) {
+  margin-bottom: calc(8px + 45.36px + 20px) !important;
+}
+
+.reveal .slides section:not(.title-frame) > h1:first-child:not(:has(+ h3))::before,
+.reveal .slides section:not(.title-frame) > h2:first-child:not(:has(+ h3))::before {
+  height: calc(100% + 8px + 45.36px) !important;
+}
+
+.reveal .slides section:not(.title-frame) > h2:first-child + h3 {
+  position: relative !important;
+  text-align: left !important;
+  margin-top: 0 !important;
+  margin-left: 0 !important;
+  margin-bottom: 20px !important;
+}
+
+.reveal .slides section:not(.title-frame) > h2:first-child + h3::before {
+  top: calc(-1 * (8px + 2px)) !important;
+  left: calc(-1 * (calc(calc(var(--slide-height) * 0.05) + 24px) + var(--ids-band-left, 0px))) !important;
+  height: calc(100% + 8px + 2px) !important;
 }
 
 :root {
@@ -457,17 +531,16 @@
   transition: opacity 800ms ease-in-out;
 }
 
-.title-frame div.slide-background-content {
-  text-align: right !important;
-  background-image: url("https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/Mitglied_WGL_transparent.svg") !important;
+.reveal .slides section.title-frame {
+  background-image: url("https://corpora.ids-mannheim.de/slides/reveal.js.ids/images/Mitglied_WGL_transparent.svg");
   background-repeat: no-repeat;
-  background-position: right 15px bottom 5px !important;
-  background-size: 7vH auto !important;
+  background-position: right 15px bottom 5px;
+  background-size: calc(var(--slide-height) * 0.07) auto;
 }
 
 @media only screen {
-  .title-frame div.slide-background-content {
-    background-position: right 45px bottom 5px !important;
+  .reveal .slides section.title-frame {
+    background-position: right 45px bottom 5px;
   }
 }
 
@@ -524,10 +597,6 @@
   margin-top: -0.5em !important;
 }
 
-.reveal h1:last-child {
-  margin-top: 250px;
-}
-
 .title-frame h1,
 .title-frame h2 {
   margin: 0 0 0 0;
diff --git a/inst/rmarkdown/templates/revealjs_presentation/resources/default.html b/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
index e1d79f6..2d8b497 100644
--- a/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
+++ b/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
@@ -714,6 +714,32 @@
 $include-after$
 $endfor$
 $if(theme-ids)$
+<script>
+  // IDS theme: reveal.js centres the scaled slide in the viewport, so when the
+  // window's aspect ratio differs from the deck's an empty band is left beside
+  // (or above) it. The corporate-design bar has to reach the window's physical
+  // edge regardless, and working the band out needs viewport / scale, which CSS
+  // cannot express. Publish it as a custom property in slide coordinates and let
+  // the theme shift the bar out by that much.
+  (function () {
+    function updateBands() {
+      var scale = Reveal.getScale();
+      if (!scale) return;
+      var size = Reveal.getComputedSlideSize();
+      var root = document.querySelector('.reveal-viewport') || document.body;
+      root.style.setProperty('--ids-band-left',
+        Math.max(0, (window.innerWidth - size.width * scale) / 2 / scale) + 'px');
+      root.style.setProperty('--ids-band-top',
+        Math.max(0, (window.innerHeight - size.height * scale) / 2 / scale) + 'px');
+    }
+    Reveal.on('ready', updateBands);
+    Reveal.on('resize', updateBands);
+    window.addEventListener('resize', updateBands);
+  })();
+</script>
+$endif$
+
+$if(theme-ids)$
     <footer id="ids-footer"><span>
       $for(author)$$if(author.name)$$author.name$$else$$author$$endif$${ sep }, $endfor$ · $title$ · $date$</span></footer>
 $endif$