Skip to content

Feeds and containment

This guide is for app builders rendering other people’s content in their own looks — a feed, a reader, an embed. It builds on Render themes in your app: the functions used here (getTheme, fontFaces, themeToCss) are defined there, and the layer order @layer app, author, viewer is already declared. What’s new is the problem the author source brings with it: many authors on one page, every look needing to stay inside its own content — the Mode Contract, Rule 7 job.

The real multi-author case is a feed: many authors’ posts, each dressed in its author’s baseline — every author’s own default. So every post carries the same marker, data-mode="default", and every author’s [data-mode="default"] rule, all sharing the author layer, would match every post. The layer separates the author tier from app and viewer — not the authors from each other: peers in one layer fall through to source order, so the last rule would win for all of them. Namespacing only helps when authors ship genuinely distinct concepts (a com.alice.dark and a com.bob.dark get distinct markers for free); here the marker is the same default for everyone, so containment is required. This is the Rule 7 job for @scope.

The app writes each author’s CSS in place — inside the post, at the moment it renders it. That’s the author lifecycle from the sources table in Render themes in your app: an author’s CSS is produced in the same step that writes the author’s post, because that’s exactly when the app has the author’s DID and the post’s container in hand. And because the CSS lands inside the post, the @scope needs no selector at all: a prelude-less @scope { … } roots at the <style>’s parent element, so the post is the boundary. themeToCss stays placement-agnostic; the app wraps its output in @scope { … } and drops that <style> inside the <article>. (An implementer who only renders author content could fold the wrap into their own themeToCss; the reference leaves it out so one function still serves the global app and viewer tiers.)

// Called as the app renders each feed item. Everything for the post lives in one <style>
// written INSIDE it: the @font-face rules at the top (global wherever they sit, as long as
// they're outside @scope), and a prelude-less @scope { … } — which roots at the <article> on
// its own, no id or selector — containing the scoped variable rules.
// Themes change rarely — cache per author DID (like the agentFor cache in the render
// guide) so a feed doesn't re-fetch the same author's theme on every post and every render.
const themes = new Map<string, Promise<ThemeRecord | null>>();
function themeFor(did: string): Promise<ThemeRecord | null> {
let theme = themes.get(did);
if (!theme) themes.set(did, (theme = getTheme(did, "default")));
return theme;
}
async function renderPost(post: { authorDid: string; body: string }) {
const theme = await themeFor(post.authorDid); // the author's default look, cached
if (!theme) return `<article><div data-mode="default">${post.body}</div></article>`;
const style = `<style>${fontFaces(theme)} @scope { ${themeToCss(theme, "default", "author")} }</style>`;
// The marker sits on a CHILD of the <article>, not on the <article> itself: a scoped
// style rule only matches descendants of its @scope root (a selector with no :scope in
// it never matches the root element), so a marker on the <article> would sit just
// outside the reach of the very look written for it.
return `<article>${style}<div data-mode="default">${post.body}</div></article>`;
}

For a feed of Alice and Bob that yields (each post’s <style> sits inside its <article> — any @font-face at the top of the block, the @scope beneath it; these are color-only, so there’s no font to show):

<style>@layer app, author, viewer;</style>
<article>
<style>
/* fontFaces(theme) would go here — global, outside @scope (empty for a color-only look) */
@scope { /* prelude-less → scopes to THIS <article>, its parent */
@layer author {
:where([data-mode="default"], [data-mode~="default/color"]) {
--color-surface-primary-background: #0a0e14; /* …Alice's default… */
}
}
}
</style>
<div data-mode="default">…Alice's content…</div>
</article>
<article>
<style>
@scope {
@layer author {
:where([data-mode="default"], [data-mode~="default/color"]) {
--color-surface-primary-background: #241018; /* …Bob's default… */
}
}
}
</style>
<div data-mode="default">…Bob's content…</div>
</article>

Each <style> lives inside its own <article>, so the prelude-less @scope roots at that article and the author’s rule can’t escape it — no id, no selector: the position is the boundary. Both posts still carry the honest data-mode="default" marker. And the viewer still wins: a viewer’s page-global @layer viewer { :where([data-mode="default"]) { … } } matches inside each article too, and because layers sort above scope-proximity, the viewer layer beats the scoped author rule on every post. Contain the author by position, keep the marker; the override survives. (Keep the app’s own chrome outside these article subtrees, per Rule 7, so an author’s colors don’t repaint the feed’s toolbar.)