diff --git a/docs/fragment-composition-plan.md b/docs/fragment-composition-plan.md
new file mode 100644
index 0000000..7619ae9
--- /dev/null
+++ b/docs/fragment-composition-plan.md
@@ -0,0 +1,502 @@
+# Eliminate Shared Templates: Server-Side Micro-Frontend Composition
+
+## Context
+
+All 6 coop apps share ~291 templates via `shared/browser/templates/`. This couples every app's UI to a shared submodule. The widget registry (`NavWidget`, `CardWidget`, `AccountPageWidget`, `AccountNavLink`) adds cross-app UI coupling.
+
+**Goal:** Each app owns ALL its templates. Apps expose HTML **fragments** via internal HTTP endpoints. Consuming apps fetch and compose these fragments. No shared templates, no shared widget system.
+
+**Pattern:** [Server-side template composition](https://martinfowler.com/articles/micro-frontends.html) — each app is both a **container** (assembling fragments from others into its pages) and a **micro-frontend** (providing fragments to others). Authentication centralized in account app, injected via fragment. Fowler's "harvest" approach for shared UI: allow duplication first, extract proven patterns later.
+
+---
+
+## Architecture
+
+### Fragment API Contract
+
+Each app exposes `GET /internal/fragments/{type}` (internal Docker network only).
+
+- **Response**: Raw HTML (`text/html`), ready for `{{ html | safe }}`
+- **Errors**: Return `200` with empty body (graceful degradation)
+- **Header**: `X-Fragment-Request: 1` on requests
+- **Context**: Query params for per-user/per-page data (e.g. `?user_id=5`)
+- **Variant**: Optional `?variant=compact|full|inline|card` query param — provider renders a layout-appropriate version (see Consumer-Side Styling below)
+
+### Fragment Catalog
+
+| Fragment | Provider | Consumers | TTL |
+|----------|----------|-----------|-----|
+| `nav-item` | each app | all apps | 300s |
+| `nav-tree` | blog | all apps | 60s |
+| `auth-menu` | account | all apps | 60s |
+| `cart-mini` | cart | all apps | 10s |
+| `container-nav` | events, market | blog, events | 30s |
+| `container-cards` | events | blog | 30s |
+| `account-nav-item` | events, cart, federation | account | 300s |
+| `account-page/{slug}` | events, cart | account | 30s |
+| `link-card` | blog, market, events, federation | any app | 300s |
+| `/oembed` (public) | blog, market, events | Mastodon, Slack, Discord, etc. | 300s |
+| `/{slug}/embed` (public) | blog, events | Slack, Discord, WordPress (oEmbed iframe) | — |
+| OG `` tags | all apps | Twitter/X, Facebook, LinkedIn | — |
+| AP `Video` attachment | federation | Mastodon, Pleroma, Misskey | — |
+
+### Fragment Client
+
+New file: `shared/infrastructure/fragments.py`
+
+```python
+async def fetch_fragment(app_name, fragment_type, *, params=None, timeout=2.0) -> str:
+ """Fetch HTML fragment from another app. Returns '' on error."""
+
+async def fetch_fragments(requests: list[tuple[str, str, dict|None]]) -> list[str]:
+ """Fetch multiple fragments concurrently via asyncio.gather."""
+
+async def fetch_fragment_cached(app_name, fragment_type, *, params=None, ttl=30) -> str:
+ """fetch_fragment + Redis cache layer."""
+
+async def fetch_fragment_batch(
+ app_name, fragment_type, *, keys: list[str], params=None, ttl=30,
+) -> dict[str, str]:
+ """Fetch a batch of keyed fragments in a single HTTP call.
+
+ Sends keys as comma-separated query param. Provider returns HTML with
+ comment markers. Client splits and returns
+ a dict mapping each key to its HTML (missing keys → '').
+
+ Example: fetch_fragment_batch("cart", "cart-mini", keys=["flour", "bread"],
+ params={"user_id": 5})
+ → GET /internal/fragments/cart-mini?keys=flour,bread&user_id=5
+ → {"flour": "
...
", "bread": "
...
"}
+ """
+```
+
+Uses `INTERNAL_URL_{APP}` env vars (e.g. `http://blog:8000`). Falls back to `http://{app}:8000`.
+
+### Batch Fragments (list pages)
+
+Fragments that appear per-item on list pages (product cards, timeline posts, etc.)
+support batch fetching to avoid N+1 HTTP calls. One request returns all items,
+delimited by `` comment markers.
+
+**Pattern (already proven by `container-cards`):**
+
+```
+GET /internal/fragments/cart-mini?keys=flour,bread,milk&user_id=5
+```
+
+```html
+
+
...
+
+
...
+
+
...
+```
+
+The client helper `fetch_fragment_batch()` splits by markers and returns `dict[key, html]`.
+Templates access per-item HTML via the dict:
+
+```jinja2
+{% for product in products %}
+ {{ batch_cart_minis[product.slug] | safe }}
+{% endfor %}
+```
+
+**Batch-eligible fragments:**
+
+| Fragment | Key field | Use case |
+|----------|-----------|----------|
+| `cart-mini` | `product_slug` | Add-to-cart buttons on browse/listing pages |
+| `like-button` | `slug` | Like buttons on product cards, blog listings |
+| `link-card` | `slug` | Multiple rich link previews in a blog post |
+| `container-cards` | `post_id` | Calendar entry cards on blog listing (already implemented) |
+
+**Provider-side:** batch handlers receive `keys` query param, query all items in one
+DB call (e.g. `WHERE slug IN (...)`), render each to HTML, join with comment markers.
+This is a single SQL query + template render loop, not N separate handler calls.
+
+### Jinja Integration
+
+Register `fragment` as Jinja async global in `jinja_setup.py`:
+```jinja2
+{{ fragment("cart", "cart-mini", user_id=uid) | safe }}
+```
+
+**Preferred pattern**: Pre-fetch all fragments in context processor with `asyncio.gather`, pass as template vars.
+
+### Consumer-Side Styling
+
+Fragments need to look different depending on where they appear. A cart-mini button
+on a product card looks different from one in a sidebar or a mobile nav. The consumer
+controls the presentation context; the provider supplies the content.
+
+**Three complementary mechanisms:**
+
+**1. Provider variants (`?variant=`)** — the provider renders a structurally different
+version. Use when layout differs significantly (not just CSS tweaks):
+
+```python
+# Context processor
+cart_mini_html = await fetch_fragment("cart", "cart-mini",
+ params={"product_slug": "flour", "variant": "compact"})
+```
+
+Standard variant names:
+- `full` — default, complete rendering (product pages, dedicated sections)
+- `compact` — minimal version (listing cards, sidebars, tight spaces)
+- `inline` — single-line/inline-flow version (within text, table cells)
+- `icon` — icon-only, no text (toolbars, mobile nav)
+
+Provider templates: `fragments/cart_mini.html` checks `variant` param and renders
+accordingly. One template with conditionals, or separate includes per variant.
+
+**2. CSS wrapper classes** — the consumer wraps the fragment in a styled container.
+Use for spacing, sizing, color scheme adaptation:
+
+```jinja2
+{# Same fragment, different contexts #}
+
+ {{ batch_cart_minis[product.slug] | safe }}
+
+
+
+```
+
+Fragments use BEM-style or data-attribute selectors (`[data-fragment="cart-mini"]`)
+so consumers can target them with CSS without relying on internal class names.
+
+**3. Data attributes for JS hooks** — fragments include `data-fragment="type"`
+and `data-app="provider"` attributes on their root element. Consumers can attach
+behavior without coupling to internal markup:
+
+```html
+
+
+ ...
+
+
+
+
+```
+
+**Convention:** Every fragment's root element MUST include:
+- `data-fragment="{type}"` — the fragment type name
+- `data-app="{provider}"` — the app that rendered it
+- A single root element (no bare text nodes) so wrapping/targeting works reliably
+
+---
+
+## Implementation Phases
+
+### Phase 1: Fragment Infrastructure (foundation, no breaking changes)
+
+**New files:**
+- `shared/infrastructure/fragments.py` — HTTP client + Redis cache
+- `{blog,market,cart,events,federation,account}/bp/fragments/__init__.py` + `routes.py` — fragment blueprints
+- `art-dag/celery/app/routers/fragments.py` — artdag fragment endpoints
+
+**Modified files:**
+- `shared/infrastructure/jinja_setup.py` — add `fragment` Jinja global
+- Each app's `app.py` — register fragments blueprint
+
+### Phase 2: Cart + Account Fragments (highest cross-app value)
+
+Cart implements: `cart-mini`, `nav-item`, `account-nav-item`
+Account implements: `auth-menu`, `sign-in`
+Blog updates context processor to fetch these fragments.
+
+### Phase 3: Navigation Fragments
+
+Blog implements: `nav-tree` (renders `menu_items` to HTML)
+All apps update context processors to fetch `nav-tree` from blog.
+Copy `_types/root/` templates to each app, replace nav includes with fragment slots.
+
+### Phase 4: Container Widgets → Fragments
+
+Events implements: `container-nav`, `container-cards`
+Market implements: `container-nav`
+Blog/events replace `widgets.container_nav`/`widgets.container_cards` with fragment fetches.
+
+### Phase 5: Link Card Fragments (rich internal links)
+
+Any app can request a rich link preview from any other app:
+
+```python
+html = await fetch_fragment("blog", "link-card", params={"slug": "about-us"})
+html = await fetch_fragment("market", "link-card", params={"slug": "organic-flour"})
+html = await fetch_fragment("events", "link-card", params={"slug": "summer-fair", "calendar_id": "42"})
+```
+
+Each app's `link-card` handler returns a self-contained HTML card with domain-appropriate data:
+
+| Provider | Card contents |
+|----------|--------------|
+| **blog** | title, feature image, excerpt |
+| **market** | product name, image, price, availability |
+| **events** | event name, date/time, image, ticket status |
+| **federation** | actor display name, avatar, bio |
+
+**Key design principles:**
+
+1. **Provider owns the base markup** — the card HTML includes sensible default styling (image, title, description), but is wrapped in a simple container (`