Heise Editorial Layout

Grid and list homepage reading surface for heise.de

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Heise Editorial Layout
// @namespace    local.heise-editorial-layout
// @version      0.6.0
// @description  Grid and list homepage reading surface for heise.de
// @match        https://www.heise.de/
// @run-at       document-idle
// @grant        none
// @license      MIT
// @author       Trbtxc
// ==/UserScript==

(function () {
  'use strict';

  const ROOT_ID = 'heise-editorial-layout';
  const STYLE_ID = 'heise-editorial-layout-styles';
  const VIEW_STORAGE_KEY = 'heise-editorial-layout:view';
  const DISABLED_BRANDINGS_STORAGE_KEY =
    'heise-editorial-layout:disabled-brandings';
  const HIDE_PAYWALLED_STORAGE_KEY = 'heise-editorial-layout:hide-paywalled';
  const DEFAULT_VIEW = 'grid';
  const GRID_ARTICLE_LIMIT = 6;
  const OBSERVER_TIMEOUT_MS = 5000;
  const THEME_DARK = 'dark';
  const THEME_LIGHT = 'light';

  const TARGET_SELECTOR =
    '[data-component="ModulesContainer"][data-upscore-zone="ho_homepage_full"]';
  const FEATURED_SELECTOR =
    '[data-component="TeasersModule"] section[data-layout-name="FreeHorizontalLayout"]';
  const MAIN_SELECTOR =
    '[data-component="TeasersModule"] section[data-layout="TeaserListLayout"]';
  const CARD_SELECTOR = 'article[data-component="TeaserContainer"]';
  const MAIN_LIST_CARD_SELECTOR =
    ':scope > [data-component="TeaserList"] > article[data-component="TeaserContainer"]';

  const dateFormatter = new Intl.DateTimeFormat('de-DE', {
    dateStyle: 'medium',
    timeStyle: 'short',
  });

  if (document.getElementById(ROOT_ID)) {
    return;
  }

  let observer = null;
  let timeoutId = null;
  let retryFrameId = null;
  let themeObserver = null;
  let themeMediaQuery = null;
  let themeSyncFrameId = null;
  let themeRoot = null;
  let mounted = false;
  let lastFailure = 'required homepage content was not ready';

  function parseRgbColor(colorValue) {
    if (typeof colorValue !== 'string') {
      return null;
    }

    const normalized = colorValue.trim().toLowerCase();
    if (!normalized || normalized === 'transparent') {
      return null;
    }

    const rgbMatch = normalized.match(
      /^rgba?\((\d+)\s*[\s,/]\s*(\d+)\s*[\s,/]\s*(\d+)(?:\s*[\s,/]\s*([\d.]+))?\)$/
    );
    if (rgbMatch) {
      const alpha = rgbMatch[4] !== undefined ? Number(rgbMatch[4]) : 1;
      if (Number.isFinite(alpha) && alpha <= 0) {
        return null;
      }
      return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])];
    }

    const hexMatch = normalized.match(/^#([\da-f]{3}|[\da-f]{6})$/i);
    if (hexMatch) {
      const hex = hexMatch[1];
      if (hex.length === 3) {
        return [
          parseInt(hex[0] + hex[0], 16),
          parseInt(hex[1] + hex[1], 16),
          parseInt(hex[2] + hex[2], 16),
        ];
      }
      return [
        parseInt(hex.slice(0, 2), 16),
        parseInt(hex.slice(2, 4), 16),
        parseInt(hex.slice(4, 6), 16),
      ];
    }

    return null;
  }

  function getColorLuminance(rgbColor) {
    return 0.2126 * rgbColor[0] + 0.7152 * rgbColor[1] + 0.0722 * rgbColor[2];
  }

  function getEffectiveTheme() {
    const html = document.documentElement;
    if (html.classList.contains(THEME_DARK)) {
      return THEME_DARK;
    }

    if (html.classList.contains(THEME_LIGHT)) {
      return THEME_LIGHT;
    }

    const bodyStyle = window.getComputedStyle(document.body);
    const htmlStyle = window.getComputedStyle(html);
    const sampledColor =
      parseRgbColor(bodyStyle.backgroundColor) ??
      parseRgbColor(htmlStyle.backgroundColor);

    if (sampledColor) {
      return getColorLuminance(sampledColor) < 140 ? THEME_DARK : THEME_LIGHT;
    }

    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      return THEME_DARK;
    }

    return THEME_LIGHT;
  }

  function applyTheme(root, theme) {
    root.dataset.helTheme = theme === THEME_DARK ? THEME_DARK : THEME_LIGHT;
  }

  function scheduleThemeSync() {
    if (!themeRoot || themeSyncFrameId !== null) {
      return;
    }

    themeSyncFrameId = requestAnimationFrame(() => {
      themeSyncFrameId = null;
      if (!themeRoot) {
        return;
      }
      applyTheme(themeRoot, getEffectiveTheme());
    });
  }

  function setupThemeSync(root) {
    themeRoot = root;
    applyTheme(root, getEffectiveTheme());

    themeObserver?.disconnect();
    themeObserver = new MutationObserver(scheduleThemeSync);
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['class', 'data-theme'],
    });

    themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    if (typeof themeMediaQuery.addEventListener === 'function') {
      themeMediaQuery.addEventListener('change', scheduleThemeSync);
    } else if (typeof themeMediaQuery.addListener === 'function') {
      themeMediaQuery.addListener(scheduleThemeSync);
    }

    document.addEventListener('visibilitychange', scheduleThemeSync);
    window.addEventListener('pageshow', scheduleThemeSync);
  }

  function readStoredView() {
    try {
      const storedView = localStorage.getItem(VIEW_STORAGE_KEY);
      return storedView === 'grid' || storedView === 'list'
        ? storedView
        : DEFAULT_VIEW;
    } catch {
      return DEFAULT_VIEW;
    }
  }

  function storeView(view) {
    try {
      localStorage.setItem(VIEW_STORAGE_KEY, view);
    } catch {
      // Persistence is optional; view switching remains available in memory.
    }
  }

  function readDisabledBrandings() {
    try {
      const storedBrandings = JSON.parse(
        localStorage.getItem(DISABLED_BRANDINGS_STORAGE_KEY) ?? '[]'
      );
      return new Set(
        Array.isArray(storedBrandings)
          ? storedBrandings.filter((branding) => typeof branding === 'string')
          : []
      );
    } catch {
      return new Set();
    }
  }

  function storeDisabledBrandings(disabledBrandings) {
    try {
      localStorage.setItem(
        DISABLED_BRANDINGS_STORAGE_KEY,
        JSON.stringify(Array.from(disabledBrandings))
      );
    } catch {
      // Persistence is optional; filtering remains available in memory.
    }
  }

  function readHidePaywalled() {
    try {
      return localStorage.getItem(HIDE_PAYWALLED_STORAGE_KEY) === 'true';
    } catch {
      return false;
    }
  }

  function storeHidePaywalled(hidePaywalled) {
    try {
      localStorage.setItem(HIDE_PAYWALLED_STORAGE_KEY, String(hidePaywalled));
    } catch {
      // Persistence is optional; filtering remains available in memory.
    }
  }

  function formatDate(datetimeIso) {
    if (!datetimeIso.trim()) {
      return '';
    }

    const date = new Date(datetimeIso);
    return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date);
  }

  function extractTeaser(card) {
    const link = card.querySelector('a[data-component="TeaserLinkContainer"]');
    const imageContainer = card.querySelector('a-img[src]');
    const image = imageContainer?.querySelector('img') ??
      card.querySelector('figure[data-component="Image"] img');
    const headline = card.querySelector('[data-component="TeaserHeadline"]');
    const paywallIcon = headline?.querySelector('img[src*="heise_plus_blue.svg"]');
    const synopsis = card.querySelector('[data-component="TeaserSynopsis"]');
    const meta = card.querySelector('[data-component="TeaserMeta"]');
    const time = meta?.querySelector('time[datetime]');
    const branding = meta?.querySelector('[data-component="Branding"]');
    const comments = Array.from(meta?.querySelectorAll('span.items-center') ?? [])
      .find((element) => element.querySelector('img[src*="comments_"]'));
    const commentIcon = comments?.querySelector(
      'img[src*="comments_outline_grey_dark.svg"]'
    ) ?? comments?.querySelector('img[src*="comments_"]');

    const url = link?.getAttribute('href') ?? '';
    const title = headline?.textContent?.trim() ?? '';
    const synopsisText = synopsis?.textContent?.trim() ?? '';

    if (!url.trim() || !title || !synopsisText) {
      return null;
    }

    const datetimeIso = time?.getAttribute('datetime') ?? '';
    const commentCount = comments?.textContent?.match(/\d[\d.\s]*/)?.[0]
      .replace(/\s/g, '') ?? '';

    return {
      url,
      imageUrl: imageContainer?.getAttribute('src') ?? image?.currentSrc ??
        image?.getAttribute('src') ?? '',
      imageAlt: imageContainer?.getAttribute('alt') ?? image?.getAttribute('alt') ?? '',
      imageWidth: imageContainer?.getAttribute('width') ??
        image?.getAttribute('width') ?? '',
      imageHeight: imageContainer?.getAttribute('height') ??
        image?.getAttribute('height') ?? '',
      title,
      synopsis: synopsisText,
      datetimeIso,
      displayDate: formatDate(datetimeIso),
      commentCount,
      commentIconUrl: commentIcon?.getAttribute('src') ?? '',
      branding: branding?.textContent?.trim() ?? '',
      isPaywalled: Boolean(paywallIcon),
      paywallIconUrl: paywallIcon?.getAttribute('src') ?? '',
    };
  }

  function extractCards(section, cardSelector = CARD_SELECTOR) {
    return Array.from(section.querySelectorAll(cardSelector))
      .map(extractTeaser)
      .filter(Boolean);
  }

  function createIcon(icon) {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('width', '16');
    svg.setAttribute('height', '16');
    svg.setAttribute('aria-hidden', 'true');
    svg.setAttribute('fill', 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2');

    if (icon === 'grid') {
      for (const [x, y] of [[3, 3], [14, 3], [3, 14], [14, 14]]) {
        const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
        rect.setAttribute('x', String(x));
        rect.setAttribute('y', String(y));
        rect.setAttribute('width', '7');
        rect.setAttribute('height', '7');
        svg.append(rect);
      }
    } else if (icon === 'list') {
      for (const y of [5, 12, 19]) {
        const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
        line.setAttribute('x1', '4');
        line.setAttribute('y1', String(y));
        line.setAttribute('x2', '20');
        line.setAttribute('y2', String(y));
        svg.append(line);
      }
    } else if (icon === 'close') {
      for (const [x1, y1, x2, y2] of [[5, 5, 19, 19], [19, 5, 5, 19]]) {
        const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
        line.setAttribute('x1', String(x1));
        line.setAttribute('y1', String(y1));
        line.setAttribute('x2', String(x2));
        line.setAttribute('y2', String(y2));
        svg.append(line);
      }
    } else {
      const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
      circle.setAttribute('cx', '12');
      circle.setAttribute('cy', '12');
      circle.setAttribute('r', '3');
      svg.append(circle);

      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute(
        'd',
        'M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21H9.6v-.1A1.7 1.7 0 0 0 8.5 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3V9.6h.1A1.7 1.7 0 0 0 4.6 8.5a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.1A1.7 1.7 0 0 0 15.5 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.16.38.39.72.7 1 .3.26.69.4 1.1.4h.1v4h-.1a1.7 1.7 0 0 0-1.8.6Z'
      );
      svg.append(path);
    }

    return svg;
  }

  function createViewButton(view, label, activeView) {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'hel-view-button';
    button.dataset.view = view;
    button.setAttribute('aria-pressed', String(view === activeView));
    button.append(createIcon(view));

    const text = document.createElement('span');
    text.textContent = label;
    button.append(text);
    return button;
  }

  function createMediaLink(teaser, highQualityThumbnail = false) {
    const link = document.createElement('a');
    link.className = 'hel-media-link';
    link.setAttribute('href', teaser.url);

    const image = document.createElement('a-img');
    image.className = 'hel-image';
    image.setAttribute('alt', teaser.imageAlt);
    image.setAttribute('quality', highQualityThumbnail ? '90' : '75');
    image.setAttribute('high-dpi-quality', highQualityThumbnail ? '90' : '70');
    if (teaser.imageUrl) {
      image.setAttribute('src', teaser.imageUrl);
    }
    if (teaser.imageWidth) {
      image.setAttribute('width', teaser.imageWidth);
    }
    if (teaser.imageHeight) {
      image.setAttribute('height', teaser.imageHeight);
    }

    link.append(image);
    return link;
  }

  function createHeadline(teaser, featured) {
    const heading = document.createElement(featured ? 'h2' : 'h3');
    heading.className = 'hel-headline';

    const link = document.createElement('a');
    link.setAttribute('href', teaser.url);
    if (teaser.isPaywalled && teaser.paywallIconUrl) {
      const paywallIcon = document.createElement('img');
      paywallIcon.className = 'hel-paywall-icon';
      paywallIcon.setAttribute('src', teaser.paywallIconUrl);
      paywallIcon.setAttribute('alt', 'heise Plus');
      link.append(paywallIcon);
    }
    link.append(document.createTextNode(teaser.title));
    heading.append(link);
    return heading;
  }

  function createTextContent(teaser, featured) {
    const content = document.createElement('div');
    content.className = 'hel-card-content';
    content.append(createHeadline(teaser, featured));

    const synopsis = document.createElement('p');
    synopsis.className = 'hel-synopsis';
    synopsis.textContent = teaser.synopsis;
    content.append(synopsis);

    const meta = document.createElement('div');
    meta.className = 'hel-meta';

    const time = document.createElement('time');
    time.className = 'hel-meta-time';
    time.setAttribute('datetime', teaser.datetimeIso);
    time.textContent = teaser.displayDate || '\u00a0';

    const comments = document.createElement('span');
    comments.className = 'hel-meta-comments';
    if (teaser.commentCount) {
      comments.setAttribute(
        'aria-label',
        `${teaser.commentCount} ${teaser.commentCount === '1' ? 'Kommentar' : 'Kommentare'}`
      );
      if (teaser.commentIconUrl) {
        const commentIcon = document.createElement('img');
        commentIcon.setAttribute('src', teaser.commentIconUrl);
        commentIcon.setAttribute('alt', '');
        comments.append(commentIcon);
      }
      const commentCount = document.createElement('span');
      commentCount.textContent = teaser.commentCount;
      comments.append(commentCount);
    } else {
      comments.textContent = '\u00a0';
      comments.setAttribute('aria-hidden', 'true');
    }

    const branding = document.createElement('span');
    branding.className = 'hel-meta-branding';
    branding.textContent = teaser.branding || '\u00a0';
    if (!teaser.branding) {
      branding.setAttribute('aria-hidden', 'true');
    }

    meta.append(time, comments, branding);
    content.append(meta);
    return content;
  }

  function createCard(teaser, variant, highQualityThumbnail = false) {
    const featured = variant === 'featured';
    const article = document.createElement('article');
    article.className = `hel-card hel-${variant}-card`;
    article.append(createMediaLink(teaser, highQualityThumbnail));
    article.append(createTextContent(teaser, featured));
    return article;
  }

  function buildView(view, featuredTeasers, mainTeasers) {
    const fragment = document.createDocumentFragment();

    const isEmpty = view === 'list'
      ? mainTeasers.length === 0
      : featuredTeasers.length === 0 && mainTeasers.length === 0;
    if (isEmpty) {
      const empty = document.createElement('p');
      empty.className = 'hel-empty';
      empty.textContent = 'Für diese Auswahl sind keine Artikel vorhanden.';
      fragment.append(empty);
      return fragment;
    }

    if (view === 'grid') {
      if (featuredTeasers.length > 0) {
        const featuredGrid = document.createElement('div');
        featuredGrid.className = 'hel-featured-grid';
        featuredGrid.dataset.count = String(featuredTeasers.length);
        for (const teaser of featuredTeasers) {
          featuredGrid.append(createCard(teaser, 'featured'));
        }
        fragment.append(featuredGrid);
      }

      if (mainTeasers.length > 0) {
        const mainGrid = document.createElement('div');
        mainGrid.className = 'hel-main-grid';
        for (const teaser of mainTeasers.slice(0, GRID_ARTICLE_LIMIT)) {
          mainGrid.append(createCard(teaser, 'grid'));
        }
        fragment.append(mainGrid);
      }

      const remainingTeasers = mainTeasers.slice(GRID_ARTICLE_LIMIT);
      if (remainingTeasers.length > 0) {
        const continuation = document.createElement('div');
        continuation.className = 'hel-list hel-grid-continuation';
        for (const teaser of remainingTeasers) {
          continuation.append(createCard(teaser, 'list'));
        }
        fragment.append(continuation);
      }
    } else {
      const list = document.createElement('div');
      list.className = 'hel-list';
      for (const teaser of mainTeasers) {
        list.append(createCard(teaser, 'list', true));
      }
      fragment.append(list);
    }

    return fragment;
  }

  function buildLayout(featuredTeasers, mainTeasers) {
    let activeView = readStoredView();
    const disabledBrandings = readDisabledBrandings();
    let hidePaywalled = readHidePaywalled();
    const brandings = new Set(
      [...featuredTeasers, ...mainTeasers].map((teaser) => teaser.branding)
    );
    const root = document.createElement('section');
    root.id = ROOT_ID;
    root.dataset.view = activeView;
    root.setAttribute('aria-label', 'Aktuelle Nachrichten');

    const toolbar = document.createElement('div');
    toolbar.className = 'hel-toolbar';

    const switcher = document.createElement('div');
    switcher.className = 'hel-view-switcher';
    switcher.setAttribute('role', 'group');
    switcher.setAttribute('aria-label', 'Darstellung');

    const gridButton = createViewButton('grid', 'Raster', activeView);
    const listButton = createViewButton('list', 'Liste', activeView);
    switcher.append(gridButton, listButton);
    toolbar.append(switcher);

    const settings = document.createElement('div');
    settings.className = 'hel-settings';

    const settingsButton = document.createElement('button');
    settingsButton.type = 'button';
    settingsButton.className = 'hel-settings-button';
    settingsButton.setAttribute('aria-expanded', 'false');
    settingsButton.setAttribute('aria-controls', 'hel-branding-menu');
    settingsButton.append(createIcon('settings'));

    const settingsText = document.createElement('span');
    settingsText.textContent = 'Einstellungen';
    settingsButton.append(settingsText);

    const brandingMenu = document.createElement('div');
    brandingMenu.id = 'hel-branding-menu';
    brandingMenu.className = 'hel-branding-menu';
    brandingMenu.setAttribute('role', 'group');
    brandingMenu.hidden = true;

    const settingsBackdrop = document.createElement('div');
    settingsBackdrop.className = 'hel-settings-backdrop';
    settingsBackdrop.hidden = true;
    settingsBackdrop.setAttribute('aria-hidden', 'true');

    const brandingTitle = document.createElement('div');
    brandingTitle.id = 'hel-branding-menu-title';
    brandingTitle.className = 'hel-branding-menu-title';
    brandingTitle.textContent = 'Kategorien anzeigen';
    brandingMenu.setAttribute('aria-labelledby', brandingTitle.id);
    brandingMenu.append(brandingTitle);

    const settingsCloseButton = document.createElement('button');
    settingsCloseButton.type = 'button';
    settingsCloseButton.className = 'hel-settings-close';
    settingsCloseButton.setAttribute('aria-label', 'Einstellungen schließen');
    settingsCloseButton.title = 'Einstellungen schließen';
    settingsCloseButton.append(createIcon('close'));
    brandingMenu.append(settingsCloseButton);

    function createBrandingOption(branding, labelText) {
      const option = document.createElement('label');
      option.className = 'hel-branding-option';

      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.value = branding;
      checkbox.checked = !disabledBrandings.has(branding);

      const label = document.createElement('span');
      label.textContent = labelText;
      option.append(checkbox, label);
      return option;
    }

    const brandingColumns = [
      [
        ['', 'Newsticker'],
        ['heise developer', 'heise developer'],
        ['heise security', 'heise security'],
        ["c't Magazin", "c't Magazin"],
        ['iX Magazin', 'iX Magazin'],
        ['Mac & i Magazin', 'Mac & I Magazin'],
      ],
      [
        ['Make Magazin', 'Make Magazin'],
        ['heise+ exklusiv', 'heise+ exklusiv'],
        ['heise autos', 'heise autos'],
        ['bestenlisten', 'bestenlisten'],
      ],
    ];

    for (const columnBrandings of brandingColumns) {
      const column = document.createElement('div');
      column.className = 'hel-branding-column';
      for (const [branding, labelText] of columnBrandings) {
        if (brandings.has(branding)) {
          column.append(createBrandingOption(branding, labelText));
        }
      }
      brandingMenu.append(column);
    }

    const paywallOption = document.createElement('label');
    paywallOption.className = 'hel-branding-option';

    const paywallCheckbox = document.createElement('input');
    paywallCheckbox.type = 'checkbox';
    paywallCheckbox.checked = !hidePaywalled;

    const paywallLabel = document.createElement('span');
    paywallLabel.textContent = 'Heise Plus Artikel anzeigen';
    paywallOption.append(paywallCheckbox, paywallLabel);
    brandingMenu.lastElementChild.append(paywallOption);

    settings.append(settingsButton, brandingMenu);
    toolbar.append(settings);
    root.append(toolbar, settingsBackdrop);

    const content = document.createElement('div');
    content.className = 'hel-content';

    function renderContent() {
      const isEnabled = (teaser) =>
        !disabledBrandings.has(teaser.branding) &&
        (!hidePaywalled || !teaser.isPaywalled);
      content.replaceChildren(buildView(
        activeView,
        featuredTeasers.filter(isEnabled),
        mainTeasers.filter(isEnabled)
      ));
    }

    function setSettingsOpen(open) {
      brandingMenu.hidden = !open;
      settingsBackdrop.hidden = !open;
      settingsButton.setAttribute('aria-expanded', String(open));
    }

    renderContent();
    root.append(content);

    settingsButton.addEventListener('click', () => {
      setSettingsOpen(settingsButton.getAttribute('aria-expanded') !== 'true');
    });

    settingsCloseButton.addEventListener('click', () => {
      setSettingsOpen(false);
      settingsButton.focus();
    });

    brandingMenu.addEventListener('change', (event) => {
      const checkbox = event.target.closest('input[type="checkbox"]');
      if (!checkbox || !brandingMenu.contains(checkbox)) {
        return;
      }

      if (checkbox === paywallCheckbox) {
        hidePaywalled = !checkbox.checked;
        storeHidePaywalled(hidePaywalled);
        renderContent();
        return;
      }

      if (checkbox.checked) {
        disabledBrandings.delete(checkbox.value);
      } else {
        disabledBrandings.add(checkbox.value);
      }
      storeDisabledBrandings(disabledBrandings);
      renderContent();
    });

    document.addEventListener('click', (event) => {
      if (!settings.contains(event.target)) {
        setSettingsOpen(false);
      }
    });

    document.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && !brandingMenu.hidden) {
        setSettingsOpen(false);
        settingsButton.focus();
      }
    });

    switcher.addEventListener('click', (event) => {
      const button = event.target.closest('button[data-view]');
      if (!button || !switcher.contains(button)) {
        return;
      }

      const nextView = button.dataset.view;
      if ((nextView !== 'grid' && nextView !== 'list') || nextView === activeView) {
        return;
      }

      activeView = nextView;
      root.dataset.view = activeView;
      gridButton.setAttribute('aria-pressed', String(activeView === 'grid'));
      listButton.setAttribute('aria-pressed', String(activeView === 'list'));
      storeView(activeView);
      renderContent();
    });

    return root;
  }

  function injectStyles() {
    if (document.getElementById(STYLE_ID)) {
      return;
    }

    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = `
      #${ROOT_ID} {
        --hel-surface: #ffffff;
        --hel-surface-elevated: #f7f8f7;
        --hel-text: #181a19;
        --hel-text-muted: #5e625f;
        --hel-rule: #c9cdca;
        --hel-media: #e3e6e4;
        --hel-hover-surface: #eceeed;
        --hel-overlay: rgb(24 26 25 / 48%);
        --hel-synopsis: #000000;
        box-sizing: border-box;
        width: 100%;
        max-width: 1240px;
        margin: 0 auto;
        padding: 30px 28px 64px;
        color: var(--hel-text);
        background: var(--hel-surface);
        font-family: "Source Sans VF", system-ui, -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
      }

      #${ROOT_ID}[data-hel-theme="light"] {
        --hel-surface: #ffffff;
        --hel-surface-elevated: #f7f8f7;
        --hel-text: #181a19;
        --hel-text-muted: #5e625f;
        --hel-rule: #c9cdca;
        --hel-media: #e3e6e4;
        --hel-hover-surface: #eceeed;
        --hel-overlay: rgb(24 26 25 / 48%);
        --hel-synopsis: #000000;
      }

      #${ROOT_ID}[data-hel-theme="dark"] {
        --hel-surface: #323232;
        --hel-surface-elevated: #1a1f23;
        --hel-text: #edf1f4;
        --hel-text-muted: #a8b0b7;
        --hel-rule: #3a434b;
        --hel-media: #2a3138;
        --hel-hover-surface: #252c33;
        --hel-overlay: rgb(0 0 0 / 62%);
        --hel-synopsis: #dce2e7;
      }

      #${ROOT_ID} *,
      #${ROOT_ID} *::before,
      #${ROOT_ID} *::after {
        box-sizing: border-box;
      }

      #${ROOT_ID} .hel-toolbar {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 0;
        margin: -20px 0 10px;
      }

      #${ROOT_ID} .hel-view-switcher {
        display: inline-flex;
        align-items: stretch;
      }

      #${ROOT_ID} .hel-view-button {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 3px;
        min-height: 36px;
        margin-right: 7px;
        padding: 0;
        border: 0;
        border-bottom: 3px solid transparent;
        border-radius: 0;
        color: var(--hel-text);
        background: transparent;
        font: 700 13px/1 Avenir, "Avenir Next", "Helvetica Neue", sans-serif;
        letter-spacing: 0;
        cursor: pointer;
      }

      #${ROOT_ID} .hel-view-button[aria-pressed="true"] {
        color: var(--brand-branding);
        border-bottom-color: var(--brand-branding);
      }

      #${ROOT_ID}[data-hel-theme="dark"] .hel-view-button[aria-pressed="true"] {
        color: #ffffff;
        border-bottom-color: #ffffff;
      }

      #${ROOT_ID} .hel-view-button:hover:not([aria-pressed="true"]) {
        color: var(--brand-branding);
      }

      #${ROOT_ID}[data-hel-theme="dark"] .hel-view-button:hover:not([aria-pressed="true"]) {
        color: #ffffff;
      }

      #${ROOT_ID} .hel-settings-button {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 7px;
        min-height: 36px;
        padding: 0 10px;
        border: 0;
        border-radius: 0;
        color: var(--hel-text-muted);
        background: transparent;
        font: 700 13px/1 Avenir, "Avenir Next", "Helvetica Neue", sans-serif;
        letter-spacing: 0;
        cursor: pointer;
      }

      #${ROOT_ID} .hel-settings-button:hover,
      #${ROOT_ID} .hel-settings-button[aria-expanded="true"] {
        color: var(--hel-text);
        background: var(--hel-hover-surface);
      }

      #${ROOT_ID} .hel-settings {
        position: relative;
        align-self: stretch;
        display: flex;
        align-items: center;
      }

      #${ROOT_ID} .hel-branding-menu {
        position: fixed;
        z-index: 1001;
        top: 50%;
        left: 50%;
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        width: min(560px, calc(100vw - 32px));
        max-height: calc(100vh - 32px);
        gap: 8px 18px;
        margin: 0;
        padding: 24px;
        overflow-y: auto;
        border: 0;
        border-radius: 0;
        color: var(--hel-text);
        background: var(--hel-surface-elevated);
        box-shadow: 0 18px 48px rgb(24 26 25 / 28%);
        transform: translate(-50%, -50%);
      }

      #${ROOT_ID} .hel-branding-menu[hidden] {
        display: none;
      }

      #${ROOT_ID} .hel-settings-backdrop {
        position: fixed;
        z-index: 1000;
        inset: 0;
        background: var(--hel-overlay);
      }

      #${ROOT_ID} .hel-settings-backdrop[hidden] {
        display: none;
      }

      #${ROOT_ID} .hel-branding-menu-title {
        grid-column: 1 / -1;
        margin: 0 0 4px;
        padding: 0;
        font: 700 13px/1.2 Avenir, "Avenir Next", "Helvetica Neue", sans-serif;
        letter-spacing: 0;
      }

      #${ROOT_ID} .hel-branding-column {
        display: grid;
        align-content: start;
        gap: 8px;
        min-width: 0;
      }

      #${ROOT_ID} .hel-settings-close {
        position: absolute;
        top: 12px;
        right: 12px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 32px;
        height: 32px;
        padding: 0;
        border: 0;
        border-radius: 0;
        color: var(--hel-text-muted);
        background: transparent;
        cursor: pointer;
      }

      #${ROOT_ID} .hel-settings-close:hover {
        color: var(--hel-text);
        background: var(--hel-hover-surface);
      }

      #${ROOT_ID} .hel-branding-option {
        display: flex;
        align-items: center;
        gap: 9px;
        min-height: 34px;
        padding: 6px 0;
        border: 0;
        color: var(--hel-text);
        background: transparent;
        font-size: 13px;
        font-weight: 700;
        line-height: 1.2;
        cursor: pointer;
      }

      #${ROOT_ID} .hel-branding-option:has(input:checked) {
        color: var(--hel-text);
      }

      #${ROOT_ID} .hel-branding-option input {
        width: 16px;
        height: 16px;
        margin: 0;
        accent-color: var(--brand-branding);
      }

      #${ROOT_ID} .hel-view-button:focus-visible,
      #${ROOT_ID} .hel-settings-button:focus-visible,
      #${ROOT_ID} .hel-settings-close:focus-visible,
      #${ROOT_ID} .hel-branding-option:has(input:focus-visible),
      #${ROOT_ID} a:focus-visible {
        outline: 3px solid var(--brand-branding);
        outline-offset: 3px;
      }

      #${ROOT_ID} .hel-content {
        min-width: 0;
      }

      #${ROOT_ID} .hel-featured-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 30px;
        margin-bottom: 50px;
      }

      #${ROOT_ID} .hel-featured-grid[data-count="1"] {
        grid-template-columns: minmax(0, 1fr);
      }

      #${ROOT_ID} .hel-main-grid {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 40px 26px;
      }

      #${ROOT_ID} .hel-grid-continuation {
        margin-top: 44px;
      }

      #${ROOT_ID} .hel-card {
        min-width: 0;
        margin: 0;
      }

      #${ROOT_ID} .hel-media-link {
        display: block;
        width: 100%;
        overflow: hidden;
        aspect-ratio: 16 / 10;
        border-bottom: 4px solid var(--brand-branding);
        background: var(--hel-media);
      }

      #${ROOT_ID} .hel-featured-card .hel-media-link {
        aspect-ratio: 16 / 9;
        border-bottom-width: 6px;
      }

      #${ROOT_ID} .hel-image,
      #${ROOT_ID} .hel-image img {
        display: block;
        width: 100%;
        height: 100%;
        object-fit: cover;
      }

      #${ROOT_ID} .hel-image img {
        transition: transform 180ms ease;
      }

      #${ROOT_ID} .hel-media-link:hover .hel-image img {
        transform: scale(1.015);
      }

      #${ROOT_ID} .hel-card-content {
        min-width: 0;
        padding-top: 14px;
      }

      #${ROOT_ID} .hel-headline {
        margin: 0;
        color: var(--hel-text);
        font-size: 20px;
        font-weight: 600;
        line-height: 27.5px;
        overflow-wrap: anywhere;
      }

      #${ROOT_ID} .hel-featured-card .hel-headline {
        font-size: 20px;
        line-height: 27.5px;
      }

      #${ROOT_ID} .hel-headline a {
        color: inherit;
        text-decoration: none;
      }

      #${ROOT_ID} .hel-paywall-icon {
        display: inline-block;
        width: auto;
        height: 16px;
        margin-right: 0.3em;
        vertical-align: middle;
        transform: translateY(-0.1em);
      }

      #${ROOT_ID} .hel-headline a:hover {
        color: var(--brand-branding);
        text-decoration: underline;
        text-decoration-thickness: 2px;
        text-underline-offset: 3px;
      }

      #${ROOT_ID}[data-hel-theme="dark"] .hel-headline a:hover {
        color: #ffffff;
      }

      #${ROOT_ID} .hel-synopsis {
        margin: 11px 0 0;
        color: var(--hel-synopsis);
        font-size: 16px;
        font-weight: 400;
        line-height: 22px;
        overflow-wrap: anywhere;
      }

      #${ROOT_ID} .hel-featured-card .hel-synopsis {
        max-width: 62ch;
      }

      #${ROOT_ID} .hel-meta {
        display: flex;
        align-items: center;
        white-space: nowrap;
        min-height: 1.4em;
        margin-top: 13px;
        color: var(--hel-text-muted);
        font-size: 14px;
        font-weight: 400;
        line-height: 14px;
      }

      #${ROOT_ID} .hel-meta > [aria-hidden="true"],
      #${ROOT_ID} .hel-meta-time[datetime=""] {
        display: none;
      }

      #${ROOT_ID} .hel-meta > :not([aria-hidden="true"]):not(.hel-meta-time[datetime=""])
        ~ :not([aria-hidden="true"]):not(.hel-meta-time[datetime=""]) {
        margin-left: 12px;
      }

      #${ROOT_ID} .hel-meta-comments {
        display: inline-flex;
        align-items: center;
        gap: 4px;
      }

      #${ROOT_ID} .hel-meta-comments img {
        display: block;
        width: 14px;
        height: 14px;
      }

      #${ROOT_ID} .hel-meta-branding {
        overflow: hidden;
        text-overflow: ellipsis;
      }

      #${ROOT_ID} .hel-empty {
        margin: 48px 0;
        color: var(--hel-text-muted);
        font-family: Charter, "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
        font-size: 22px;
      }

      #${ROOT_ID} .hel-grid-card {
        padding-top: 10px;
      }

      #${ROOT_ID} .hel-list {
      }

      #${ROOT_ID} .hel-list-card {
        display: grid;
        grid-template-columns: 260px minmax(0, 1fr);
        gap: 24px;
        padding: 22px 0;
        border-bottom: 1px solid var(--hel-rule);
      }

      #${ROOT_ID} .hel-list-card .hel-media-link {
        align-self: start;
        aspect-ratio: 16 / 10;
        border-bottom-width: 4px;
      }

      #${ROOT_ID} .hel-list-card .hel-card-content {
        padding-top: 0;
      }

      #${ROOT_ID} .hel-list-card .hel-headline {
        font-size: 20px;
      }

      #${ROOT_ID}[data-view="list"] .hel-list {
        display: grid;
        gap: 20px;
      }

      #${ROOT_ID}[data-view="list"] .hel-list-card {
        grid-template-columns: 96px minmax(0, 1fr);
        gap: 20px;
        height: 96px;
        padding: 0;
        border-bottom: 0;
        overflow: hidden;
      }

      #${ROOT_ID}[data-view="list"] .hel-list-card .hel-media-link {
        width: 96px;
        height: 96px;
        aspect-ratio: 1;
        border-bottom: 0;
      }

      #${ROOT_ID}[data-view="list"] .hel-list-card .hel-media-link .hel-image {
        width: 144px;
        height: 144px;
        transform: scale(0.666667);
        transform-origin: top left;
      }

      #${ROOT_ID}[data-view="list"] .hel-list-card .hel-card-content {
        display: flex;
        flex-direction: column;
        height: 96px;
        overflow: hidden;
      }

      #${ROOT_ID}[data-view="list"] .hel-list-card .hel-headline {
        display: -webkit-box;
        overflow: hidden;
        font-size: 20px;
        line-height: 27.5px;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
      }

      #${ROOT_ID}[data-view="list"] .hel-list-card .hel-synopsis {
        overflow: hidden;
        margin-top: 4px;
        line-height: 22px;
        text-overflow: ellipsis;
      }

      @media (max-width: 1099px) {
        #${ROOT_ID} .hel-main-grid {
          grid-template-columns: repeat(2, minmax(0, 1fr));
        }

      }

      @media (max-width: 699px) {
        #${ROOT_ID} {
          padding: 20px 16px 44px;
        }

        #${ROOT_ID} .hel-toolbar {
          align-items: stretch;
        }

        #${ROOT_ID} .hel-view-button {
          min-width: 68px;
          padding: 0 8px;
        }

        #${ROOT_ID} .hel-settings-button {
          padding: 0 8px;
        }

        #${ROOT_ID} .hel-settings-button span {
          display: none;
        }

        #${ROOT_ID} .hel-featured-grid,
        #${ROOT_ID} .hel-main-grid {
          grid-template-columns: minmax(0, 1fr);
        }

        #${ROOT_ID} .hel-featured-grid {
          gap: 34px;
          margin-bottom: 42px;
        }

        #${ROOT_ID} .hel-main-grid {
          gap: 34px;
        }

        #${ROOT_ID} .hel-list-card {
          grid-template-columns: minmax(0, 1fr);
          gap: 14px;
          padding: 22px 0 26px;
        }

        #${ROOT_ID}[data-view="list"] .hel-list-card {
          grid-template-columns: 64px minmax(0, 1fr);
          gap: 12px;
          height: auto;
          min-height: 64px;
          padding: 0;
        }

        #${ROOT_ID}[data-view="list"] .hel-list-card .hel-media-link {
          width: 64px;
          height: 64px;
        }

        #${ROOT_ID}[data-view="list"] .hel-list-card .hel-media-link .hel-image {
          width: 96px;
          height: 96px;
        }

        #${ROOT_ID}[data-view="list"] .hel-list-card .hel-card-content {
          height: auto;
          min-height: 64px;
        }

        #${ROOT_ID} .hel-meta {
          overflow: hidden;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        #${ROOT_ID} .hel-image img {
          transition: none;
        }
      }
    `;
    document.head.append(style);
  }

  function cleanupInitialization() {
    observer?.disconnect();
    observer = null;

    if (timeoutId !== null) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }

    if (retryFrameId !== null) {
      cancelAnimationFrame(retryFrameId);
      retryFrameId = null;
    }
  }

  function attemptMount() {
    if (mounted || document.getElementById(ROOT_ID)) {
      mounted = true;
      cleanupInitialization();
      return true;
    }

    const target = document.querySelector(TARGET_SELECTOR);
    const featuredSection = document.querySelector(FEATURED_SELECTOR);
    const mainSections = Array.from(document.querySelectorAll(MAIN_SELECTOR));

    if (!target || !featuredSection || mainSections.length === 0) {
      const missing = [
        !target && 'target container',
        !featuredSection && 'featured section',
        mainSections.length === 0 && 'main sections',
      ].filter(Boolean);
      lastFailure = `missing ${missing.join(', ')}`;
      return false;
    }

    if (!target.contains(featuredSection) ||
        mainSections.some((section) => !target.contains(section))) {
      lastFailure = 'selected source sections were outside the target container';
      return false;
    }

    const featuredTeasers = extractCards(featuredSection).slice(0, 2);
    const mainTeasers = mainSections.flatMap((section) =>
      extractCards(section, MAIN_LIST_CARD_SELECTOR)
    );

    if (featuredTeasers.length === 0 || mainTeasers.length === 0) {
      lastFailure = `invalid source data (featured: ${featuredTeasers.length}, main: ${mainTeasers.length})`;
      return false;
    }

    try {
      const root = buildLayout(featuredTeasers, mainTeasers);
      injectStyles();
      target.replaceChildren(root);
      setupThemeSync(root);
      mounted = true;
      cleanupInitialization();
      return true;
    } catch (error) {
      lastFailure = `rendering failed: ${error instanceof Error ? error.message : String(error)}`;
      return false;
    }
  }

  function scheduleRetry() {
    if (mounted || retryFrameId !== null) {
      return;
    }

    retryFrameId = requestAnimationFrame(() => {
      retryFrameId = null;
      attemptMount();
    });
  }

  if (attemptMount()) {
    return;
  }

  observer = new MutationObserver(scheduleRetry);
  observer.observe(document.body, { childList: true, subtree: true });
  timeoutId = setTimeout(() => {
    if (mounted) {
      return;
    }

    cleanupInitialization();
    console.warn(`[Heise Editorial Layout] Mount aborted after 5 seconds: ${lastFailure}.`);
  }, OBSERVER_TIMEOUT_MS);
})();