This file is the contract for anyone (human or AI) adding or modifying a miniapp. Read this before writing any miniapp code, then check existing miniapps for the live pattern.
A self-contained HTML/CSS/JS widget loaded into a feature page through an iframe
(feature.html builds the src as /miniapps/{page.miniapp}/?lang={page.lang}&size=large).
Each miniapp lives in its own folder under miniapps/features/<slug>/ and ships:
index.html (the only required file)img/, bg.jpg, etc.Shared library code (CSS + JS reused by 2+ miniapps) lives under miniapps/lib/.
Search engines, social-media link previews, screen readers, and crawl-only audits
all consume the initial HTML response. They do not execute long JS pipelines and
will not see content rendered after a fetch().
Rule: every word a human will see must be present in the document the server
sends. Either as the primary visible DOM (when content is static), or as a
<ul class="sr-only"> block at the bottom (when the visible DOM is built by JS).
Never fetch() content over the network. Inline it.
About the iframe boundary and SEO. A miniapp is loaded into the feature
page through an iframe, so its text is crawlable at the iframe’s own URL
(/miniapps/features/<slug>/) but is not attributed to the parent feature
page for ranking purposes. Twitter Cards / OpenGraph previews also do not
descend into iframes. This is fine for visual demo text (captions, axis
labels, chart titles) — that’s what the miniapps are for. But if a miniapp
ever contains text that we want the feature page itself to rank on, the text
must also be mirrored into the feature page — either in the markdown body or
as an <aside class="sr-only"> block next to the iframe in _layouts/feature.html.
The inline Miri AI example block (rendered server-side from the page’s
miri_ai_example frontmatter, not iframed) is the established pattern for
high-value SEO content; iframes are for supplementary demos.
<h1 data-i18n="title">My Widget</h1>
<p data-i18n-html="subtitle">Welcome to <b>my widget</b>.</p>
var lang = (new URLSearchParams(location.search).get('lang') || 'en').toLowerCase();
if (!['en','es','fr'].includes(lang)) lang = 'en';
var T = {
en: { title: 'My Widget', subtitle: 'Welcome to <b>my widget</b>.' },
es: { ... },
fr: { ... }
};
function tr(path) {
var parts = path.split('.'), v = T[lang];
for (var i = 0; i < parts.length; i++) v = v && v[parts[i]];
if (v == null) { v = T.en; for (var j = 0; j < parts.length; j++) v = v && v[parts[j]]; }
return v;
}
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var v = tr(el.getAttribute('data-i18n'));
if (typeof v === 'string') el.textContent = v;
});
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
var v = tr(el.getAttribute('data-i18n-html'));
if (typeof v === 'string') el.innerHTML = v;
});
data-i18n sets textContent. data-i18n-html sets innerHTML
(for strings containing <b>, <i>, etc).fetch('data/{lang}.json'). That breaks scrapers,
adds a roundtrip, and creates four files where one suffices.For dynamically-generated DOM (JS-built carousels, animated stacks), the items
must still appear in the static HTML as a <ul class="sr-only"> fallback list,
so scrapers see the underlying text.
feature.html always passes ?lang={en|es|fr}&size={large|small}. Read both
from location.search. size=small is the compact variant used in the carousel
on the homepage; honor it where it matters (font sizes, animation speeds), but
the marketing feature page always loads size=large.
The iframe boundary already isolates the miniapp from the parent site’s CSS —
Bootstrap, grayscale.scss, .about-section p { margin: 5rem }, none of it
reaches in. Do not assume the parent’s styles are available; do not
emit styles that try to escape (no :host, no parent-targeting tricks).
Inside the miniapp:
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }.html, body { width: 100%; height: 100%; overflow: hidden; } so the
miniapp fills its iframe and never produces inner scrollbars.miniapps/lib/*), pick distinctive
names (e.g. .shot, .cdot, .miri-chat__*) so the lib doesn’t have to
worry about what an instance might re-use.Burning CPU when no one’s looking is rude. Every animated miniapp must pause itself when the iframe scrolls out of view OR when the tab is hidden:
document.addEventListener('visibilitychange', function () {
if (document.hidden) stop(); else start();
});
if (window.frameElement && 'IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) start(); else stop();
}, { threshold: 0.05 }).observe(window.frameElement);
} else {
start();
}
preview.pngEvery miniapp folder should contain a preview.png (or .jpg) alongside its
index.html. The brochure, social-media link previews, PDF exports and any
other non-iframe context use this still image instead of trying to render the
miniapp. The brochure_feature.html include automatically uses
/miniapps/{miniapp}/preview.png when the feature page declares miniapp: in
its frontmatter.
To regenerate previews from the live miniapps (Jekyll must be running on :4000):
node scripts/render-miniapp-preview.js # all miniapps
node scripts/render-miniapp-preview.js reports # one miniapp
node scripts/render-miniapp-preview.js --settle=3500 --width=900 --height=675
The script uses puppeteer (already a devDep), loads each miniapp with
?lang=en&size=large, waits ~2.2s for animations to land in a representative
pose, then writes preview.png. Run it whenever the miniapp’s visual changes.
No internals in user-visible strings. The rules in /CLAUDE.md (“No internals
in marketing copy”) apply equally to miniapp text. Replace SQL/MQTT/Twilio/OAuth
mentions with the user-visible behavior they enable.
miniapps/lib/When two or more miniapps share an animation pattern, factor it into the lib:
miniapps/lib/<pattern>.css — generic stylesminiapps/lib/<pattern>.js — generic logic that reads window.MIRIAPP_<NAME>The instance miniapp then becomes a thin config file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="../../lib/<pattern>.css">
</head>
<body style="--backdrop-url: url('/img/features/foo.jpg'); --card-aspect: 1465/2845;">
<div id="stage"></div>
<ul class="sr-only" aria-label="...">
<li>English caption 1</li>
<li>English caption 2</li>
</ul>
<script>
window.MIRIAPP_SHOTS = {
intervalMs: 4800,
shots: [
{ src: 'img/skin-1.png', labels: { en: '...', es: '...', fr: '...' } },
...
]
};
</script>
<script src="../../lib/<pattern>.js"></script>
</body>
</html>
Variation lives in CSS variables on <body> (e.g. --backdrop-url,
--card-aspect) and in the window.MIRIAPP_* config object — never by editing
the lib for one consumer.
Lib path: miniapps/lib/ (no leading underscore — Jekyll excludes _* dirs
from the build by default).
lib/screenshots.{css,js} — phone-shaped (or any aspect-ratio) screenshot
carousel with a blurred photographic backdrop, spotlight vignette, gold accent
line, Ken Burns drift, glass-pill captions, click-to-jump dots. Config shape:
window.MIRIAPP_SHOTS = {
intervalMs: 4800,
shots: [ { src, labels: { en, es, fr } } ]
};
Body CSS variables: --backdrop-url, --card-aspect. Instance must also
include a <ul class="sr-only"> listing English captions. Used by:
features/fully-customizable/.
features/ai-concierge/ — 3D-stack of conversation cards with Miri.
Conversations inlined as SCENARIOS array with per-field {en,es,fr}.
Static <ul class="sr-only"> provides the scraper-readable transcript.features/reports/ — Animated SVG chart carousel for the Reports feature.
Card titles, group labels, toolbar pills, tooltip text — all data-i18n.
Dates computed relative to today via JS; month names language-aware.If a third miniapp would reuse any of these patterns, factor it into lib/
before adding it.
fetch() content from JSON/data files. Inline it.miniapps/_lib/ (leading underscore = Jekyll
ignores it). Use miniapps/lib/.