/ Examples
estimatedHeightasync
Source
// Social Feed Example
// Demonstrates variable-size items with three independent axes:
//   Source: Reddit (live feed via /api/feed) | RSS (any feed URL)
//   Layout: Card (social-card style) | Compact (title-dominant, thumbnail right)
//   Mode:   A (pre-measure all items at init) | B (estimatedSize + ResizeObserver)
//
// Any combination works — you can pre-measure Reddit posts or auto-size RSS items.

import { vlist } from "vlist";
import { createStats } from "../stats.js";

// =============================================================================
// Reddit feed — fetcher + state
// =============================================================================

// Preload-ahead: fetch pages before the user reaches the boundary
const PRELOAD_THRESHOLD = 15; // trigger fetch when within N items of the end
const INITIAL_PAGES = 3; // pages to fetch on init (75 posts)
const INITIAL_PAGE_DELAY = 1500; // ms between initial burst requests
const MIN_FETCH_INTERVAL = 2000; // minimum ms between requests

// =============================================================================
// Preferences — persist source, mode, subreddit, RSS feed in localStorage
// =============================================================================

const STORAGE_KEY = "vlist-social-feed";

const loadPrefs = () => {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
  } catch {
    return {};
  }
};

const savePrefs = (patch) => {
  try {
    const prefs = { ...loadPrefs(), ...patch };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
  } catch {
    // localStorage unavailable — ignore
  }
};

const feedState = {
  posts: [],
  nextCursor: null,
  subreddit: "worldnews",
  loading: false,
  endOfFeed: false,
  lastFetchTime: 0,
};

const rssState = {
  posts: [],
  feedUrl: "https://feeds.bbci.co.uk/news/world/rss.xml",
  loading: false,
};

/**
 * Fetch a page of posts from /api/feed (proxied through the Bun server).
 * @param {string} subreddit
 * @param {string|null} after  pagination cursor (null = first page)
 * @returns {Promise<{posts: object[], nextCursor: string|null}>}
 */
const loadFeedPage = async (subreddit, after = null) => {
  const url = new URL("/api/feed", location.origin);
  url.searchParams.set("source", "reddit");
  url.searchParams.set("target", subreddit);
  url.searchParams.set("limit", "25");
  if (after) url.searchParams.set("after", after);

  const res = await fetch(url);
  if (!res.ok) throw new Error(`Feed API error ${res.status}`);
  return res.json();
};

const loadRssFeed = async (feedUrl) => {
  const url = new URL("/api/feed", location.origin);
  url.searchParams.set("source", "rss");
  url.searchParams.set("target", feedUrl);
  url.searchParams.set("limit", "50");

  const res = await fetch(url);
  if (!res.ok) throw new Error(`RSS API error ${res.status}`);
  return res.json();
};

// =============================================================================
// Helpers
// =============================================================================

/** Format large numbers like Reddit: 1234 → "1.2K", 35000 → "35K" */
const formatCount = (n) => {
  if (n >= 100_000) return `${Math.round(n / 1000)}K`;
  if (n >= 1_000) {
    const k = n / 1000;
    return k >= 10 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
  }
  return String(n);
};

// =============================================================================
// Template — renders any post (Reddit or RSS)
// =============================================================================

/** Image slot for measurement — same container, no <img> tag */
const renderImageSlot = (image) => {
  if (!image) return "";
  return `<div class="post__image post__image--real"></div>`;
};

/** Thumbnail slot for measurement — compact layout */
const renderThumbnailSlot = (image) => {
  if (!image) return "";
  return `<div class="rpost__thumb"></div>`;
};

const renderImageReal = (image) => {
  if (!image) return "";
  if (image.url) {
    return `
      <div class="post__image post__image--real">
        <img src="${image.url}" alt="${image.alt ?? ""}" loading="lazy" onerror="this.parentElement.style.display='none'">
      </div>
    `;
  }
  return "";
};

/**
 * Light markdown → HTML for body text.
 * Converts [text](url) links and bare URLs to clickable <a> tags.
 */
const markdownToHtml = (text) => {
  // 1. Convert [text](url) markdown links
  let html = text.replace(
    /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
    '<a href="$2" target="_blank" rel="noopener">$1</a>',
  );
  // 2. Convert remaining bare URLs (not already inside an href="...")
  html = html.replace(
    /(?<!href="|">)(https?:\/\/[^\s<]+)/g,
    '<a href="$1" target="_blank" rel="noopener">$1</a>',
  );
  return html;
};

/** End-of-feed sentinel renderer */
const renderEndOfFeed = (post) => `
  <div class="post-end">
    <div class="post-end__line"></div>
    <span class="post-end__label">End of feed · ${post.count} items</span>
    <div class="post-end__line"></div>
  </div>
`;

/** Card post renderer — social-card style (default) */
const renderCardPost = (post, { measure = false } = {}) => {
  if (post._endOfFeed) return renderEndOfFeed(post);

  const titleHtml = post.title
    ? `<div class="post__title">${post.title}</div>`
    : "";

  const imageHtml = measure
    ? renderImageSlot(post.image)
    : renderImageReal(post.image);

  const openAction = post.url
    ? `<a class="post__action post__action--link" href="${post.url}" target="_blank" rel="noopener">↗ Open</a>`
    : "";

  const tagsHtml =
    post.tags.length > 0
      ? `<div class="post__tags">${post.tags.map((t) => `<span class="post__tag">${t}</span>`).join(" ")}</div>`
      : "";

  const likesHtml =
    post.likes > 0
      ? `<button class="post__action">♡ ${post.likes}</button>`
      : "";
  const commentsHtml =
    post.comments > 0
      ? `<button class="post__action">💬 ${post.comments}</button>`
      : "";

  return `
    <article class="post${post.source ? ` post--${post.source}` : ""}">
      <div class="post__header">
        <div class="post__avatar" style="background:${post.color}">${post.initials}</div>
        <div class="post__meta">
          <span class="post__user">${post.user}</span>
          <span class="post__time">${post.time}</span>
        </div>
      </div>
      ${titleHtml}
      ${imageHtml}
      ${post.text ? `<div class="post__body">${markdownToHtml(post.text)}</div>` : ""}
      ${tagsHtml}
      <div class="post__actions">
        ${likesHtml}
        ${commentsHtml}
        ${openAction}
      </div>
    </article>
  `;
};

/** Reddit-native post renderer — title-dominant, thumbnail right, vote pills */
const renderRedditPost = (post, { measure = false } = {}) => {
  if (post._endOfFeed) return renderEndOfFeed(post);

  const thumbHtml = measure
    ? renderThumbnailSlot(post.image)
    : post.image?.url
      ? `<a class="rpost__thumb" href="${post.url || "#"}" target="_blank" rel="noopener">
          <img src="${post.image.url}" alt="${post.image.alt ?? ""}" loading="lazy" onerror="this.parentElement.classList.add('rpost__thumb--broken')">
        </a>`
      : "";

  const titleHtml = post.title
    ? `<h3 class="rpost__title">${post.title}</h3>`
    : "";

  const linkUrl = post.url || "";
  const linkHtml = linkUrl ? `<span class="rpost__link">${linkUrl}</span>` : "";

  const openHtml = post.url
    ? `<a class="rpost__more" href="${post.url}" target="_blank" rel="noopener" title="Open">···</a>`
    : "";

  return `
    <article class="rpost">
      <div class="rpost__header">
        <div class="rpost__avatar" style="background:${post.color}">${post.initials}</div>
        <span class="rpost__user">u/${post.user}</span>
        <span class="rpost__dot">·</span>
        <span class="rpost__time">${post.time}</span>
        ${openHtml}
      </div>
      <div class="rpost__body">
        <div class="rpost__content">
          ${titleHtml}
          ${linkHtml}
          ${post.text && !post.title ? `<div class="rpost__text">${markdownToHtml(post.text)}</div>` : ""}
        </div>
        ${thumbHtml}
      </div>
      <div class="rpost__actions">
        <span class="rpost__pill">
          <span class="rpost__vote rpost__vote--up">⬆</span>
          <span class="rpost__count">${post.likes > 0 ? formatCount(post.likes) : "Vote"}</span>
          <span class="rpost__vote rpost__vote--down">⬇</span>
        </span>
        <span class="rpost__pill">
          <span class="rpost__pill-icon">💬</span>
          <span class="rpost__count">${post.comments > 0 ? formatCount(post.comments) : "0"}</span>
        </span>

      </div>
    </article>
  `;
};

/** Dispatch to the right renderer based on current layout */
const renderPost = (post, opts) =>
  currentLayout === "compact"
    ? renderRedditPost(post, opts)
    : renderCardPost(post, opts);

// =============================================================================
// Mode A — Pre-measure all items via hidden DOM element
// =============================================================================

/**
 * Measure the actual rendered size of every item by inserting its HTML
 * into a hidden element that matches the list's inner width.
 *
 * We cache by a content key (text + hasImage + aspect) so items with
 * identical templates share a single measurement.
 */
const measureSizes = (items, container) => {
  const measurer = document.createElement("div");
  measurer.style.cssText =
    "position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;" +
    `width:${container.offsetWidth}px;`;
  document.body.appendChild(measurer);

  const cache = new Map();
  let uniqueCount = 0;

  for (const item of items) {
    const key =
      (item.title ?? "") + item.text + (item.image ? item.image.aspect : "");

    if (cache.has(key)) {
      item.size = cache.get(key);
      continue;
    }

    measurer.innerHTML = renderPost(item, { measure: true });
    const measured = measurer.firstElementChild.offsetHeight;
    item.size = measured;
    cache.set(key, measured);
    uniqueCount++;
  }

  measurer.remove();
  return uniqueCount;
};

// =============================================================================
// Constants
// =============================================================================

const ESTIMATED_SIZE = 160;
const ESTIMATED_SIZE_COMPACT = 120;

// =============================================================================
// DOM references
// =============================================================================

// Source panel
const sourceToggleEl = document.getElementById("feed-source");
const sectionRedditEl = document.getElementById("section-reddit");
const sectionRssEl = document.getElementById("section-rss");
const feedSubredditEl = document.getElementById("feed-subreddit");
const layoutToggleEl = document.getElementById("layout-toggle");
const infoFeedStatusEl = document.getElementById("info-feed-status");
const infoFeedCountEl = document.getElementById("info-feed-count");
const feedRssEl = document.getElementById("feed-rss");
const infoRssStatusEl = document.getElementById("info-rss-status");
const infoRssCountEl = document.getElementById("info-rss-count");

// Mode panel
const modeToggleEl = document.getElementById("mode-toggle");
const sectionMeasurementEl = document.getElementById("section-measurement");

// Measurement info
const infoStrategyEl = document.getElementById("info-strategy");
const infoInitEl = document.getElementById("info-init");
const infoUniqueEl = document.getElementById("info-unique");

// List + badge + footer + feed name
const feedNameEl = document.getElementById("feed-name");
const containerEl = document.getElementById("list-container");
const modeBadgeEl = document.getElementById("mode-badge");
const ftModeEl = document.getElementById("ft-mode");
const ftSourceEl = document.getElementById("ft-source");

// =============================================================================
// Stats (footer left — progress, velocity, items)
// =============================================================================

const stats = createStats({
  getList: () => list,
  getTotal: () => getActiveItems().length,
  getItemHeight: () => ESTIMATED_SIZE,
  container: "#list-container",
});

// =============================================================================
// State — two independent axes
// =============================================================================

const prefs = loadPrefs();
let currentSource = prefs.source === "rss" ? "rss" : "reddit";
let currentMode = prefs.mode || "a";
let currentLayout = prefs.layout || "card";
let list = null;

/** Return the items array for the active source */
const getActiveItems = () =>
  currentSource === "reddit" ? feedState.posts : rssState.posts;

// =============================================================================
// Create / recreate list — called when source OR mode changes
// =============================================================================

function createList() {
  // Destroy previous
  if (list) {
    list.destroy();
    list = null;
  }
  containerEl.innerHTML = "";

  // Apply layout class to container
  containerEl.classList.toggle("layout--compact", currentLayout === "compact");

  const items = getActiveItems();
  const ariaLabel =
    currentSource === "reddit" ? "Live Reddit feed" : "RSS feed";

  const isCompact = currentLayout === "compact";
  const estimatedSize = isCompact ? ESTIMATED_SIZE_COMPACT : ESTIMATED_SIZE;

  let initTime = 0;
  let uniqueSizes = 0;

  if (currentMode === "a") {
    // Mode A: pre-measure all items, then use size function
    const start = performance.now();
    if (items.length > 0) {
      uniqueSizes = measureSizes(items, containerEl);
    }
    initTime = performance.now() - start;

    list = vlist({
      container: containerEl,
      ariaLabel,
      items,
      item: {
        height: (index) => getActiveItems()[index]?.size ?? estimatedSize,
        template: renderPost,
      },
    }).build();
  } else {
    // Mode B: estimated size, auto-measured by ResizeObserver
    const start = performance.now();

    list = vlist({
      container: containerEl,
      ariaLabel,
      items,
      item: {
        estimatedHeight: estimatedSize,
        template: renderPost,
      },
    }).build();

    initTime = performance.now() - start;
  }

  // Wire up events
  list.on("scroll", stats.scheduleUpdate);
  list.on("range:change", ({ range }) => {
    stats.scheduleUpdate();
    preloadIfNeeded(range.end);
  });
  list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));

  list.on("item:click", ({ item, event }) => {
    // Only open URL if the click was on the "Open" link itself
    const target = event?.target;
    if (target && target.closest && target.closest(".post__action--link"))
      return;
    console.log(
      `Clicked post by ${item.user}: "${(item.title || item.text || "").slice(0, 50)}…"`,
    );
  });

  // If no posts loaded yet, trigger initial fetch
  if (currentSource === "reddit" && feedState.posts.length === 0) {
    loadInitialPages();
  }
  if (currentSource === "rss" && rssState.posts.length === 0) {
    loadRssItems();
  }

  stats.update();
  updatePanelInfo(initTime, uniqueSizes);
}

// =============================================================================
// Reddit feed loading
// =============================================================================

async function loadNextFeedPage({ skipRateLimit = false } = {}) {
  if (feedState.loading || feedState.endOfFeed) return;

  // Rate-limit: don't fetch faster than MIN_FETCH_INTERVAL
  if (!skipRateLimit) {
    const now = Date.now();
    const elapsed = now - feedState.lastFetchTime;
    if (elapsed < MIN_FETCH_INTERVAL) {
      setTimeout(() => loadNextFeedPage(), MIN_FETCH_INTERVAL - elapsed);
      return;
    }
  }

  feedState.loading = true;
  feedState.lastFetchTime = Date.now();
  setFeedStatus("loading…");

  try {
    const data = await loadFeedPage(feedState.subreddit, feedState.nextCursor);

    feedState.posts.push(...data.posts);
    feedState.nextCursor = data.nextCursor;

    if (!data.nextCursor) {
      feedState.endOfFeed = true;
      // Append end-of-feed sentinel
      feedState.posts.push({
        id: "__end__",
        _endOfFeed: true,
        count: feedState.posts.length,
        tags: [],
        size: 0,
      });
    }

    // Update the live vlist with the new items
    if (list && currentSource === "reddit") {
      // If Mode A, measure BEFORE setItems so heights are available
      let uniqueSizes = 0;
      if (currentMode === "a") {
        uniqueSizes = measureSizes(data.posts, containerEl);
      }

      list.setItems(feedState.posts);
      stats.update();
      updatePanelInfo(0, uniqueSizes);
    }

    setFeedStatus(feedState.endOfFeed ? "end of feed" : "ready");
    infoFeedCountEl.textContent = String(feedState.posts.length);
  } catch (err) {
    console.error("Feed fetch failed:", err);
    setFeedStatus(`error: ${err.message}`);
  } finally {
    feedState.loading = false;
  }
}

/**
 * Preload-ahead: called on range:change to auto-fetch more posts
 * when the user scrolls near the end of loaded data.
 */
function preloadIfNeeded(rangeEnd) {
  if (currentSource !== "reddit") return;
  if (feedState.endOfFeed || feedState.loading) return;

  const remaining = feedState.posts.length - rangeEnd;
  if (remaining <= PRELOAD_THRESHOLD) {
    loadNextFeedPage();
  }
}

/**
 * Load multiple pages sequentially on init for a comfortable buffer.
 * Stops early if end-of-feed is reached.
 */
async function loadInitialPages() {
  for (let i = 0; i < INITIAL_PAGES; i++) {
    if (feedState.endOfFeed) break;
    await loadNextFeedPage({ skipRateLimit: true });
    // Delay between pages to avoid Reddit rate-limiting
    if (i < INITIAL_PAGES - 1 && !feedState.endOfFeed) {
      await new Promise((r) => setTimeout(r, INITIAL_PAGE_DELAY));
    }
  }
}

/** @param {string} msg */
function setFeedStatus(msg) {
  if (infoFeedStatusEl) infoFeedStatusEl.textContent = msg;
}

// =============================================================================
// RSS feed loading
// =============================================================================

async function loadRssItems() {
  if (rssState.loading) return;

  rssState.loading = true;
  setRssStatus("loading…");

  try {
    const data = await loadRssFeed(rssState.feedUrl);

    // Append end-of-feed sentinel
    rssState.posts = [
      ...data.posts,
      {
        id: "__end__",
        _endOfFeed: true,
        count: data.posts.length,
        tags: [],
        size: 0,
      },
    ];

    if (list && currentSource === "rss") {
      // If Mode A, measure BEFORE setItems so heights are available
      let uniqueSizes = 0;
      if (currentMode === "a") {
        uniqueSizes = measureSizes(rssState.posts, containerEl);
      }

      list.setItems(rssState.posts);
      stats.update();
      updatePanelInfo(0, uniqueSizes);
    }

    setRssStatus("ready");
    infoRssCountEl.textContent = String(rssState.posts.length);
  } catch (err) {
    console.error("RSS fetch failed:", err);
    setRssStatus(`error: ${err.message}`);
  } finally {
    rssState.loading = false;
  }
}

/** @param {string} msg */
function setRssStatus(msg) {
  if (infoRssStatusEl) infoRssStatusEl.textContent = msg;
}

// =============================================================================
// Panel info — update badge, footer, measurement section
// =============================================================================

function updateFeedName() {
  if (!feedNameEl) return;
  if (currentSource === "reddit") {
    const sub = feedSubredditEl.value;
    feedNameEl.textContent = `r/${sub}`;
  } else {
    const selected = feedRssEl.options[feedRssEl.selectedIndex];
    feedNameEl.textContent = selected ? selected.textContent.trim() : "RSS";
  }
}

function updatePanelInfo(initTime, uniqueSizes) {
  const modeLabel = currentMode === "a" ? "Mode A" : "Mode B";
  const sourceLabel = currentSource === "reddit" ? "Reddit" : "RSS";

  modeBadgeEl.textContent = modeLabel;
  ftModeEl.textContent = modeLabel;
  ftSourceEl.textContent = sourceLabel;

  infoStrategyEl.textContent =
    currentMode === "a" ? "size: (index) => px" : "estimatedSize";

  infoInitEl.textContent = `${initTime.toFixed(0)}ms`;
  infoUniqueEl.textContent = currentMode === "a" ? String(uniqueSizes) : "–";
}

// =============================================================================
// Panel visibility — show/hide source-specific sections
// =============================================================================

function updateSourceSections() {
  sectionRedditEl.classList.toggle(
    "panel-section--hidden",
    currentSource !== "reddit",
  );
  sectionRssEl.classList.toggle(
    "panel-section--hidden",
    currentSource !== "rss",
  );
}

function updateLayoutToggle() {
  if (!layoutToggleEl) return;
  layoutToggleEl.querySelectorAll("button").forEach((b) => {
    b.classList.toggle(
      "panel-segmented__btn--active",
      b.dataset.layout === currentLayout,
    );
  });
}

// =============================================================================
// Source toggle
// =============================================================================

sourceToggleEl.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-source]");
  if (!btn || btn.disabled) return;

  const source = btn.dataset.source;
  if (source === currentSource) return;

  currentSource = source;
  savePrefs({ source });

  // Update active button
  sourceToggleEl.querySelectorAll("button").forEach((b) => {
    b.classList.toggle(
      "panel-segmented__btn--active",
      b.dataset.source === source,
    );
  });

  // Ensure state is ready for the selected source
  if (source === "reddit" && feedState.posts.length === 0) {
    feedState.subreddit = feedSubredditEl.value;
  }
  if (source === "rss" && rssState.posts.length === 0) {
    rssState.feedUrl = feedRssEl.value;
  }

  updateSourceSections();
  updateFeedName();
  createList();
});

// =============================================================================
// Layout toggle
// =============================================================================

layoutToggleEl.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-layout]");
  if (!btn) return;

  const layout = btn.dataset.layout;
  if (layout === currentLayout) return;

  currentLayout = layout;
  savePrefs({ layout });

  updateLayoutToggle();

  // Clear cached sizes — layout changes dimensions
  for (const item of getActiveItems()) {
    item.size = 0;
  }

  createList();
});

// =============================================================================
// Mode toggle
// =============================================================================

modeToggleEl.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-mode]");
  if (!btn) return;

  const mode = btn.dataset.mode;
  if (mode === currentMode) return;

  currentMode = mode;
  savePrefs({ mode });

  // Update active button
  modeToggleEl.querySelectorAll("button").forEach((b) => {
    b.classList.toggle("panel-segmented__btn--active", b.dataset.mode === mode);
  });

  createList();
});

// =============================================================================
// Reddit controls — subreddit picker
// =============================================================================

feedSubredditEl.addEventListener("change", () => {
  if (currentSource !== "reddit") return;

  // Reset feed for the new subreddit
  feedState.posts = [];
  feedState.nextCursor = null;
  feedState.endOfFeed = false;
  feedState.lastFetchTime = 0;
  feedState.subreddit = feedSubredditEl.value;
  savePrefs({ subreddit: feedSubredditEl.value });
  infoFeedCountEl.textContent = "–";

  updateFeedName();
  createList();
});

// =============================================================================
// RSS controls — feed picker
// =============================================================================

feedRssEl.addEventListener("change", () => {
  if (currentSource !== "rss") return;

  rssState.posts = [];
  rssState.feedUrl = feedRssEl.value;
  savePrefs({ rssFeed: feedRssEl.value });
  infoRssCountEl.textContent = "–";

  updateFeedName();
  createList();
});

// =============================================================================
// Navigation controls
// =============================================================================

document.getElementById("jump-top").addEventListener("click", () => {
  list.scrollToIndex(0, { behavior: "smooth" });
});

document.getElementById("jump-middle").addEventListener("click", () => {
  const total = getActiveItems().length;
  list.scrollToIndex(Math.floor(total / 2), {
    align: "center",
    behavior: "smooth",
  });
});

document.getElementById("jump-bottom").addEventListener("click", () => {
  const total = getActiveItems().length;
  list.scrollToIndex(Math.max(0, total - 1), {
    align: "end",
    behavior: "smooth",
  });
});

document.getElementById("jump-random").addEventListener("click", () => {
  const total = getActiveItems().length;
  const idx = Math.floor(Math.random() * total);
  list.scrollToIndex(idx, { align: "center", behavior: "smooth" });
});

// =============================================================================
// Initialise — restore preferences and boot
// =============================================================================

// Restore saved selections into UI controls
if (
  prefs.subreddit &&
  feedSubredditEl.querySelector(`option[value="${prefs.subreddit}"]`)
) {
  feedSubredditEl.value = prefs.subreddit;
  feedState.subreddit = prefs.subreddit;
}
if (
  prefs.rssFeed &&
  feedRssEl.querySelector(`option[value="${prefs.rssFeed}"]`)
) {
  feedRssEl.value = prefs.rssFeed;
  rssState.feedUrl = prefs.rssFeed;
}

// Restore source toggle UI
sourceToggleEl.querySelectorAll("button").forEach((b) => {
  b.classList.toggle(
    "panel-segmented__btn--active",
    b.dataset.source === currentSource,
  );
});

// Restore mode toggle UI
modeToggleEl.querySelectorAll("button").forEach((b) => {
  b.classList.toggle(
    "panel-segmented__btn--active",
    b.dataset.mode === currentMode,
  );
});

// Restore layout toggle UI
updateLayoutToggle();

// Update badge / footer to match restored state
modeBadgeEl.textContent = currentMode === "a" ? "Mode A" : "Mode B";
ftModeEl.textContent = currentMode === "a" ? "Mode A" : "Mode B";
ftSourceEl.textContent = currentSource === "reddit" ? "Reddit" : "RSS";

updateSourceSections();
updateFeedName();
createList();
/* Social Feed Example — variable-height posts with Source + Mode controls
   Common styles (.container, h1, .description, .stats, footer)
   are provided by examples/examples.css using shell.css design tokens.
   Panel system (.split-layout, .split-panel, .panel-*)
   is also provided by example/example.css. */

/* ==========================================================================
   Mode badge
   ========================================================================== */

.mode-badge {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 12px;
    border-radius: 20px;
    font-size: 12px;
    font-weight: 600;
    letter-spacing: 0.3px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    margin-left: 8px;
    vertical-align: middle;
}

/* ==========================================================================
   List container
   ========================================================================== */

.container.social-feed {
    height: 100dvh;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
}

.container > .header {
    flex: none;
}

.split-main {
    padding: 0 !important;
}

.split-layout {
    flex: 1;
    min-height: 0;
}

.container > .example-footer {
    flex: none;
    margin-bottom: 64px;
}

.layout-feed {
    display: flex;
    flex-direction: column;
    min-height: 0;
    height: 100%;
}

#feed-name {
    flex: none;
    margin: 0;
    padding: 10px 20px;
    font-size: 16px;
    font-weight: 600;
    color: var(--vlist-text-muted);
    letter-spacing: 0.2px;
}

#list-container {
    flex: 1;
    height: 0;
    min-height: 0;
    overflow: hidden;
    background: var(--vlist-bg);
    border-radius: 6px;
}

.split-main [id$="-container"] {
    width: 100%;
    margin: 0 auto;
}

/* ==========================================================================
   vlist item overrides
   ========================================================================== */

#list-container .vlist-item {
    padding: 0;
    border-bottom: none;
    cursor: default;
    border-bottom: 1px solid var(--vlist-border);
}

/* ==========================================================================
   Post card
   ========================================================================== */

.post {
    padding: 16px 20px;
    border-bottom: 1px solid var(--vlist-border);
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.post__header {
    display: flex;
    align-items: center;
    gap: 10px;
}

.post__avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: 700;
    font-size: 12px;
    flex-shrink: 0;
    letter-spacing: -0.3px;
}

.post__meta {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-width: 0;
}

.post__user {
    font-weight: 600;
    font-size: 14px;
    color: var(--vlist-text);
}

.post__time {
    font-size: 12px;
    color: var(--vlist-text-muted);
}

/* ==========================================================================
   Post title (live posts have a separate title)
   ========================================================================== */

.post__title {
    font-size: 15px;
    font-weight: 600;
    color: var(--vlist-text);
    line-height: 1.4;
    word-break: break-word;
}

/* ==========================================================================
   Post body text
   ========================================================================== */

.post__body {
    font-size: 14px;
    color: var(--vlist-text);
    opacity: 0.88;
    line-height: 1.55;
    white-space: pre-wrap;
    word-break: break-word;
}

.post__body a {
    color: var(--accent, #667eea);
    text-decoration: none;
    font-weight: 500;
}

.post__body a:hover {
    text-decoration: underline;
    opacity: 0.9;
}

/* ==========================================================================
   Image — real <img> (live sources)
   ========================================================================== */

.post__image--real {
    aspect-ratio: 16 / 9;
    background: var(--vlist-placeholder-bg, rgba(127, 127, 127, 0.12));
    display: block;
    padding: 0;
    border-radius: 10px;
    overflow: hidden;
}

.post__image--real img {
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
    object-position: center;
}

/* ==========================================================================
   Tags
   ========================================================================== */

.post__tags {
    display: flex;
    gap: 6px;
    flex-wrap: wrap;
}

.post__tag {
    font-size: 12px;
    font-weight: 500;
    color: var(--accent, #667eea);
    opacity: 0.8;
}

/* ==========================================================================
   Action bar
   ========================================================================== */

.post__actions {
    display: flex;
    gap: 4px;
    padding-top: 2px;
}

.post__action {
    padding: 4px 12px;
    border: none;
    border-radius: 6px;
    background: transparent;
    color: var(--vlist-text-muted);
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.12s ease;
}

.post__action:hover {
    background: var(--vlist-bg-hover, rgba(127, 127, 127, 0.08));
    color: var(--vlist-text);
}

.post__action:active {
    transform: scale(0.96);
}

.post__action--link {
    text-decoration: none;
    display: inline-flex;
    align-items: center;
}

/* ==========================================================================
   Source badges — small coloured dot after the username
   ========================================================================== */

.post--reddit .post__user::after,
.post--rss .post__user::after {
    content: "";
    display: inline-block;
    width: 10px;
    height: 10px;
    margin-left: 5px;
    vertical-align: middle;
    border-radius: 50%;
    opacity: 0.7;
    flex-shrink: 0;
}

.post--reddit .post__user::after {
    background: #ff4500;
}

.post--rss .post__user::after {
    background: #ee802f;
}

/* ==========================================================================
   Compact layout (.rpost)
   Title-dominant, thumbnail on right, vote pills — denser news-style cards.
   ========================================================================== */

.rpost {
    padding: 10px 16px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    width: 100%;
}

/* --- Header: avatar · u/name · time --- */

.rpost__header {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 12px;
    color: var(--vlist-text-muted);
}

.rpost__more {
    margin-left: auto;
    font-size: 14px;
    line-height: 1;
    letter-spacing: 2px;
    color: var(--vlist-text-muted);
    text-decoration: none;
    opacity: 0.5;
    transition: opacity 0.12s ease;
    flex-shrink: 0;
    padding: 2px 4px;
    border-radius: 4px;
}

.rpost__more:hover {
    opacity: 1;
    color: var(--vlist-text);
    background: var(--vlist-bg-hover, rgba(127, 127, 127, 0.08));
}

.rpost__avatar {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: 700;
    font-size: 9px;
    flex-shrink: 0;
    letter-spacing: -0.3px;
}

.rpost__user {
    font-weight: 600;
    color: var(--vlist-text);
    font-size: 12px;
}

.rpost__dot {
    opacity: 0.4;
}

.rpost__time {
    font-size: 12px;
}

/* --- Body: content left + thumbnail right --- */

.rpost__body {
    display: flex;
    gap: 12px;
    align-items: flex-start;
}

.rpost__content {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.rpost__title {
    font-size: 17px;
    font-weight: 600;
    color: var(--vlist-text);
    line-height: 1.35;
    margin: 0;
    word-break: break-word;
}

.rpost__link {
    font-size: 12px;
    color: var(--accent, #667eea);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 100%;
    display: block;
}

.rpost__text {
    font-size: 13px;
    color: var(--vlist-text);
    opacity: 0.8;
    line-height: 1.45;
    word-break: break-word;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.rpost__text a {
    color: var(--accent, #667eea);
    text-decoration: none;
}

/* --- Thumbnail --- */

.rpost__thumb {
    width: 128px;
    height: 98px;
    flex-shrink: 0;
    border-radius: 8px;
    overflow: hidden;
    background: var(--vlist-placeholder-bg, rgba(127, 127, 127, 0.12));
}

.rpost__thumb img {
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
    object-position: center;
}

.rpost__thumb--broken img {
    display: none;
}

/* --- Action pills --- */

.rpost__actions {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    padding-top: 2px;
}

.rpost__pill {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 12px;
    border-radius: 20px;
    background: var(--vlist-bg-hover, rgba(127, 127, 127, 0.08));
    font-size: 12px;
    font-weight: 500;
    color: var(--vlist-text-muted);
    cursor: pointer;
    transition: background 0.12s ease;
    border: none;
    text-decoration: none;
    line-height: 1;
    white-space: nowrap;
}

.rpost__pill:hover {
    background: var(--vlist-border);
    color: var(--vlist-text);
}

.rpost__pill-icon {
    font-size: 13px;
    line-height: 1;
}

.rpost__vote {
    font-size: 11px;
    line-height: 1;
    opacity: 0.6;
    cursor: pointer;
    transition: opacity 0.1s ease;
}

.rpost__vote:hover {
    opacity: 1;
}

.rpost__vote--up:hover {
    color: #ff4500;
}

.rpost__vote--down:hover {
    color: #7193ff;
}

.rpost__count {
    font-size: 12px;
    font-weight: 600;
}

/* --- Compact layout: skeleton placeholders --- */

.vlist-item--placeholder .rpost__avatar {
    background-color: var(--vlist-placeholder-bg) !important;
    color: transparent;
}

.vlist-item--placeholder .rpost__user,
.vlist-item--placeholder .rpost__time {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 3px;
    min-width: 60px;
}

.vlist-item--placeholder .rpost__time {
    min-width: 40px;
}

.vlist-item--placeholder .rpost__dot {
    visibility: hidden;
}

.vlist-item--placeholder .rpost__title {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 4px;
    width: 80%;
    min-height: 1.3em;
}

.vlist-item--placeholder .rpost__link {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 3px;
    width: 40%;
    min-height: 1em;
}

.vlist-item--placeholder .rpost__thumb {
    background: var(--vlist-placeholder-bg);
}

.vlist-item--placeholder .rpost__thumb img {
    visibility: hidden;
}

.vlist-item--placeholder .rpost__pill {
    color: transparent;
    min-width: 60px;
}

.vlist-item--placeholder .rpost__count,
.vlist-item--placeholder .rpost__vote,
.vlist-item--placeholder .rpost__pill-icon {
    visibility: hidden;
}

.vlist-item--placeholder .rpost__more {
    visibility: hidden;
}

/* ==========================================================================
   Panel — select dropdown
   ========================================================================== */

.panel-select {
    width: 100%;
    padding: 6px 10px;
    border-radius: 6px;
    border: 1px solid var(--vlist-border);
    background: var(--vlist-bg);
    color: var(--vlist-text);
    font-size: 13px;
    font-weight: 500;
    cursor: pointer;
    appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23888' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 10px center;
    padding-right: 28px;
    transition: border-color 0.12s ease;
}

.panel-select:focus {
    outline: none;
    border-color: var(--accent, #667eea);
}

.panel-select optgroup {
    font-weight: 600;
    font-size: 12px;
    color: var(--vlist-text-muted);
}

.panel-select option {
    font-weight: 500;
    color: var(--vlist-text);
}

/* ==========================================================================
   Panel — load more button variant
   ========================================================================== */

.panel-row--action {
    padding-top: 4px;
}

.panel-btn--load {
    width: 100%;
    justify-content: center;
    background: var(--accent, #667eea);
    color: white;
    opacity: 1;
    font-weight: 600;
}

.panel-btn--load:hover:not(:disabled) {
    background: var(--accent, #667eea);
    filter: brightness(1.1);
    color: white;
}

.panel-btn--load:disabled {
    opacity: 0.4;
    cursor: not-allowed;
}

/* ==========================================================================
   Panel section visibility toggle
   ========================================================================== */

.panel-section--hidden {
    display: none;
}

/* ==========================================================================
   End-of-feed indicator
   ========================================================================== */

.post-end {
    display: flex;
    align-items: center;
    gap: 16px;
    padding: 24px 20px;
    height: 100%;
}

.post-end__line {
    flex: 1;
    height: 1px;
    background: var(--vlist-border);
}

.post-end__label {
    font-size: 12px;
    font-weight: 500;
    color: var(--vlist-text-muted);
    white-space: nowrap;
    letter-spacing: 0.3px;
}

/* ==========================================================================
   Disabled segmented button
   ========================================================================== */

.panel-segmented__btn--disabled,
.panel-segmented__btn:disabled {
    opacity: 0.35;
    cursor: not-allowed;
    pointer-events: none;
}

/* ==========================================================================
   Placeholder skeleton — .vlist-item--placeholder on the wrapper.
   Same template renders, CSS hides text and shows skeleton blocks.
   ========================================================================== */

.vlist-item--placeholder .post {
    gap: 12px;
}

.vlist-item--placeholder .post__avatar {
    background-color: var(--vlist-placeholder-bg) !important;
    color: transparent;
}

.vlist-item--placeholder .post__user,
.vlist-item--placeholder .post__time {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 4px;
    width: fit-content;
    min-width: 60%;
    line-height: 1.1;
}

.vlist-item--placeholder .post__time {
    min-width: 30%;
}

.vlist-item--placeholder .post__title {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 4px;
    width: 85%;
    line-height: 1.2;
}

.vlist-item--placeholder .post__body {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 4px;
    width: 100%;
    min-height: 2.4em;
    line-height: 1.2;
}

.vlist-item--placeholder .post__image--real {
    background: var(--vlist-placeholder-bg);
}

.vlist-item--placeholder .post__image--real img {
    visibility: hidden;
}

.vlist-item--placeholder .post__tag {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 4px;
    min-width: 50px;
    line-height: 1;
}

.vlist-item--placeholder .post__action {
    color: transparent;
    background-color: var(--vlist-placeholder-bg);
    border-radius: 4px;
    min-width: 48px;
}

/* ==========================================================================
   Muted label (placeholder text)
   ========================================================================== */

.panel-label--muted {
    color: var(--vlist-text-muted);
    font-style: italic;
    font-size: 12px;
}
<div class="container social-feed">
    <header>
        <h1>
            Social Feed
            <span class="mode-badge" id="mode-badge">Mode A</span>
        </h1>
        <p class="description">
            Social feed where item sizes are <strong>unknown upfront</strong>.
            Choose a <strong>source</strong> (live Reddit or RSS) and a
            <strong>mode</strong> to control how vlist handles variable sizes:
            <strong>A</strong> (pre-measure all items at init via hidden DOM
            element) or <strong>B</strong> (estimated size, let
            <code>ResizeObserver</code> measure on the fly and correct scroll
            position).
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main layout-feed">
            <h2 id="feed-name">Feed</h2>
            <div id="list-container"></div>
        </div>

        <aside class="split-panel">
            <!-- Source -->
            <section class="panel-section">
                <h3 class="panel-title">Source</h3>
                <div class="panel-row">
                    <div class="panel-segmented" id="feed-source">
                        <button
                            class="panel-segmented__btn panel-segmented__btn--active"
                            data-source="reddit"
                        >
                            Reddit
                        </button>
                        <button class="panel-segmented__btn" data-source="rss">
                            RSS
                        </button>
                    </div>
                </div>
            </section>

            <!-- Reddit options (only visible when source=reddit) -->
            <section class="panel-section" id="section-reddit">
                <h3 class="panel-title">Subreddit</h3>
                <div class="panel-row">
                    <select class="panel-select" id="feed-subreddit">
                        <option value="worldnews">r/worldnews</option>
                        <option value="technology">r/technology</option>
                        <option value="science">r/science</option>
                        <option value="music">r/music</option>
                        <option value="movies">r/movies</option>
                        <option value="dataisbeautiful">
                            r/dataisbeautiful
                        </option>
                        <option value="todayilearned">r/todayilearned</option>
                        <option value="space">r/space</option>
                        <option value="explainlikeimfive">
                            r/explainlikeimfive
                        </option>
                        <option value="AskScience">r/AskScience</option>
                    </select>
                </div>

                <div class="panel-row">
                    <span class="panel-label">Status</span>
                    <span class="panel-value" id="info-feed-status">–</span>
                </div>
                <div class="panel-row">
                    <span class="panel-label">Posts loaded</span>
                    <span class="panel-value" id="info-feed-count">–</span>
                </div>
            </section>

            <!-- RSS options (only visible when source=rss) -->
            <section
                class="panel-section panel-section--hidden"
                id="section-rss"
            >
                <h3 class="panel-title">RSS Feed</h3>
                <div class="panel-row">
                    <select class="panel-select" id="feed-rss">
                        <optgroup label="News">
                            <option
                                value="https://feeds.bbci.co.uk/news/world/rss.xml"
                            >
                                BBC World News
                            </option>
                            <option
                                value="https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml"
                            >
                                New York Times
                            </option>
                            <option
                                value="https://www.theguardian.com/world/rss"
                            >
                                The Guardian World
                            </option>
                            <option
                                value="https://feeds.reuters.com/reuters/topNews"
                            >
                                Reuters Top News
                            </option>
                        </optgroup>
                        <optgroup label="Tech">
                            <option value="https://hnrss.org/frontpage">
                                Hacker News
                            </option>
                            <option
                                value="https://feeds.arstechnica.com/arstechnica/index"
                            >
                                Ars Technica
                            </option>
                            <option
                                value="https://www.theverge.com/rss/index.xml"
                            >
                                The Verge
                            </option>
                            <option value="https://twobithistory.org/feed.xml">
                                Two-Bit History
                            </option>
                        </optgroup>
                        <optgroup label="Science & Space">
                            <option
                                value="https://www.nasa.gov/rss/dyn/breaking_news.rss"
                            >
                                NASA Breaking News
                            </option>
                            <option
                                value="https://www.newscientist.com/section/news/feed/"
                            >
                                New Scientist
                            </option>
                            <option
                                value="https://www.quantamagazine.org/feed/"
                            >
                                Quanta Magazine
                            </option>
                        </optgroup>
                        <optgroup label="Culture & Music">
                            <option
                                value="https://pitchfork.com/feed/feed-news/rss"
                            >
                                Pitchfork
                            </option>
                            <option value="https://www.openculture.com/feed">
                                Open Culture
                            </option>
                            <option
                                value="https://feeds.feedburner.com/brainpickings/rss"
                            >
                                The Marginalian
                            </option>
                            <option value="https://lithub.com/feed/">
                                Literary Hub
                            </option>
                        </optgroup>
                        <optgroup label="Design & Visual">
                            <option value="https://www.designboom.com/feed/">
                                Designboom
                            </option>
                            <option value="https://www.creativebloq.com/feed">
                                Creative Bloq
                            </option>
                            <option value="https://colossal.art/feed/">
                                Colossal
                            </option>
                        </optgroup>
                    </select>
                </div>
                <div class="panel-row">
                    <span class="panel-label">Status</span>
                    <span class="panel-value" id="info-rss-status">–</span>
                </div>
                <div class="panel-row">
                    <span class="panel-label">Items loaded</span>
                    <span class="panel-value" id="info-rss-count">–</span>
                </div>
            </section>

            <!-- Layout -->
            <section class="panel-section">
                <h3 class="panel-title">Layout</h3>
                <div class="panel-row">
                    <div class="panel-segmented" id="layout-toggle">
                        <button
                            class="panel-segmented__btn panel-segmented__btn--active"
                            data-layout="card"
                        >
                            Card
                        </button>
                        <button
                            class="panel-segmented__btn"
                            data-layout="compact"
                        >
                            Compact
                        </button>
                    </div>
                </div>
            </section>

            <!-- Mode -->
            <section class="panel-section">
                <h3 class="panel-title">Mode</h3>
                <div class="panel-row">
                    <div class="panel-segmented" id="mode-toggle">
                        <button
                            class="panel-segmented__btn panel-segmented__btn--active"
                            data-mode="a"
                        >
                            A · Pre-measure
                        </button>
                        <button class="panel-segmented__btn" data-mode="b">
                            B · Auto-size
                        </button>
                    </div>
                </div>
            </section>

            <!-- Measurement -->
            <section class="panel-section" id="section-measurement">
                <h3 class="panel-title">Measurement</h3>
                <div class="panel-row">
                    <span class="panel-label">Strategy</span>
                    <span class="panel-value" id="info-strategy"
                        >size: (index) =&gt; px</span
                    >
                </div>
                <div class="panel-row">
                    <span class="panel-label">Init time</span>
                    <span class="panel-value" id="info-init">–</span>
                </div>
                <div class="panel-row">
                    <span class="panel-label">Unique sizes</span>
                    <span class="panel-value" id="info-unique">–</span>
                </div>
            </section>

            <!-- Navigation -->
            <section class="panel-section">
                <h3 class="panel-title">Navigation</h3>
                <div class="panel-row">
                    <div class="panel-btn-group">
                        <button
                            id="jump-top"
                            class="panel-btn panel-btn--icon"
                            title="Top"
                        >
                            <i class="icon icon--up"></i>
                        </button>
                        <button
                            id="jump-middle"
                            class="panel-btn panel-btn--icon"
                            title="Middle"
                        >
                            <i class="icon icon--center"></i>
                        </button>
                        <button
                            id="jump-bottom"
                            class="panel-btn panel-btn--icon"
                            title="Bottom"
                        >
                            <i class="icon icon--down"></i>
                        </button>
                        <button
                            id="jump-random"
                            class="panel-btn panel-btn--icon"
                            title="Random"
                        >
                            <i class="icon icon--shuffle"></i>
                        </button>
                    </div>
                </div>
            </section>
        </aside>
    </div>

    <footer class="example-footer" id="example-footer">
        <div class="example-footer__left">
            <span class="example-footer__stat">
                <strong id="ft-progress">0%</strong>
            </span>
            <span class="example-footer__stat">
                <span id="ft-velocity">0.00</span> /
                <strong id="ft-velocity-avg">0.00</strong>
                <span class="example-footer__unit">px/ms</span>
            </span>
            <span class="example-footer__stat">
                <span id="ft-dom">0</span> /
                <strong id="ft-total">0</strong>
                <span class="example-footer__unit">items</span>
            </span>
        </div>
        <div class="example-footer__right">
            <span class="example-footer__stat" id="ft-source-stat">
                <span id="ft-source">Reddit</span>
                ·
                <strong id="ft-mode">Mode A</strong>
            </span>
        </div>
    </footer>
</div>