Miniapps — Principles

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.

What a miniapp is

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:

Shared library code (CSS + JS reused by 2+ miniapps) lives under miniapps/lib/.

The non-negotiables

1. Scraper-readable English content in static HTML

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.

2. Localization is one pattern only

<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;
});

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.

3. Lang and size from query string

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.

4. CSS isolation

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:

5. Pause animations when off-screen

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();
}

6. Ship a static preview.png

Every 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.

7. Marketing copy rules apply

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.

Code reuse via miniapps/lib/

When two or more miniapps share an animation pattern, factor it into the lib:

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).

Existing libraries

Existing one-off miniapps

If a third miniapp would reuse any of these patterns, factor it into lib/ before adding it.

Don’ts