//one by ome prompt based spec definition..
Yes — and this is a good moment to align instead of duplicating. Short answer: do not introduce a parallel “StudyCard” domain. You already have a mature Card system. We should project Skills into it, not fork it.
Below is a clean, minimal alignment plan that respects your existing models and avoids schema sprawl.
⸻
- What you already have (and should reuse)
You already have a canonical card system:
Existing, stable truth • Chapter • PromptCard • Palette • Highlight
This system already supports: • ordering • grouping • visual semantics (palette) • rendering • study / learning / reference use cases
This is not just dev-only — it is already a knowledge card engine.
Conclusion: 👉 Skills should feed this system, not replace it.
⸻
- What the new Skill layer actually is
Your new layer introduces runtime / commercial / experiential structure, not knowledge rendering.
Conceptually:
Layer Role MicroSkill Capability / offering / competence MicroSession Delivery instance / format SessionStep Operational steps / pedagogy PromptCard Knowledge atom / explanation
So the relationship is not parallel, it is derivative.
A SessionStep can generate or reference one or more PromptCards.
⸻
- The key design decision (important)
You have two valid options. Only one scales cleanly.
⸻
✅ Option A (recommended): Skills project into Cards
Do not create a new “StudyCard” model. Instead: • Keep PromptCard as-is • Add a thin link layer from Skills → Cards
Minimal new model
model SkillCardLink { id String @id @default(cuid()) skillId String sessionId String? stepId String? cardId String order Int @default(0) purpose String? // "primary", "supplement", "marketing"
card PromptCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@index([skillId]) @@index([sessionId]) @@index([stepId]) }
Why this is correct • No duplication of content • Cards remain reusable across: • dev study • coaching • posters • PDFs • Skills become curated views over cards
⸻
❌ Option B (not recommended): duplicate card models
This would mean: • SkillCard • SessionCard • MarketingCard
You already know where that leads: schema explosion, rendering forks, cognitive debt.
⸻
- How /skills/[skillId]/cards should really work
Instead of rendering ad-hoc StudyCard, the page should: 1. Resolve the Skill 2. Resolve linked PromptCards 3. Render existing Card UI
Revised mental model for the page
Skill └─ Session └─ Steps └─ SkillCardLink └─ PromptCard (rendered)
⸻
- Revised page.tsx (aligned with your system)
This is what /skills/[skillId]/cards/page.tsx should evolve into:
// READ-ONLY ORCHESTRATION
const cards = await prisma.skillCardLink.findMany({ where: { skillId: params.skillId }, orderBy: { order: "asc" }, include: { card: true, }, });
Then render using your existing cards renderer (the same one used in /cards/[slug]).
No new card UI. No new semantics. No drift.
⸻
- Where SessionStep fits now
SessionStep should not own long-form content.
Instead:
model SessionStep { id String @id @default(cuid()) sessionId String position Int title String
// optional short guidance only cue String? // what to do timingMin Int?
// knowledge lives elsewhere }
Long-form explanations live in PromptCard.content.
⸻
- Marketing & commercial value (you asked explicitly)
This structure lets you: • reuse the same cards for: • coaching UI • public landing pages • printed posters • PDF exports • tag cards by purpose = "marketing" • selectively expose cards publicly without leaking internal logic
You already have: • isPublic • visibility • Palette • Space / Section
This fits cleanly.
⸻
- Clear recommendation
Do this, in order: 1. ❌ Do NOT add a new StudyCard model 2. ✅ Add SkillCardLink 3. ✅ Strip long text out of SessionStep 4. ✅ Make /skills/[skillId]/cards a projection over PromptCard 5. ✅ Reuse the existing Cards UI
⸻
- Next concrete step (I recommend)
If you want, next I can: • propose the exact Prisma migration • refactor your current SkillCardsPage to use PromptCard • or define the poster renderer that consumes the same cards
Just tell me which one you want next.
Below is a single, clear, execution-grade path that turns everything you’ve written so far into a paced implementation plan you can actually follow week by week.
This is not new theory. This is a compressed roadmap that aligns: • Skill-first MLV • Prisma + Next.js reality • Physical postcards/posters in a local radius • Online reuse (landing pages, socials, in-app) • One-to-one sessions (in person + online) • Zero over-engineering up front
You can treat this as your operational playbook for the next 6–10 weeks.
⸻
PHASE 0 — LOCK THE INTENT (DO THIS ONCE)
Outcome: no more conceptual drift.
Rules (already agreed, now operational): 1. Micro-Skill is the root 2. Sessions execute skills (90′) 3. Steps contain knowledge 4. Cards/Posters/PDFs only render steps 5. Documents are instruments, not products 6. Trips are applied programs of skills
👉 Nothing new gets built unless it respects this chain.
⸻
PHASE 1 — DATA & CODE SPINE (DEV FIRST, 1–2 WEEKS)
Step 1.1 — Commit the Skill-First Prisma Migration
(you already have it) • Enums • MicroSkill • MicroSession • SessionStep • Asset • Program
Stop here until: • migration runs • Prisma client regenerates • seed script runs cleanly
⸻
Step 1.2 — Seed ONLY 3 Skills (Not 30)
Use real skills you can sell immediately, not the full catalog.
Recommended initial trio: 1. iPad Creative Workflow 2. Google Workspace for Professionals 3. Andalusia Cultural Journey – Córdoba → Granada
Each seeded with: • 1 online session • 1 in-person session • 4–6 canonical steps
👉 This is your entire universe for the first launch.
⸻
Step 1.3 — Wire MicroSkill ↔ Document2Data (Minimal)
Goal: A Folio document = authoring surface for one MicroSkill.
Do NOT: • build a generic CMS • build editors for everything
Do: • store microSkillId on the document • render SessionSteps into StudyCards via StudyCard.tsx • reuse existing Folio pagination & rendering
Outcome: • Skills can be authored like documents • Cards are rendered, not hand-designed
⸻
PHASE 2 — FIRST CARD → FIRST OFFER (2 WEEKS)
Step 2.1 — Finalise StudyCard as Atomic Unit
You already have: • StudyCard.tsx • Print-safe CSS
Lock this. No variants yet.
This card is: • screen • print • PDF • poster content • postcard content
⸻
Step 2.2 — Create ONE Poster Wrapper
Poster = Card + Hero + QR
Minimal component: • Skill title • One key step headline • “90′ One-to-One Session” • QR → /skills/[slug]
No pricing logic. No booking logic yet.
This lets you: • print posters • screenshot for socials • reuse in app
⸻
Step 2.3 — Create ONE Skill Landing Page
Route:
/skills/[skillSlug]
Content: • Skill description • Steps rendered as StudyCards • Session options: • Online • In-person (Granada) • One CTA: → “Request a Session”
This page is: • what QR codes point to • what social posts link to • what postcards link to
⸻
PHASE 3 — PHYSICAL DISTRIBUTION (LOW TECH, HIGH SIGNAL)
Step 3.1 — Define a Micro-Radius
Choose: • 1 coworking space • 2 cafés • 1 library / cultural venue
Walking distance from where you can host sessions.
No expansion yet.
⸻
Step 3.2 — Print 3 Assets Only
For EACH of the 3 skills: 1. Poster • Window / board • QR to skill page 2. Postcard • Same QR • Collectable • One sentence + title 3. Study Card (single step) • Not full deck • Teaser only
👉 Do NOT print full decks yet.
⸻
Step 3.3 — Manual Fulfilment (Important)
For the first 5–10 sessions: • booking can be manual • payment can be manual • calendar can be manual
Why: You are validating signal, not scaling.
⸻
PHASE 4 — ONLINE MIRROR (REUSE, NOT NEW WORK)
Everything physical now becomes digital without new content.
Step 4.1 — Social Activation
Each poster/postcard becomes: • Instagram post • Story • LinkedIn post • X thread
All link to:
/skills/[skillSlug]
No funnels yet. No email sequences yet.
⸻
Step 4.2 — In-App Reuse
Inside your app: • Skills list page • Same StudyCard components • Same poster wrapper as hero
Your app is now: • a catalog of skills • not a “tool” people need to understand
⸻
PHASE 5 — CONVERSION HARDENING (AFTER FIRST SALES)
Only after you’ve sold sessions.
Step 5.1 — Booking & Payment
Add: • booking slots per MicroSession • Stripe link or simple checkout • confirmation email
Do NOT add subscriptions yet.
⸻
Step 5.2 — Guidebook PDF (Upsell)
For 1 skill only: • generate Steps Guidebook PDF • priced 120–200€
This is not mandatory to launch. It’s leverage once demand exists.
⸻
PHASE 6 — TRIPS AS PROGRAMS (AFTER SKILLS PROVE)
Step 6.1 — Convert Trip into Program
Your Córdoba → Granada trip becomes: • Program (type: TRIP) • composed of MicroSessions • each day = steps
Same cards. Same renderer. Same posters.
Now: • travellers • guides • trainees
all consume the same system.
⸻
PHASE 7 — SCALE BY DUPLICATION, NOT COMPLEXITY
When ready: • add new skills • add new locations • onboard collaborators via Programs • reuse posters/cards system
No redesign. No new platform.
⸻
ONE-PAGE SUMMARY (FOR YOU)
Order of work (non-negotiable): 1. Prisma migration ✅ 2. Seed 3 real skills 3. Skill ↔ Document wiring 4. Lock StudyCard renderer 5. One poster wrapper 6. One skill landing page 7. Physical postcards/posters (local) 8. Manual sessions 9. Then automate
If you follow this order: • you ship • you sell • you don’t burn time
⸻
Next decision (pick ONE) 1. Skill ↔ Document wiring (exact code) 2. Poster / Postcard wrapper components 3. Skill landing page layout 4. Booking + payment minimal flow
Say the number.
Below is the exact, minimal, copy-pastable wiring for MicroSkill ↔ Document2Data, aligned with your locked Folio architecture and without importing any of the AppScript assumptions directly.
I will also explicitly map how your poster AppScript logic conceptually translates into the Next.js / Folio world, so nothing you built mentally is lost — only relocated correctly.
This answer is strictly Step 1. Poster comes after this is working.
⸻
STEP 1 — MICROSKILL ↔ DOCUMENT WIRING (EXACT CODE)
- What “wiring” means (precise)
A Document is the authoring + rendering instrument A MicroSkill is the knowledge root A Document may represent exactly one MicroSkill
So the wiring is: • Document stores microSkillId • Document2Data renders SessionSteps • Folio stays unchanged
No duplication. No CMS.
⸻
- Prisma: minimal addition to existing Document model
You already have Document / ProjectDocument (or similar).
Add one nullable foreign key.
model Document { id String @id @default(cuid()) title String document2Data Json
// 🔗 NEW — optional skill binding microSkillId String? microSkill MicroSkill? @relation(fields: [microSkillId], references: [id])
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Why nullable? • Documents can still exist without skills • Skill documents are explicit, not forced
👉 migrate this first.
⸻
- Server action: bind a MicroSkill to a Document
Authority: /documents/actions/attachSkillToDocument.ts
"use server";
import {prisma} from "@/lib/prisma"; import { revalidatePath } from "next/cache";
export async function attachSkillToDocument( documentId: string, microSkillId: string ) { await prisma.document.update({ where: { id: documentId }, data: { microSkillId }, });
revalidatePath(/documents/${documentId});
}
This is the only mutation. No implicit creation. No side effects.
⸻
- Server fetch: document + skill + steps (read-only)
Where: your server page loader (example: app/documents/[id]/page.tsx)
import {prisma} from "@/lib/prisma";
export async function getDocumentWithSkill(documentId: string) { return prisma.document.findUnique({ where: { id: documentId }, include: { microSkill: { include: { sessions: { include: { steps: { orderBy: { position: "asc" } }, }, }, }, }, }, }); }
Important: • Read-only • No Prisma in components • No transformation here
⸻
- Document2Data → Skill projection (pure)
5.1 Add a SkillSection to Document2Data
This is not a new editor. It’s a renderable section.
// document/types/document2data.ts
export type SkillSection = { type: "skill"; skillId: string; };
In Document2Data.sections[], this becomes:
{ "type": "skill", "skillId": "ckxy..." }
This is analogous to how you already reference Trips, LineItems, etc.
⸻
- Render SkillSection (page-1 only)
File: app/documents/components/folio/lib/renderBlock.tsx
Add ONE case.
import { StudyCard } from "@/components/study-card/StudyCard";
function renderSkillSection( skill: any // already fetched server-side ) { if (!skill) return null;
const session = skill.sessions[0]; // first canonical session if (!session) return null;
return ( <section> {session.steps.map((step) => ( <StudyCard key={step.id} skillTitle={skill.title} stepTitle={step.title} stepBody={step.body} meta={{ duration: "90 min", delivery: "Online / Granada", }} /> ))} </section> ); }
Then in your existing switch:
case "skill": return renderSkillSection(viewModel.microSkill);
Key point: Pagination does not care. It paginates rendered output exactly like text blocks.
⸻
- Where the Skill data comes from (VM layer)
In your server page:
const doc = await getDocumentWithSkill(params.id);
const viewModel = { document: doc, microSkill: doc.microSkill, };
Pass viewModel into PaperStack.
No context mutation. No client fetching.
⸻
- Why this matches your AppScript mental model
Your AppScript does this:
AppScript concept New system equivalent Spreadsheet row MicroSkill Doc append block SkillSection Poster sections SessionSteps GPT enhancement later (asset generation) Google Doc Folio Document Slides template Poster renderer
Crucial difference: You no longer append text. You render structured knowledge.
This is why everything becomes reusable.
⸻
- What we explicitly did NOT do (important)
❌ No poster yet ❌ No AI enhancement ❌ No Slides logic ❌ No new editors ❌ No client fetches ❌ No second document system
This is deliberate.
⸻
VERIFICATION CHECKLIST (DO THIS BEFORE MOVING ON)
You are done with Step 1 when: • Document has microSkillId • Skill steps render as StudyCards inside document • Pagination still works • PDF export renders identical output • No client-side Prisma usage • No duplicated content
⸻
NEXT STEP (NOW SAFE)
Step 2 — Poster / Postcard rendering, directly inspired by your AppScript, but now: • Stateless • Deterministic • Reusing SessionStep[] • No document mutation
👉 Say “Poster next” and I will translate your AppScript logic into a clean Poster renderer + Asset generator, one-to-one.
Below is a precise, non-destructive integration plan that answers exactly: • where Skills should plug into your current app • what stays (unchanged) • what becomes a container • what gets generalized • what new folder (if any) is justified
No rewrites. No philosophical drift.
⸻
TL;DR (decision first)
Do NOT create a new app or parallel system. Do NOT replace study-guide or cards.
👉 Promote them into containers and bind them to MicroSkill.
Your instinct is correct:
“generalize them and make them containers”
That is the cleanest move.
⸻
- What you already have (important recognition)
You already built 90% of a Skill system, just without calling it that.
Current primitives (already correct)
Existing thing What it really is chapters.tsx Skill curriculum outline cards.tsx Study Cards / Steps StudyShell Skill container UI /study-guide/* Skill authoring + explanation surface /cards/* Atomic step renderer
This is not demo content. It is a MicroSkill: “Next.js + Prisma Developer (Entity-First)”.
So the move is promotion, not replacement.
⸻
- Canonical decision: ONE model, TWO sources (for now)
We introduce MicroSkill as authority, but we allow static skills to coexist.
Rule (LOCK THIS):
A MicroSkill can be backed by: • DB (Prisma) → dynamic / business skills • Static registry → internal / reference skills
Same UI. Same shell. Same cards.
⸻
- Exact insertion point (answering your core question)
✅ INSERT HERE (do not fight this):
// app/study-guide/chapters.tsx
This file becomes a Skill Registry Adapter, not “chapters”.
⸻
3.1 Rename mentally (not physically yet)
You keep the file, but conceptually it becomes:
StudyGuideSkillAdapter
It adapts MicroSkill → StudyShell format.
⸻
3.2 Introduce ONE discriminant type
Add this without breaking anything:
export type SkillSource = | { kind: "static"; skillKey: string } | { kind: "db"; skillId: string };
Then extend ChapterContent minimally:
export type ChapterContent = { slug: string; title: string; summary: string; sections: Section[];
// 🔗 NEW (non-breaking) skill?: SkillSource; };
Your existing chapters become:
{ slug: "00-layout", title: "00 · Root Layout", summary: "...", skill: { kind: "static", skillKey: "nextjs-entity-first" }, sections: [...] }
Nothing breaks. Nothing rerenders differently. You’ve just attached intent.
⸻
- Cards = Steps (formalize this)
You already have:
export type CardContent = { slug: string; title: string; summary: string; chapterSlug: string; sections: Section[]; };
This already is a SessionStep.
Add ONE optional binding:
export type CardContent = { ... skillStepKey?: string; // stable identifier };
Example:
{ slug: "01-server-action", title: "Card · Server Page", skillStepKey: "server-page-read-only", chapterSlug: "01-server-action", ... }
Now: • Static cards = canonical step definitions • DB-backed skills can reuse this renderer
⸻
- Where Prisma-backed skills enter (cleanly)
NEW folder (justified, minimal):
app/skills/ page.tsx // list skills (DB) [skillId]/ page.tsx // skill overview cards/ page.tsx // render steps as cards
This does not replace /study-guide.
It sits alongside it.
⸻
5.1 Skill page uses the SAME shell
// app/skills/[skillId]/layout.tsx import { StudyShell } from "@/app/study-guide/components/layouts/StudyShell";
export default function SkillLayout({ children }) { return ( <StudyShell heading="Skill" items={[]} // later: sessions / steps basePath="/skills" > {children} </StudyShell> ); }
Same shell. Different source.
⸻
- Poster & assets (important positioning)
Your AppScript posters map exactly to this layer:
MicroSkill → MicroSession → SessionStep[] → Asset(type=POSTER)
In your current app: • cards = steps • StudyCard = render primitive • Poster is just a different renderer
⚠️ Do NOT insert poster logic into study-guide Poster belongs to:
app/assets/ poster/ PosterRenderer.tsx
It consumes SessionStep[] (DB or static).
⸻
- What NOT to do (explicitly)
❌ Do not fork: • one-professional • pro-micro-session • separate app
❌ Do not: • move study-guide content to DB now • rewrite cards to Prisma immediately • mix Prisma into study-guide routes
⸻
- Migration path (safe, incremental)
Phase 1 — NOW (1–2 sessions) • Treat study-guide as Static MicroSkill • Attach skill: { kind: "static" } • No DB dependency
Phase 2 • Create 1 DB-backed MicroSkill (e.g. iPad Workflow) • Render it through /skills/[id]/cards • Reuse StudyShell + StudyCard
Phase 3 • Generate Poster assets from DB steps • Print / QR / PDF
⸻
- Final recommendation (clear answer)
Generalize what you have. Do not replace it.
• study-guide = reference / internal skill
• cards = canonical step renderer
• skills/ = commercial / applied skills
• StudyShell = universal container
This keeps: • your architecture intact • your mental model consistent • your existing work fully reused • your Skill-first MLV actually enforced
⸻
If you want, next I can: 1. Show exact diff to generalize chapters.tsx 2. Implement Skill-backed Cards page 3. Translate your AppScript Poster into a pure Next.js Poster renderer 4. Define static → DB skill migration utility
Just say which.
Below is a clean, exact translation of your AppScript poster system into a pure Next.js (App Router) renderer, without losing intent, and without leaking business logic into UI.
This is deliberately boring, composable, and pluggable.
We do this in three strict layers, mirroring what you already did in Apps Script.
⸻
PART A — Poster model (what a Poster is)
Your AppScript already tells us the truth: • A poster is not free text • It is a structured projection of a Session / Steps • It has sections, optional AI enhancement, and layout rules
So we encode that explicitly.
⸻
A1. PosterSection definition (LOCKED)
// app/assets/poster/types.ts
export type PosterSectionKey = | "overview" | "highlights" | "itinerary" | "audience" | "extra";
export type PosterSection = { key: PosterSectionKey; title: string; content: string; };
This maps 1:1 to your Apps Script:
AppScript key PosterSectionKey Description overview Why join highlights Itinerary itinerary Who is this activity suited for audience Extra descriptions extra
⸻
A2. PosterPayload (stored in Prisma Asset.payload)
// app/assets/poster/types.ts
export type PosterPayload = { skillTitle: string; sessionMeta: { delivery: string; // "Online" | "Granada" duration: string; // "90 min" price?: string; }; sections: PosterSection[]; cta?: { label: string; href: string; }; };
This replaces Google Docs + Slides but keeps the same structure discipline.
⸻
PART B — PosterRenderer (pure UI, no data access)
This is the exact equivalent of insertPosterLayoutToDoc() but render-safe (screen + print + PDF).
⸻
B1. PosterRenderer component
// app/assets/poster/PosterRenderer.tsx
import { PosterPayload } from "./types";
type Props = { poster: PosterPayload; };
export function PosterRenderer({ poster }: Props) {
return (
<article className="poster">
{/* HEADER */}
<header className="poster__header">
<h1>{poster.skillTitle}</h1>
<p className="poster__meta">
{poster.sessionMeta.delivery} · {poster.sessionMeta.duration}
{poster.sessionMeta.price && · ${poster.sessionMeta.price}}
</p>
</header>
{/* SECTIONS */}
<section className="poster__sections">
{poster.sections.map((sec) => (
<div key={sec.key} className="poster__section">
<h2>{sec.title}</h2>
<p>{sec.content}</p>
</div>
))}
</section>
{/* CTA */}
{poster.cta && (
<footer className="poster__cta">
<a href={poster.cta.href}>{poster.cta.label}</a>
</footer>
)}
</article>
); }
⸻
B2. Print-safe CSS (mirrors your Doc formatting intent)
/* app/assets/poster/poster.css */
.poster { width: 210mm; /* A4 */ min-height: 297mm; padding: 20mm; font-family: "Droid Serif", system-ui, serif; box-sizing: border-box; }
.poster__header { text-align: center; margin-bottom: 20mm; }
.poster__header h1 { font-size: 32pt; margin-bottom: 6mm; }
.poster__meta { font-size: 12pt; opacity: 0.7; }
.poster__sections { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12mm; }
.poster__section h2 { font-size: 16pt; margin-bottom: 4mm; }
.poster__section p { font-size: 11pt; line-height: 1.4; }
.poster__cta { margin-top: 25mm; text-align: center; font-size: 14pt; }
@media print { body { background: none; } }
This is poster-grade, not webpage-grade.
⸻
PART C — Poster generation (Server Action)
This replaces: • appendPosterToCampaignDoc • Slides template replacement • Spreadsheet-driven generation
But keeps authority rules.
⸻
C1. Poster generation action (from Session)
// app/assets/actions/generatePoster.ts
"use server";
import {prisma} from "@/lib/prisma"; import { AssetType } from "@prisma/client"; import { PosterPayload } from "../poster/types";
export async function generatePoster(sessionId: string) { const session = await prisma.microSession.findUnique({ where: { id: sessionId }, include: { skill: true, steps: { orderBy: { position: "asc" } }, }, });
if (!session) throw new Error("Session not found");
const payload: PosterPayload = {
skillTitle: session.skill.title,
sessionMeta: {
delivery: session.deliveryMode === "IN_PERSON" ? "Granada" : "Online",
duration: ${session.durationMin} min,
price: "50€",
},
sections: [
{
key: "overview",
title: "Overview",
content: session.skill.description,
},
{
key: "highlights",
title: "Highlights",
content: session.steps.slice(0, 2).map(s => s.title).join(" · "),
},
{
key: "itinerary",
title: "Structure",
content: session.steps.map(s => s.title).join(" → "),
},
{
key: "audience",
title: "Audience",
content: "Professionals · Creators · Independent learners",
},
],
cta: {
label: "Book or Learn More",
href: /sessions/${session.id},
},
};
return prisma.asset.create({ data: { sessionId, type: AssetType.POSTER, payload, }, }); }
⚠️ Important No AI here. AI enhancement is a separate step (optional, non-authoritative), exactly like your AppScript toggle.
⸻
PART D — Poster page (rendering an Asset)
// app/assets/poster/[assetId]/page.tsx
import {prisma} from "@/lib/prisma"; import { PosterRenderer } from "../PosterRenderer";
export default async function PosterPage({ params }) { const asset = await prisma.asset.findUnique({ where: { id: params.assetId }, });
if (!asset) return null;
return <PosterRenderer poster={asset.payload as any} />; }
This page: • can be printed • can be exported to PDF • can be used as a QR landing page • can be screenshotted for social
Exactly as you described.
⸻
PART E — What we do next (as you requested)
NEXT STEP (locked order) 1. ✅ Poster renderer (DONE above) 2. ⏭ Skill-backed Cards page • /skills/[skillId]/cards • Reuse StudyCard • Steps come from SessionStep 3. ⏭ Poster ↔ Card linking (QR → cards → booking) 4. ⏭ Optional AI enhancement pipeline
⸻
One-sentence anchor (use this mentally)
Posters are render-only assets derived from sessions; sessions derive from skills; skills own the truth.
⸻
If you want, next message I will implement /skills/[skillId]/cards exactly, reusing your existing StudyCard with zero duplication.