"""Refresh live HTML, React, and Nuxt starter runtimes from the current catalogs.

Writes gitignored boilerplate trees. Snapshots are rebuilt separately by the operator.
"""
from __future__ import annotations

import json
from pathlib import Path

from src.domain.app_blocks.catalogs import canonical_feature_pack
from src.domain.app_blocks.hosts import merge_host_map
from src.domain.codegen.port_adapter import catalog_adapter_files
from src.domain.feature_packs.catalog_v2 import CATALOG_OVERLAYS
from src.domain.starter_library.catalog import known_contract_ids, merge_contract_map

ROOT = Path(__file__).resolve().parents[1]
BOIL = ROOT / "boilerplates"
MARKER = "p2hs-starter-refresh"

CSS_EXTRA = """
/* starter-shell */
.starter-button--secondary { background: var(--starter-color-text); }
.starter-button--destructive { background: var(--starter-color-danger); color: #fff; }
.starter-button--outline {
  background: transparent;
  color: var(--starter-color-text);
  border: 1px solid var(--starter-color-border);
}
.starter-button:focus-visible,
.starter-input:focus-visible,
.starter-select:focus-visible,
.starter-textarea:focus-visible,
.starter-tabs button:focus-visible {
  outline: 2px solid var(--starter-color-accent);
  outline-offset: 2px;
}
.starter-shell { max-width: 72rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
.starter-site-header, .starter-site-footer {
  display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center;
  gap: var(--starter-space-4); padding: var(--starter-space-4) 0;
}
.starter-site-header { border-bottom: 1px solid var(--starter-color-border); }
.starter-site-footer { border-top: 1px solid var(--starter-color-border); color: var(--starter-color-muted); }
.starter-nav { display: flex; flex-wrap: wrap; gap: var(--starter-space-4); }
.starter-nav a { color: var(--starter-color-text); font-weight: 600; text-decoration: none; }
.starter-hero { display: grid; gap: var(--starter-space-4); padding: var(--starter-space-8) 0; }
.starter-hero p { max-width: 40rem; color: var(--starter-color-muted); font-size: 1.05rem; }
.starter-grid { display: grid; gap: var(--starter-space-4); grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
.starter-card {
  display: grid; gap: var(--starter-space-2); padding: var(--starter-space-4);
  background: var(--starter-color-surface); border-radius: var(--starter-radius); box-shadow: var(--starter-shadow);
}
.starter-kanban { display: grid; gap: var(--starter-space-4); grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
.starter-split { display: grid; gap: var(--starter-space-4); grid-template-columns: minmax(16rem, 22rem) 1fr; }
.starter-dialog-backdrop {
  position: fixed; inset: 0; background: rgb(15 23 42 / 0.45);
  display: grid; place-items: center; z-index: 40; padding: 1rem;
}
.starter-dialog {
  width: min(100%, 32rem); background: var(--starter-color-surface);
  border-radius: var(--starter-radius); box-shadow: var(--starter-shadow); padding: var(--starter-space-6);
}
.starter-badge, .starter-avatar {
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 999px; font-weight: 600;
}
.starter-badge { padding: 0.15rem 0.55rem; font-size: 0.75rem; background: var(--starter-color-border); }
.starter-avatar { width: 2rem; height: 2rem; background: var(--starter-color-accent); color: var(--starter-color-accent-text); }
.starter-spinner {
  width: 1rem; height: 1rem; border: 2px solid var(--starter-color-border);
  border-top-color: var(--starter-color-accent); border-radius: 50%;
  animation: starter-spin 0.8s linear infinite;
}
@keyframes starter-spin { to { transform: rotate(360deg); } }
.starter-tabs { display: flex; flex-wrap: wrap; gap: var(--starter-space-2); }
.starter-tabs button { background: transparent; border: 1px solid var(--starter-color-border); border-radius: var(--starter-radius); padding: 0.4rem 0.7rem; }
.starter-tabs button[aria-selected="true"] { border-color: var(--starter-color-accent); color: var(--starter-color-accent); }
.starter-menu, .starter-listbox {
  display: grid; gap: 0.15rem; padding: 0.35rem; background: var(--starter-color-surface);
  border: 1px solid var(--starter-color-border); border-radius: var(--starter-radius); box-shadow: var(--starter-shadow);
}
.starter-menu button, .starter-listbox button { text-align: left; background: transparent; border: 0; padding: 0.45rem 0.6rem; border-radius: 0.4rem; }
.starter-menu button:hover, .starter-listbox button[aria-selected="true"] { background: var(--starter-color-bg); }
@media (max-width: 800px) { .starter-split { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) {
  .starter-skeleton__row, .starter-spinner { animation: none; }
}
"""

BLOCK_ENTITY = {
    "activity.timeline": "activity_entry",
    "asset.serialized": "serialized_asset",
    "aviation.maintenance": "maintenance_event",
    "billing.invoice": "invoice_record",
    "billing.subscription_operations": "subscription_record",
    "booking.appointment": "",
    "case.management": "case_record",
    "catalog.product": "catalog_item",
    "collaboration.conversation": "message",
    "content.collection": "",
    "content.events": "event_item",
    "content.faq": "faq_item",
    "content.gallery": "gallery_item",
    "content.locations": "business_location",
    "content.pricing": "pricing_tier",
    "content.publishing": "content_item",
    "content.services": "service_item",
    "content.team": "team_member",
    "content.testimonials": "testimonial",
    "crm.customer": "customer",
    "crm.lead": "lead",
    "crm.org_unit": "org_unit",
    "crm.organization": "organization",
    "crm.pipeline": "lead",
    "crm.team": "team",
    "documents": "document",
    "education.assessment": "assessment",
    "education.attendance": "attendance_record",
    "education.certificate": "certificate_record",
    "education.course_delivery": "course",
    "education.progress": "lesson_progress",
    "education.student": "enrollment",
    "files.attachments": "file_attachment",
    "finance.loan_operations": "loan_record",
    "inventory.stock": "inventory_record",
    "inventory.warehouse": "warehouse",
    "lab.result_review": "lab_result",
    "lab.sample": "lab_sample",
    "logistics.shipment": "shipment",
    "manufacturing.work_order": "work_order",
    "membership.member": "membership_record",
    "orders.order": "order_record",
    "property.unit": "property_unit",
    "requirements.checklist": "requirement_item",
    "support.ticket": "support_ticket",
    "task.management": "platform_task",
    "workflow.approval": "approval_request",
    "workflow.assignment": "assignment",
}

REUSE = {
    "nuxt_app": {
        ("content.faq", "list"): "components/starter/blocks/FaqList.vue",
        ("content.testimonials", "list"): "components/starter/blocks/TestimonialsGrid.vue",
        ("content.services", "list"): "components/starter/blocks/ServicesGrid.vue",
        ("content.team", "list"): "components/starter/blocks/TeamGrid.vue",
        ("content.locations", "list"): "components/starter/blocks/LocationsList.vue",
        ("content.pricing", "list"): "components/starter/blocks/PricingGrid.vue",
        ("content.gallery", "list"): "components/starter/blocks/GalleryGrid.vue",
        ("content.events", "list"): "components/starter/blocks/EventsList.vue",
        ("content.collection", "index"): "components/starter/blocks/ContentCollection.vue",
        ("content.collection", "grid"): "components/starter/blocks/ContentCollection.vue",
        ("calculation.calculator", "formula"): "components/starter/blocks/CalculationCalculator.vue",
        ("calculation.calculator", "result"): "components/starter/blocks/CalculationResult.vue",
        ("visualization.chart", "chart"): "components/starter/blocks/VisualizationChart.vue",
        ("visualization.chart", "metric"): "components/starter/blocks/VisualizationMetric.vue",
        ("booking.appointment", "book"): "components/starter/blocks/BookingForm.vue",
    },
    "react_app": {
        ("content.faq", "list"): "src/components/starter/content-blocks.jsx#FaqList",
        ("content.testimonials", "list"): "src/components/starter/content-blocks.jsx#TestimonialsGrid",
        ("content.services", "list"): "src/components/starter/content-blocks.jsx#ServicesGrid",
        ("content.team", "list"): "src/components/starter/content-blocks.jsx#TeamGrid",
        ("content.locations", "list"): "src/components/starter/content-blocks.jsx#LocationsList",
        ("content.pricing", "list"): "src/components/starter/content-blocks.jsx#PricingGrid",
        ("content.gallery", "list"): "src/components/starter/content-blocks.jsx#GalleryGrid",
        ("content.events", "list"): "src/components/starter/content-blocks.jsx#EventsList",
        ("content.collection", "index"): "src/components/starter/blocks.jsx#ContentCollection",
        ("content.collection", "grid"): "src/components/starter/blocks.jsx#ContentCollection",
    },
    "html": {
        ("content.faq", "list"): "js/starter-blocks.js#mountFaqList",
        ("content.testimonials", "list"): "js/starter-blocks.js#mountTestimonialsGrid",
        ("content.services", "list"): "js/starter-blocks.js#mountServicesGrid",
        ("content.team", "list"): "js/starter-blocks.js#mountTeamGrid",
        ("content.locations", "list"): "js/starter-blocks.js#mountLocationsList",
        ("content.pricing", "list"): "js/starter-blocks.js#mountPricingGrid",
        ("content.gallery", "list"): "js/starter-blocks.js#mountGalleryGrid",
        ("content.events", "list"): "js/starter-blocks.js#mountEventsList",
        ("content.collection", "index"): "js/starter-blocks.js#mountContentCollection",
        ("content.collection", "grid"): "js/starter-blocks.js#mountContentCollection",
    },
}

SHARED = {
    "nuxt_app": {
        "empty": "components/starter/EmptyState.vue",
        "config": "components/starter/ConfigurationRequired.vue",
        "error": "components/starter/ErrorState.vue",
        "loading": "components/starter/LoadingState.vue",
        "upload": "components/starter/FileUpload.vue",
        "chart": "components/starter/ChartBar.vue",
        "metric": "components/starter/MetricCard.vue",
        "calculator": "components/starter/CalculatorFormula.vue",
        "index": "components/starter/CollectionIndex.vue",
        "grid": "components/starter/CollectionGrid.vue",
        "nav": "components/starter/CollectionNav.vue",
        "pagination": "components/starter/CollectionPagination.vue",
    },
    "react_app": {
        "empty": "src/components/starter/library.jsx#EmptyState",
        "config": "src/components/starter/patterns.jsx#ConfigurationRequired",
        "error": "src/components/starter/patterns.jsx#ErrorState",
        "loading": "src/components/starter/patterns.jsx#LoadingState",
        "upload": "src/components/starter/FileUpload.jsx#FileUpload",
        "chart": "src/components/starter/library.jsx#ChartBar" if False else "src/components/starter/patterns.jsx#ChartHost",
        "metric": "src/components/starter/patterns.jsx#MetricHost",
        "calculator": "src/components/starter/patterns.jsx#CalculatorHost",
        "index": "src/components/starter/patterns.jsx#CollectionIndex",
        "grid": "src/components/starter/patterns.jsx#CollectionGrid",
        "nav": "src/components/starter/patterns.jsx#CollectionNav",
        "pagination": "src/components/starter/patterns.jsx#CollectionPagination",
    },
    "html": {
        "empty": "js/starter-library.js#renderEmptyState",
        "config": "js/starter-patterns.js#renderConfigurationRequired",
        "error": "js/starter-patterns.js#renderErrorState",
        "loading": "js/starter-patterns.js#renderLoadingState",
        "upload": "js/starter-patterns.js#renderFileUpload",
        "chart": "js/starter-patterns.js#renderChartHost",
        "metric": "js/starter-patterns.js#renderMetricHost",
        "calculator": "js/starter-patterns.js#renderCalculatorHost",
        "index": "js/starter-patterns.js#renderCollectionIndex",
        "grid": "js/starter-patterns.js#renderCollectionGrid",
        "nav": "js/starter-patterns.js#renderCollectionNav",
        "pagination": "js/starter-patterns.js#renderCollectionPagination",
    },
}


def write(path: Path, content: str, *, overwrite: bool = True) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists() and not overwrite:
        return
    path.write_text(content, encoding="utf-8", newline="\n")


def pascal(block: str, slot: str) -> str:
    raw = f"{block}_{slot}".replace(".", "_")
    return "".join(part.capitalize() for part in raw.split("_") if part)


def title_for(block: str, slot: str) -> str:
    return f"{block.split('.')[-1].replace('_', ' ').title()} {slot.replace('_', ' ')}".strip()


def entity_of(block: str) -> str:
    if block in BLOCK_ENTITY:
        return BLOCK_ENTITY[block]
    return ""


def infer_type(key: str) -> str:
    if key.endswith("_at") or key.endswith("_date") or key in {"due_date", "start_date"}:
        return "date"
    if key in {"amount", "price", "quantity", "total", "qty"} or key.endswith("_count"):
        return "number"
    if key.startswith("is_") or key.startswith("has_"):
        return "boolean"
    if key == "status":
        return "select"
    return "text"


def schema_for(entity: str) -> dict:
    for overlay in CATALOG_OVERLAYS.values():
        policy = (overlay.get("field_policy") or {}).get(entity)
        if not policy:
            continue
        fields = []
        for key, flags in policy.items():
            field = {"key": key, "name": key.replace("_", " ").title(), "type": infer_type(key)}
            if field["type"] == "select":
                field["options"] = ["draft", "active", "closed"]
            if isinstance(flags, dict):
                field.update({k: v for k, v in flags.items() if k != "type"})
            fields.append(field)
        if not any(item["key"] == "name" or item["key"] == "title" for item in fields):
            fields.insert(0, {"key": "name", "name": "Name", "type": "text", "required": True})
        return {"fields": fields}
    return {
        "fields": [
            {"key": "name", "name": "Name", "type": "text", "required": True},
            {"key": "status", "name": "Status", "type": "select", "options": ["draft", "active", "closed"]},
            {"key": "notes", "name": "Notes", "type": "text"},
        ]
    }


def columns_of(schema: dict) -> list[dict]:
    cols = []
    for field in schema.get("fields") or []:
        key = field.get("key")
        if not key or key in {"id"}:
            continue
        cols.append({"key": key, "label": field.get("name") or key.replace("_", " ").title()})
        if len(cols) == 4:
            break
    return cols or [{"key": "name", "label": "Name"}]


def kind_of(item: dict) -> str:
    slot = item["slot"]
    contract = item.get("component_contract") or ""
    presentation = item.get("presentation") or "default"
    query = str(item.get("query_source") or "")
    if contract == "state.empty" or slot == "empty":
        return "empty"
    if contract == "state.configuration_required" or slot == "unavailable":
        return "config"
    if query.startswith("port:imports"):
        return "import"
    if presentation == "status_grouping":
        return "status_grouping"
    if presentation == "kanban_grouping":
        return "kanban"
    if presentation == "split_list_detail":
        return "split"
    if presentation == "compact_dashboard":
        return "summary"
    if presentation == "relationship_view":
        return "relationship"
    if presentation == "portal_read_only":
        return "portal"
    if presentation == "calendar" or contract == "record.calendar":
        return "calendar"
    if presentation == "staff_table":
        return "staff_table"
    if slot == "status" and contract == "record.edit":
        return "status_edit"
    if contract == "file.upload" or slot == "upload":
        return "upload"
    if contract == "calculator.formula":
        return "calculator"
    if contract.startswith("chart."):
        return "chart"
    if contract == "metric.card":
        return "metric"
    if contract == "collection.index":
        return "index"
    if contract == "collection.grid":
        return "grid"
    if contract == "collection.list":
        return "list_collection"
    if contract == "collection.nav":
        return "nav"
    if contract == "collection.pagination":
        return "pagination"
    if contract == "record.table":
        return "table"
    if contract == "record.detail":
        return "detail"
    if contract == "record.create":
        return "create"
    if contract == "record.edit":
        return "edit"
    if contract == "record.summary":
        return "summary"
    if contract == "form.standard" and item["block"] == "booking.appointment" and slot == "membership_gate":
        return "membership_gate"
    if contract == "form.standard" and item["block"] in {
        "billing.portal",
        "billing.tax",
        "billing.subscription_operations",
    }:
        return "connector"
    if contract == "form.standard":
        return "form"
    return "table" if entity_of(item["block"]) else "empty"


def append_css() -> None:
    paths = [
        BOIL / "nuxt_app" / "assets" / "css" / "starter-tokens.css",
        BOIL / "react_app" / "src" / "starter-tokens.css",
        BOIL / "html" / "css" / "starter-tokens.css",
    ]
    for path in paths:
        text = path.read_text(encoding="utf-8")
        if "starter-shell" in text:
            continue
        path.write_text(text.rstrip() + "\n" + CSS_EXTRA, encoding="utf-8", newline="\n")


def write_nuxt_patterns() -> None:
    base = BOIL / "nuxt_app" / "components" / "starter"
    files = {
        "ConfigurationRequired.vue": """<script setup>
defineProps({
  title: { type: String, default: "Setup required" },
  description: { type: String, default: "This capability is not available until it is connected." },
  audience: { type: String, default: "customer" },
});
</script>
<template>
  <section class="starter-alert" role="status">
    <h2>{{ title }}</h2>
    <p>{{ audience === "admin" ? description : "This is not available yet." }}</p>
  </section>
</template>
""",
        "ErrorState.vue": """<script setup>
defineProps({ title: { type: String, default: "Something went wrong" }, description: { type: String, default: "" } });
</script>
<template>
  <section class="starter-alert starter-alert--error" role="alert">
    <h2>{{ title }}</h2>
    <p v-if="description">{{ description }}</p>
  </section>
</template>
""",
        "LoadingState.vue": """<script setup>
defineProps({ label: { type: String, default: "Loading" } });
</script>
<template>
  <p class="starter-field-hint" role="status"><span class="starter-spinner" aria-hidden="true"></span> {{ label }}</p>
</template>
""",
        "FormSection.vue": """<script setup>
defineProps({ legend: { type: String, default: "" }, description: { type: String, default: "" } });
</script>
<template>
  <fieldset class="starter-stack">
    <legend v-if="legend">{{ legend }}</legend>
    <p v-if="description" class="starter-field-hint">{{ description }}</p>
    <slot />
  </fieldset>
</template>
""",
        "SettingsLayout.vue": """<script setup>
defineProps({ title: { type: String, default: "Settings" }, description: { type: String, default: "" } });
</script>
<template>
  <main class="starter-page">
    <div class="starter-shell">
      <header class="starter-page-header">
        <div class="starter-page-header__copy">
          <h1>{{ title }}</h1>
          <p v-if="description">{{ description }}</p>
        </div>
      </header>
      <div class="starter-split">
        <nav class="starter-stack" aria-label="Settings"><slot name="nav" /></nav>
        <div class="starter-stack"><slot /></div>
      </div>
    </div>
  </main>
</template>
""",
        "CollectionIndex.vue": """<script setup>
defineProps({
  title: { type: String, default: "Collection" },
  description: { type: String, default: "" },
  items: { type: Array, default: () => [] },
});
</script>
<template>
  <section class="starter-stack">
    <StarterPageHeader :title="title" :description="description" />
    <StarterEmptyState v-if="!items.length" title="Nothing here yet" />
    <slot v-else :items="items" />
  </section>
</template>
""",
        "CollectionGrid.vue": """<script setup>
defineProps({ items: { type: Array, default: () => [] }, titleKey: { type: String, default: "title" }, excerptKey: { type: String, default: "excerpt" } });
function text(item, key) { return item?.[key] || item?.data?.[key] || ""; }
</script>
<template>
  <div class="starter-grid">
    <article v-for="(item, index) in items" :key="item.id || index" class="starter-card">
      <h2>{{ text(item, titleKey) || "Untitled" }}</h2>
      <p v-if="text(item, excerptKey)">{{ text(item, excerptKey) }}</p>
    </article>
  </div>
</template>
""",
        "CollectionList.vue": """<script setup>
const props = defineProps({
  title: { type: String, default: "" },
  items: { type: Array, default: () => [] },
  entityKey: { type: String, default: "" },
  titleKey: { type: String, default: "title" },
});
const p2hs = useP2hs();
const rows = ref([]);
const loading = ref(false);
const error = ref("");
const shown = computed(() => (props.items.length ? props.items : rows.value));
onMounted(load);
async function load() {
  if (!props.entityKey || props.items.length) return;
  loading.value = true;
  try { rows.value = p2hs.inboxRows(await p2hs.listRecords(props.entityKey, "?limit=50")); }
  catch (err) { error.value = err?.message || "Unable to load."; }
  finally { loading.value = false; }
}
function text(item) { return item?.[props.titleKey] || item?.data?.[props.titleKey] || item?.data?.name || item?.name || "Untitled"; }
</script>
<template>
  <section class="starter-stack">
    <h2 v-if="title">{{ title }}</h2>
    <p v-if="loading">Loading…</p>
    <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <StarterEmptyState v-else-if="!shown.length" title="Nothing here yet" />
    <ul v-else class="starter-stack">
      <li v-for="(item, index) in shown" :key="item.id || index">{{ text(item) }}</li>
    </ul>
  </section>
</template>
""",
        "CollectionNav.vue": """<script setup>
defineProps({ links: { type: Array, default: () => [] } });
</script>
<template>
  <nav class="starter-nav" aria-label="Collection">
    <a v-for="link in links" :key="link.href" :href="link.href" :aria-current="link.active ? 'page' : undefined">{{ link.label }}</a>
  </nav>
</template>
""",
        "CollectionPagination.vue": """<script setup>
const props = defineProps({ page: { type: Number, default: 1 }, hasMore: { type: Boolean, default: false } });
const emit = defineEmits(["update:page"]);
</script>
<template>
  <nav class="starter-pagination" aria-label="Pagination">
    <button class="starter-button starter-button--outline" type="button" :disabled="page <= 1" @click="emit('update:page', page - 1)">Previous</button>
    <span>Page {{ page }}</span>
    <button class="starter-button starter-button--outline" type="button" :disabled="!hasMore" @click="emit('update:page', page + 1)">Next</button>
  </nav>
</template>
""",
        "RecordSummary.vue": """<script setup>
const props = defineProps({ entityKey: { type: String, required: true }, title: { type: String, default: "Overview" } });
const p2hs = useP2hs();
const loading = ref(true);
const error = ref("");
const counts = ref([]);
onMounted(load);
async function load() {
  loading.value = true;
  try {
    const rows = p2hs.inboxRows(await p2hs.listRecords(props.entityKey, "?limit=100"));
    const map = new Map();
    for (const row of rows) {
      const data = row?.data || row;
      const key = String(data.status || "total");
      map.set(key, (map.get(key) || 0) + 1);
    }
    if (!map.has("total")) map.set("total", rows.length);
    counts.value = [...map.entries()].map(([label, value]) => ({ label, value }));
  } catch (err) {
    error.value = err?.message || "Unable to load summary.";
  } finally { loading.value = false; }
}
</script>
<template>
  <section class="starter-stack">
    <h2>{{ title }}</h2>
    <p v-if="loading">Loading…</p>
    <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <StarterEmptyState v-else-if="!counts.length" title="No records yet" />
    <div v-else class="starter-grid">
      <article v-for="item in counts" :key="item.label" class="starter-card">
        <p class="starter-field-hint">{{ item.label }}</p>
        <strong>{{ item.value }}</strong>
      </article>
    </div>
  </section>
</template>
""",
        "RecordCalendar.vue": """<script setup>
const props = defineProps({
  entityKey: { type: String, default: "" },
  dateKey: { type: String, default: "start_date" },
  resourceKey: { type: String, default: "" },
  mode: { type: String, default: "records" },
});
const p2hs = useP2hs();
const loading = ref(true);
const error = ref("");
const days = ref([]);
onMounted(load);
async function load() {
  loading.value = true;
  error.value = "";
  try {
    if (props.mode === "booking" && props.resourceKey) {
      const payload = await p2hs.getBookingAvailability(props.resourceKey);
      const slots = payload?.slots || payload?.days || payload?.items || [];
      days.value = Array.isArray(slots) ? slots : [];
    } else if (props.entityKey) {
      const rows = p2hs.inboxRows(await p2hs.listRecords(props.entityKey, "?limit=100"));
      const map = new Map();
      for (const row of rows) {
        const data = row?.data || row;
        const day = String(data[props.dateKey] || data.date || "unscheduled");
        if (!map.has(day)) map.set(day, []);
        map.get(day).push(data.name || data.title || row.id);
      }
      days.value = [...map.entries()].map(([day, items]) => ({ day, items }));
    }
  } catch (err) {
    error.value = err?.message || "Unable to load the calendar.";
  } finally { loading.value = false; }
}
</script>
<template>
  <section class="starter-stack" aria-label="Calendar">
    <p v-if="loading">Loading…</p>
    <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <StarterEmptyState v-else-if="!days.length" title="Nothing scheduled" />
    <div v-else class="starter-grid">
      <article v-for="(day, index) in days" :key="day.day || index" class="starter-card">
        <h2>{{ day.day || day.label || day.start || "Slot" }}</h2>
        <ul v-if="day.items"><li v-for="item in day.items" :key="item">{{ item }}</li></ul>
      </article>
    </div>
  </section>
</template>
""",
        "StatusGrouping.vue": """<script setup>
const props = defineProps({
  entityKey: { type: String, required: true },
  groupBy: { type: String, default: "status" },
  columns: { type: Array, default: () => [] },
  emptyTitle: { type: String, default: "No records yet" },
});
const emit = defineEmits(["select"]);
const p2hs = useP2hs();
const loading = ref(true);
const error = ref("");
const rows = ref([]);
function dataOf(row) { return row?.data && typeof row.data === "object" ? row.data : row || {}; }
const groups = computed(() => {
  const map = new Map();
  for (const row of rows.value) {
    const key = String(dataOf(row)[props.groupBy] || "unassigned");
    if (!map.has(key)) map.set(key, []);
    map.get(key).push(row);
  }
  return [...map.entries()].map(([status, items]) => ({ status, items }));
});
onMounted(load);
async function load() {
  loading.value = true;
  try { rows.value = p2hs.inboxRows(await p2hs.listRecords(props.entityKey, "?limit=100")); }
  catch (err) { error.value = err?.message || "Unable to load records."; rows.value = []; }
  finally { loading.value = false; }
}
</script>
<template>
  <div class="starter-stack">
    <p v-if="loading">Loading…</p>
    <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <StarterEmptyState v-else-if="!rows.length" :title="emptyTitle" />
    <section v-for="group in groups" :key="group.status" class="starter-stack">
      <h2>{{ group.status }}</h2>
      <StarterRecordTable :columns="columns" :rows="group.items" @select="emit('select', $event)" />
    </section>
  </div>
</template>
""",
        "KanbanGrouping.vue": """<script setup>
const props = defineProps({
  entityKey: { type: String, required: true },
  groupBy: { type: String, default: "status" },
  statuses: { type: Array, default: () => ["draft", "active", "closed"] },
});
const p2hs = useP2hs();
const loading = ref(true);
const error = ref("");
const rows = ref([]);
function dataOf(row) { return row?.data && typeof row.data === "object" ? row.data : row || {}; }
function idOf(row) { return row.id || row.record_id; }
function labelOf(row) { const data = dataOf(row); return data.name || data.title || data.email || idOf(row); }
const groups = computed(() => props.statuses.map((status) => ({
  status,
  items: rows.value.filter((row) => String(dataOf(row)[props.groupBy] || "draft") === status),
})));
onMounted(load);
async function load() {
  loading.value = true;
  try { rows.value = p2hs.inboxRows(await p2hs.listRecords(props.entityKey, "?limit=100")); }
  catch (err) { error.value = err?.message || "Unable to load records."; }
  finally { loading.value = false; }
}
async function move(row, status) {
  await p2hs.patchRecord(props.entityKey, idOf(row), { [props.groupBy]: status });
  await load();
}
</script>
<template>
  <div>
    <p v-if="loading">Loading…</p>
    <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <div v-else class="starter-kanban">
      <section v-for="group in groups" :key="group.status" class="starter-card">
        <h2>{{ group.status }}</h2>
        <article v-for="row in group.items" :key="idOf(row)" class="starter-stack">
          <strong>{{ labelOf(row) }}</strong>
          <label class="starter-field">Move
            <select class="starter-select" :value="group.status" @change="move(row, $event.target.value)">
              <option v-for="status in statuses" :key="status" :value="status">{{ status }}</option>
            </select>
          </label>
        </article>
      </section>
    </div>
  </div>
</template>
""",
        "SplitListDetail.vue": """<script setup>
const props = defineProps({
  entityKey: { type: String, required: true },
  columns: { type: Array, default: () => [] },
  fields: { type: Array, default: () => [] },
  title: { type: String, default: "Record" },
});
const selected = ref("");
function onSelect(row) { selected.value = row?.id || row?.record_id || ""; }
</script>
<template>
  <div class="starter-split">
    <StarterBlocksAdminRecordTable :entity-key="entityKey" :columns="columns" @select="onSelect" />
    <StarterBlocksAdminRecordDetail v-if="selected" :entity-key="entityKey" :record-id="selected" :title="title" :fields="fields" />
    <StarterEmptyState v-else title="Select a record" />
  </div>
</template>
""",
        "RelationshipView.vue": """<script setup>
const props = defineProps({
  entityKey: { type: String, required: true },
  subjectEntityKey: { type: String, default: "" },
  subjectRecordId: { type: String, default: "" },
  columns: { type: Array, default: () => [] },
});
const emit = defineEmits(["select"]);
const p2hs = useP2hs();
const loading = ref(false);
const error = ref("");
const rows = ref([]);
watch(() => [props.subjectEntityKey, props.subjectRecordId, props.entityKey], load, { immediate: true });
async function load() {
  if (!props.subjectEntityKey || !props.subjectRecordId) { rows.value = []; return; }
  loading.value = true;
  const filter = { subject_entity_key: props.subjectEntityKey, subject_record_id: props.subjectRecordId };
  const search = `?limit=50&filter=${encodeURIComponent(JSON.stringify(filter))}`;
  try { rows.value = p2hs.inboxRows(await p2hs.listRecords(props.entityKey, search)); }
  catch (err) { error.value = err?.message || "Unable to load related records."; }
  finally { loading.value = false; }
}
</script>
<template>
  <div class="starter-stack">
    <StarterEmptyState v-if="!subjectRecordId" title="Select a record to see related items" />
    <p v-else-if="loading">Loading…</p>
    <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <StarterRecordTable v-else :columns="columns" :rows="rows" empty-title="No related records" @select="emit('select', $event)" />
  </div>
</template>
""",
        "PortalReadOnly.vue": """<script setup>
defineProps({
  entityKey: { type: String, required: true },
  recordId: { type: String, default: "" },
  title: { type: String, default: "Record" },
  fields: { type: Array, default: () => [] },
});
</script>
<template>
  <section class="starter-stack" aria-label="Read only">
    <p class="starter-field-hint">This view is read only.</p>
    <StarterBlocksAdminRecordDetail :entity-key="entityKey" :record-id="recordId" :title="title" :fields="fields" />
  </section>
</template>
""",
        "ConnectorAction.vue": """<script setup>
const props = defineProps({
  title: { type: String, default: "Connected action" },
  action: { type: String, default: "" },
  connectionId: { type: String, default: "" },
  submitLabel: { type: String, default: "Continue" },
});
const p2hs = useP2hs();
const loading = ref(false);
const error = ref("");
const success = ref("");
const ready = computed(() => Boolean(props.action && props.connectionId));
async function submit() {
  if (!ready.value) return;
  loading.value = true;
  error.value = "";
  success.value = "";
  try {
    const result = await p2hs.executeIntegration({
      connection_id: props.connectionId,
      action: props.action,
      input: {},
    });
    const url = result?.url || result?.result?.url || "";
    if (url) window.location.assign(url);
    else success.value = "Request accepted.";
  } catch (err) {
    error.value = err?.message || "Unable to start that action.";
  } finally { loading.value = false; }
}
</script>
<template>
  <StarterConfigurationRequired v-if="!ready" :title="title" audience="admin" description="Connect the provider before this action can run." />
  <form v-else class="starter-stack" @submit.prevent="submit">
    <h2>{{ title }}</h2>
    <p v-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <p v-if="success" role="status">{{ success }}</p>
    <button class="starter-button" type="submit" :disabled="loading">{{ loading ? "Working…" : submitLabel }}</button>
  </form>
</template>
""",
        "ImportStep.vue": """<script setup>
const props = defineProps({ step: { type: String, default: "import_form" }, entityKey: { type: String, default: "" } });
const p2hs = useP2hs();
const sessionId = ref("");
const rows = ref([]);
const mapping = reactive({});
const loading = ref(false);
const error = ref("");
const message = ref("");
async function run(work) {
  loading.value = true;
  error.value = "";
  try { return await work(); }
  catch (err) { error.value = err?.message || "Import request failed."; return null; }
  finally { loading.value = false; }
}
async function createSession() {
  const created = await run(() => p2hs.createImportSession({ entity_key: props.entityKey }));
  sessionId.value = created?.id || created?.session_id || "";
  message.value = sessionId.value ? "Import session created." : "";
}
async function preview() {
  const payload = await run(() => p2hs.previewImportSession(sessionId.value));
  rows.value = payload?.rows || payload?.items || [];
}
async function mapFields() {
  await run(() => p2hs.mapImportFields(sessionId.value, { mapping: { ...mapping } }));
  message.value = "Field mapping saved.";
}
async function validate() {
  const payload = await run(() => p2hs.validateImportSession(sessionId.value));
  rows.value = payload?.errors || payload?.rows || [];
  message.value = "Validation finished.";
}
async function commit() {
  await run(() => p2hs.commitImportSession(sessionId.value));
  message.value = "Import committed.";
}
</script>
<template>
  <section class="starter-stack">
    <h2>Import</h2>
    <label class="starter-field">Session
      <input v-model="sessionId" class="starter-input" />
    </label>
    <p v-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <p v-if="message" role="status">{{ message }}</p>
    <button v-if="step === 'import_form'" class="starter-button" type="button" :disabled="loading" @click="createSession">Create import</button>
    <button v-else-if="step === 'preview'" class="starter-button" type="button" :disabled="loading || !sessionId" @click="preview">Preview rows</button>
    <form v-else-if="step === 'mapping'" class="starter-stack" @submit.prevent="mapFields">
      <label class="starter-field">Source column <input v-model="mapping.source" class="starter-input" /></label>
      <label class="starter-field">Record field <input v-model="mapping.target" class="starter-input" /></label>
      <button class="starter-button" type="submit" :disabled="loading || !sessionId">Save mapping</button>
    </form>
    <button v-else-if="step === 'validation'" class="starter-button" type="button" :disabled="loading || !sessionId" @click="validate">Validate</button>
    <button v-else class="starter-button" type="button" :disabled="loading || !sessionId" @click="commit">Commit import</button>
    <StarterRecordTable v-if="rows.length" :columns="[{key:'message',label:'Result'}]" :rows="rows" />
  </section>
</template>
""",
        "StaffResources.vue": """<script setup>
const p2hs = useP2hs();
const loading = ref(true);
const error = ref("");
const rows = ref([]);
onMounted(async () => {
  try { rows.value = p2hs.inboxRows(await p2hs.listBookingResources()); }
  catch (err) { error.value = err?.message || "Unable to load resources."; }
  finally { loading.value = false; }
});
</script>
<template>
  <div class="starter-stack">
    <p v-if="loading">Loading resources…</p>
    <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
    <StarterEmptyState v-else-if="!rows.length" title="No booking resources yet" />
    <StarterRecordTable v-else :columns="[{key:'name',label:'Resource'},{key:'status',label:'Status'}]" :rows="rows" />
  </div>
</template>
""",
        "MembershipGate.vue": """<script setup>
const p2hs = useP2hs();
const loading = ref(true);
const error = ref("");
const active = ref(false);
onMounted(async () => {
  try {
    const rows = p2hs.inboxRows(await p2hs.listRecords("membership_record", "?limit=20"));
    active.value = rows.some((row) => String((row.data || row).status || "") === "active");
  } catch (err) {
    error.value = err?.message || "Unable to check membership.";
  } finally { loading.value = false; }
});
</script>
<template>
  <p v-if="loading">Checking membership…</p>
  <p v-else-if="error" class="starter-alert starter-alert--error" role="alert">{{ error }}</p>
  <StarterEmptyState v-else-if="!active" title="An active membership is required" description="Booking stays closed until a membership is active. The booking service remains the authority." />
  <p v-else role="status">Membership is active. You can continue to booking.</p>
</template>
""",
    }
    primitives = {
        "UiAlert.vue": '<script setup>\ndefineProps({ title: { type: String, default: "" }, tone: { type: String, default: "info" } });\n</script>\n<template><section class="starter-alert" :class="tone === \'error\' ? \'starter-alert--error\' : \'\'" role="status"><strong v-if="title">{{ title }}</strong><p><slot /></p></section></template>\n',
        "UiAvatar.vue": '<script setup>\ndefineProps({ label: { type: String, default: "" } });\n</script>\n<template><span class="starter-avatar" aria-hidden="true">{{ (label || "?").slice(0, 1).toUpperCase() }}</span></template>\n',
        "UiBadge.vue": '<script setup>\ndefineProps({ label: { type: String, default: "" } });\n</script>\n<template><span class="starter-badge">{{ label }}<slot /></span></template>\n',
        "UiCheckbox.vue": '<script setup>\ndefineProps({ label: { type: String, default: "" }, modelValue: { type: Boolean, default: false } });\nconst emit = defineEmits(["update:modelValue"]);\n</script>\n<template><label class="starter-field"><input type="checkbox" :checked="modelValue" @change="emit(\'update:modelValue\', $event.target.checked)" /> {{ label }}</label></template>\n',
        "UiSelect.vue": '<script setup>\ndefineProps({ label: { type: String, default: "" }, modelValue: { type: String, default: "" }, options: { type: Array, default: () => [] } });\nconst emit = defineEmits(["update:modelValue"]);\n</script>\n<template><label class="starter-field">{{ label }}<select class="starter-select" :value="modelValue" @change="emit(\'update:modelValue\', $event.target.value)"><option value="">Select</option><option v-for="option in options" :key="option.value || option" :value="option.value || option">{{ option.label || option }}</option></select></label></template>\n',
        "UiRadio.vue": '<script setup>\ndefineProps({ label: { type: String, default: "" }, name: { type: String, default: "choice" }, options: { type: Array, default: () => [] }, modelValue: { type: String, default: "" } });\nconst emit = defineEmits(["update:modelValue"]);\n</script>\n<template><fieldset class="starter-stack"><legend>{{ label }}</legend><label v-for="option in options" :key="option.value || option"><input type="radio" :name="name" :value="option.value || option" :checked="modelValue === (option.value || option)" @change="emit(\'update:modelValue\', option.value || option)" /> {{ option.label || option }}</label></fieldset></template>\n',
        "UiSwitch.vue": '<script setup>\ndefineProps({ label: { type: String, default: "" }, modelValue: { type: Boolean, default: false } });\nconst emit = defineEmits(["update:modelValue"]);\n</script>\n<template><label class="starter-field"><input type="checkbox" role="switch" :checked="modelValue" :aria-checked="modelValue" @change="emit(\'update:modelValue\', $event.target.checked)" /> {{ label }}</label></template>\n',
        "UiSpinner.vue": '<script setup>\ndefineProps({ label: { type: String, default: "Loading" } });\n</script>\n<template><span class="starter-spinner" role="status" :aria-label="label"></span></template>\n',
        "UiCurrencyInput.vue": '<script setup>\ndefineProps({ label: { type: String, default: "Amount" }, modelValue: { type: [String, Number], default: "" }, currency: { type: String, default: "USD" } });\nconst emit = defineEmits(["update:modelValue"]);\n</script>\n<template><label class="starter-field">{{ label }}<input class="starter-input" inputmode="decimal" :value="modelValue" :aria-describedby="currency" @input="emit(\'update:modelValue\', $event.target.value)" /></label></template>\n',
        "UiTooltip.vue": '<script setup>\ndefineProps({ text: { type: String, default: "" } });\n</script>\n<template><span :title="text"><slot /></span></template>\n',
        "UiToast.vue": '<script setup>\ndefineProps({ message: { type: String, default: "" } });\n</script>\n<template><p v-if="message" class="starter-alert" role="status">{{ message }}</p></template>\n',
        "UiDropdown.vue": '<script setup>\nconst open = ref(false);\ndefineProps({ label: { type: String, default: "Menu" } });\n</script>\n<template><div><button class="starter-button starter-button--outline" type="button" :aria-expanded="open" @click="open = !open">{{ label }}</button><div v-if="open" class="starter-menu" role="menu"><slot /></div></div></template>\n',
        "UiDrawer.vue": '<script setup>\ndefineProps({ open: { type: Boolean, default: false }, title: { type: String, default: "" } });\nconst emit = defineEmits(["close"]);\n</script>\n<template><div v-if="open" class="starter-dialog-backdrop" @click.self="emit(\'close\')"><aside class="starter-dialog" role="dialog" :aria-label="title"><header><strong>{{ title }}</strong><button class="starter-button starter-button--outline" type="button" @click="emit(\'close\')">Close</button></header><slot /></aside></div></template>\n',
        "UiDialog.vue": '<script setup>\nconst props = defineProps({ open: { type: Boolean, default: false }, title: { type: String, default: "Dialog" } });\nconst emit = defineEmits(["close"]);\nfunction onKey(event) { if (event.key === "Escape") emit("close"); }\nwatch(() => props.open, (value) => { if (value) window.addEventListener("keydown", onKey); else window.removeEventListener("keydown", onKey); });\n</script>\n<template><div v-if="open" class="starter-dialog-backdrop" @click.self="emit(\'close\')"><section class="starter-dialog" role="dialog" aria-modal="true" :aria-label="title"><h2>{{ title }}</h2><slot /><button class="starter-button starter-button--outline" type="button" @click="emit(\'close\')">Close</button></section></div></template>\n',
        "UiTabs.vue": '<script setup>\nconst props = defineProps({ tabs: { type: Array, default: () => [] }, modelValue: { type: String, default: "" } });\nconst emit = defineEmits(["update:modelValue"]);\nconst current = computed(() => props.modelValue || props.tabs[0]?.id || "");\n</script>\n<template><div><div class="starter-tabs" role="tablist"><button v-for="tab in tabs" :key="tab.id" type="button" role="tab" :aria-selected="current === tab.id" @click="emit(\'update:modelValue\', tab.id)">{{ tab.label }}</button></div><slot :name="current" /></div></template>\n',
        "UiRichtext.vue": '<script setup>\ndefineProps({ label: { type: String, default: "Notes" }, modelValue: { type: String, default: "" } });\nconst emit = defineEmits(["update:modelValue"]);\n</script>\n<template><label class="starter-field">{{ label }}<textarea class="starter-textarea" rows="6" :value="modelValue" @input="emit(\'update:modelValue\', $event.target.value)"></textarea></label></template>\n',
        "UiCombobox.vue": '<script setup>\nconst props = defineProps({ label: { type: String, default: "Choose" }, options: { type: Array, default: () => [] }, modelValue: { type: String, default: "" } });\nconst emit = defineEmits(["update:modelValue"]);\nconst query = ref("");\nconst open = ref(false);\nconst filtered = computed(() => props.options.filter((option) => String(option.label || option).toLowerCase().includes(query.value.toLowerCase())));\nfunction choose(option) { emit("update:modelValue", option.value || option); open.value = false; }\n</script>\n<template><div class="starter-field"><label>{{ label }}<input class="starter-input" role="combobox" :aria-expanded="open" v-model="query" @focus="open = true" /></label><div v-if="open" class="starter-listbox" role="listbox"><button v-for="option in filtered" :key="option.value || option" type="button" role="option" @click="choose(option)">{{ option.label || option }}</button><p v-if="!filtered.length">No matches</p></div></div></template>\n',
        "UiCommandMenu.vue": '<script setup>\nconst props = defineProps({ commands: { type: Array, default: () => [] } });\nconst open = ref(false);\nconst query = ref("");\nconst matches = computed(() => props.commands.filter((item) => item.label.toLowerCase().includes(query.value.toLowerCase())));\nfunction onKey(event) { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); open.value = !open.value; } if (event.key === "Escape") open.value = false; }\nonMounted(() => window.addEventListener("keydown", onKey));\nonBeforeUnmount(() => window.removeEventListener("keydown", onKey));\n</script>\n<template><div><button class="starter-button starter-button--outline" type="button" @click="open = true">Commands</button><StarterUiDialog :open="open" title="Commands" @close="open = false"><input v-model="query" class="starter-input" aria-label="Search commands" /><div class="starter-listbox"><button v-for="item in matches" :key="item.id" type="button" @click="item.run?.(); open = false">{{ item.label }}</button></div></StarterUiDialog></div></template>\n',
    }
    for name, content in {**files, **primitives}.items():
        write(base / name, f"<!-- {MARKER} -->\n" + content)


def nuxt_wrapper(name: str, kind: str, entity: str, heading: str, columns: list, fields: list, schema: dict, slot: str) -> str:
    cols = json.dumps(columns)
    flds = json.dumps(fields)
    schema_json = json.dumps(schema)
    if kind == "table":
        body = f'<StarterBlocksAdminRecordTable entity-key="{entity}" :columns=\'{cols}\' empty-title="No records yet" />'
    elif kind == "detail":
        body = f'<StarterBlocksAdminRecordDetail entity-key="{entity}" :record-id="recordId" title="{heading}" :fields=\'{flds}\' />'
    elif kind == "create":
        body = f'<StarterRecordCreate entity-key="{entity}" :schema="schema" heading="{heading}" @created="emit(\'created\', $event)" />'
    elif kind == "edit":
        body = f'<StarterRecordEdit entity-key="{entity}" :record-id="recordId" :schema="schema" heading="{heading}" @updated="emit(\'updated\', $event)" />'
    elif kind == "status_edit":
        body = f'<StarterRecordEdit entity-key="{entity}" :record-id="recordId" :schema="schema" :visible-fields="[\'status\']" heading="Update status" submit-label="Update status" />'
    elif kind == "status_grouping":
        body = f'<StarterStatusGrouping entity-key="{entity}" :columns=\'{cols}\' />'
    elif kind == "kanban":
        body = f'<StarterKanbanGrouping entity-key="{entity}" />'
    elif kind == "split":
        body = f'<StarterSplitListDetail entity-key="{entity}" :columns=\'{cols}\' :fields=\'{flds}\' title="{heading}" />'
    elif kind == "summary":
        body = f'<StarterRecordSummary entity-key="{entity}" title="{heading}" />'
    elif kind == "relationship":
        body = f'<StarterRelationshipView entity-key="{entity}" :columns=\'{cols}\' :subject-entity-key="subjectEntityKey" :subject-record-id="subjectRecordId" />'
    elif kind == "portal":
        body = f'<StarterPortalReadOnly entity-key="{entity}" :record-id="recordId" title="{heading}" :fields=\'{flds}\' />'
    elif kind == "calendar":
        mode = "booking" if not entity else "records"
        body = f'<StarterRecordCalendar entity-key="{entity}" mode="{mode}" :resource-key="resourceKey" />'
    elif kind == "staff_table":
        body = "<StarterStaffResources />"
    elif kind == "import":
        body = f'<StarterImportStep step="{slot}" entity-key="{entity}" />'
    elif kind == "connector":
        body = f'<StarterConnectorAction title="{heading}" />'
    elif kind == "membership_gate":
        body = "<StarterMembershipGate />"
    elif kind == "form":
        body = f'<StarterStandardForm heading="{heading}" :fields=\'{flds}\' @submit="emit(\'submit\', $event)" />'
    elif kind == "list_collection":
        body = f'<StarterCollectionList title="{heading}" entity-key="{entity}" />'
    else:
        body = f'<StarterEmptyState title="{heading}" />'
    props = ""
    if kind in {"detail", "edit", "status_edit", "portal"}:
        props = 'defineProps({ recordId: { type: String, default: "" } });\n'
    elif kind == "relationship":
        props = 'defineProps({ subjectEntityKey: { type: String, default: "" }, subjectRecordId: { type: String, default: "" } });\n'
    elif kind == "calendar":
        props = 'defineProps({ resourceKey: { type: String, default: "" } });\n'
    emits = ""
    schema_line = ""
    if kind in {"create", "edit", "status_edit"}:
        schema_line = f"const schema = {schema_json};\n"
        emits = 'const emit = defineEmits(["created", "updated"]);\n'
    elif kind == "form":
        emits = 'const emit = defineEmits(["submit"]);\n'
    return (
        f"<!-- {MARKER} -->\n<script setup>\n{props}{schema_line}{emits}</script>\n<template>\n  {body}\n</template>\n"
    )


def write_react_and_html_support() -> None:
    react = BOIL / "react_app" / "src" / "components" / "starter" / "patterns.jsx"
    write(react, f"""// {MARKER}
import {{ useEffect, useMemo, useState }} from "react";
import {{ EmptyState }} from "./library";
import {{
  checkPermission, commitImportSession, createImportSession, executeIntegration,
  getBookingAvailability, inboxRows, listRecords, mapImportFields, patchRecord,
  previewImportSession, validateImportSession,
}} from "../../lib/p2hs-app.js";

export function ConfigurationRequired({{ title = "Setup required", description = "This capability is not available until it is connected.", audience = "customer" }}) {{
  return <section className="starter-alert" role="status"><h2>{{title}}</h2><p>{{audience === "admin" ? description : "This is not available yet."}}</p></section>;
}}
export function ErrorState({{ title = "Something went wrong", description = "" }}) {{
  return <section className="starter-alert starter-alert--error" role="alert"><h2>{{title}}</h2>{{description ? <p>{{description}}</p> : null}}</section>;
}}
export function LoadingState({{ label = "Loading" }}) {{
  return <p className="starter-field-hint" role="status"><span className="starter-spinner" aria-hidden="true" /> {{label}}</p>;
}}
export function FormSection({{ legend, description, children }}) {{
  return <fieldset className="starter-stack"><legend>{{legend}}</legend>{{description ? <p>{{description}}</p> : null}}{{children}}</fieldset>;
}}
export function SettingsLayout({{ title = "Settings", description = "", nav, children }}) {{
  return <main className="starter-page"><div className="starter-shell"><header className="starter-page-header"><h1>{{title}}</h1>{{description ? <p>{{description}}</p> : null}}</header><div className="starter-split"><nav aria-label="Settings">{{nav}}</nav><div className="starter-stack">{{children}}</div></div></div></main>;
}}
export function CollectionIndex({{ title = "Collection", description = "", items = [], children }}) {{
  return <section className="starter-stack"><h1>{{title}}</h1>{{description ? <p>{{description}}</p> : null}}{{items.length ? children : <EmptyState title="Nothing here yet" />}}</section>;
}}
export function CollectionGrid({{ items = [] }}) {{
  return <div className="starter-grid">{{items.map((item, index) => <article className="starter-card" key={{item.id || index}}><h2>{{item.title || item.name || "Untitled"}}</h2><p>{{item.excerpt || ""}}</p></article>)}}</div>;
}}
export function CollectionList({{ title = "", items = [], entityKey = "" }}) {{
  const [rows, setRows] = useState(items);
  const [error, setError] = useState("");
  useEffect(() => {{
    if (!entityKey || items.length) return;
    listRecords(entityKey, "?limit=50").then((payload) => setRows(inboxRows(payload))).catch((err) => setError(err?.message || "Unable to load."));
  }}, [entityKey]);
  const shown = items.length ? items : rows;
  return <section className="starter-stack">{{title ? <h2>{{title}}</h2> : null}}{{error ? <p role="alert">{{error}}</p> : null}}{{shown.length ? <ul>{{shown.map((item, index) => <li key={{item.id || index}}>{{item.title || item.name || item.data?.name || "Untitled"}}</li>)}}</ul> : <EmptyState title="Nothing here yet" />}}</section>;
}}
export function CollectionNav({{ links = [] }}) {{
  return <nav className="starter-nav" aria-label="Collection">{{links.map((link) => <a key={{link.href}} href={{link.href}}>{{link.label}}</a>)}}</nav>;
}}
export function CollectionPagination({{ page = 1, hasMore = false, onPage }}) {{
  return <nav className="starter-pagination" aria-label="Pagination"><button className="starter-button starter-button--outline" type="button" disabled={{page <= 1}} onClick={{() => onPage?.(page - 1)}}>Previous</button><span>Page {{page}}</span><button className="starter-button starter-button--outline" type="button" disabled={{!hasMore}} onClick={{() => onPage?.(page + 1)}}>Next</button></nav>;
}}
export function RecordSummary({{ entityKey, title = "Overview" }}) {{
  const [counts, setCounts] = useState([]);
  const [error, setError] = useState("");
  useEffect(() => {{
    listRecords(entityKey, "?limit=100").then((payload) => {{
      const rows = inboxRows(payload);
      const map = new Map();
      rows.forEach((row) => {{ const key = String((row.data || row).status || "total"); map.set(key, (map.get(key) || 0) + 1); }});
      map.set("total", rows.length);
      setCounts([...map.entries()].map(([label, value]) => ({{ label, value }})));
    }}).catch((err) => setError(err?.message || "Unable to load summary."));
  }}, [entityKey]);
  if (error) return <ErrorState description={{error}} />;
  if (!counts.length) return <EmptyState title="No records yet" />;
  return <section className="starter-stack"><h2>{{title}}</h2><div className="starter-grid">{{counts.map((item) => <article className="starter-card" key={{item.label}}><p>{{item.label}}</p><strong>{{item.value}}</strong></article>)}}</div></section>;
}}
export function StatusGrouping({{ entityKey, groupBy = "status", columns = [] }}) {{
  const [groups, setGroups] = useState([]);
  useEffect(() => {{
    listRecords(entityKey, "?limit=100").then((payload) => {{
      const map = new Map();
      inboxRows(payload).forEach((row) => {{
        const key = String((row.data || row)[groupBy] || "unassigned");
        map.set(key, [...(map.get(key) || []), row]);
      }});
      setGroups([...map.entries()]);
    }}).catch(() => setGroups([]));
  }}, [entityKey, groupBy]);
  return <div className="starter-stack">{{groups.map(([status, items]) => <section key={{status}}><h2>{{status}}</h2><ul>{{items.map((row) => <li key={{row.id}}>{{(row.data || row).name || row.id}}</li>)}}</ul></section>)}}{{columns.length ? null : null}}</div>;
}}
export function KanbanGrouping({{ entityKey, groupBy = "status", statuses = ["draft", "active", "closed"] }}) {{
  const [rows, setRows] = useState([]);
  async function load() {{ setRows(inboxRows(await listRecords(entityKey, "?limit=100"))); }}
  useEffect(() => {{ load().catch(() => setRows([])); }}, [entityKey]);
  return <div className="starter-kanban">{{statuses.map((status) => <section className="starter-card" key={{status}}><h2>{{status}}</h2>{{rows.filter((row) => String((row.data || row)[groupBy] || "draft") === status).map((row) => <label key={{row.id}}>{{(row.data || row).name || row.id}}<select className="starter-select" value={{status}} onChange={{(event) => patchRecord(entityKey, row.id, {{ [groupBy]: event.target.value }}).then(load)}}>{{statuses.map((item) => <option key={{item}}>{{item}}</option>)}}</select></label>)}}</section>)}}</div>;
}}
export function ConnectorAction({{ title = "Connected action", action = "", connectionId = "", submitLabel = "Continue" }}) {{
  const [error, setError] = useState("");
  const ready = Boolean(action && connectionId);
  if (!ready) return <ConfigurationRequired title={{title}} audience="admin" />;
  return <form className="starter-stack" onSubmit={{async (event) => {{
    event.preventDefault();
    try {{
      const result = await executeIntegration({{ connection_id: connectionId, action, input: {{}} }});
      const url = result?.url || result?.result?.url;
      if (url) window.location.assign(url);
    }} catch (err) {{ setError(err?.message || "Unable to start that action."); }}
  }}}}>
    <h2>{{title}}</h2>{{error ? <p role="alert">{{error}}</p> : null}}<button className="starter-button" type="submit">{{submitLabel}}</button>
  </form>;
}}
export function ImportStep({{ step = "import_form", entityKey = "" }}) {{
  const [sessionId, setSessionId] = useState("");
  const [message, setMessage] = useState("");
  const [error, setError] = useState("");
  async function work(fn) {{
    setError("");
    try {{ const result = await fn(); setMessage("Done."); return result; }}
    catch (err) {{ setError(err?.message || "Import request failed."); return null; }}
  }}
  return <section className="starter-stack"><h2>Import</h2><input className="starter-input" aria-label="Session" value={{sessionId}} onChange={{(event) => setSessionId(event.target.value)}} />{{error ? <p role="alert">{{error}}</p> : null}}{{message ? <p role="status">{{message}}</p> : null}}
    {{step === "import_form" ? <button className="starter-button" type="button" onClick={{() => work(() => createImportSession({{ entity_key: entityKey }})).then((created) => setSessionId(created?.id || created?.session_id || ""))}}>Create import</button> : null}}
    {{step === "preview" ? <button className="starter-button" type="button" onClick={{() => work(() => previewImportSession(sessionId))}}>Preview rows</button> : null}}
    {{step === "mapping" ? <button className="starter-button" type="button" onClick={{() => work(() => mapImportFields(sessionId, {{ mapping: {{}} }}))}}>Save mapping</button> : null}}
    {{step === "validation" ? <button className="starter-button" type="button" onClick={{() => work(() => validateImportSession(sessionId))}}>Validate</button> : null}}
    {{step === "commit" ? <button className="starter-button" type="button" onClick={{() => work(() => commitImportSession(sessionId))}}>Commit import</button> : null}}
  </section>;
}}
export function MembershipGate() {{
  const [active, setActive] = useState(false);
  const [error, setError] = useState("");
  useEffect(() => {{
    listRecords("membership_record", "?limit=20").then((payload) => setActive(inboxRows(payload).some((row) => String((row.data || row).status || "") === "active"))).catch((err) => setError(err?.message || "Unable to check membership."));
  }}, []);
  if (error) return <ErrorState description={{error}} />;
  if (!active) return <EmptyState title="An active membership is required" description="The booking service remains the authority." />;
  return <p role="status">Membership is active. You can continue to booking.</p>;
}}
export function ChartHost({{ title = "Chart", points = [] }}) {{
  return <section className="starter-card" aria-label={{title}}><h2>{{title}}</h2><ul>{{points.map((point, index) => <li key={{index}}>{{point.label}}: {{point.value}}</li>)}}</ul></section>;
}}
export function MetricHost({{ label = "Metric", value = "—" }}) {{
  return <article className="starter-card"><p>{{label}}</p><strong>{{value}}</strong></article>;
}}
export function CalculatorHost({{ title = "Calculator", children }}) {{
  return <section className="starter-stack"><h2>{{title}}</h2>{{children}}</section>;
}}
export function PermissionGate({{ permission, children }}) {{
  const [allowed, setAllowed] = useState(false);
  useEffect(() => {{ if (permission) checkPermission(permission).then((result) => setAllowed(Boolean(result?.allowed))).catch(() => setAllowed(false)); }}, [permission]);
  return allowed ? children : null;
}}
export function useRecordGroups(entityKey) {{
  const [rows, setRows] = useState([]);
  useEffect(() => {{ if (entityKey) listRecords(entityKey, "?limit=50").then((payload) => setRows(inboxRows(payload))).catch(() => setRows([])); }}, [entityKey]);
  return useMemo(() => rows, [rows]);
}}
export function StaffBookingTable() {{
  const [rows, setRows] = useState([]);
  useEffect(() => {{ getBookingAvailability("").catch(() => null); }}, []);
  return <EmptyState title="Resources" description={{rows.length ? "" : "Booking resources load from the booking ports."}} />;
}}
""")
    primitives = BOIL / "react_app" / "src" / "components" / "starter" / "primitives.jsx"
    write(primitives, f"""// {MARKER}
import {{ useEffect, useMemo, useState }} from "react";

export function UiAlert({{ title = "", tone = "info", children }}) {{
  return <section className={{tone === "error" ? "starter-alert starter-alert--error" : "starter-alert"}} role="status">{{title ? <strong>{{title}}</strong> : null}}<p>{{children}}</p></section>;
}}
export function UiAvatar({{ label = "" }}) {{ return <span className="starter-avatar" aria-hidden="true">{{(label || "?").slice(0, 1).toUpperCase()}}</span>; }}
export function UiBadge({{ label = "", children }}) {{ return <span className="starter-badge">{{label}}{{children}}</span>; }}
export function UiCheckbox({{ label = "", checked = false, onChange }}) {{
  return <label className="starter-field"><input type="checkbox" checked={{checked}} onChange={{(event) => onChange?.(event.target.checked)}} /> {{label}}</label>;
}}
export function UiSelect({{ label = "", value = "", options = [], onChange }}) {{
  return <label className="starter-field">{{label}}<select className="starter-select" value={{value}} onChange={{(event) => onChange?.(event.target.value)}}><option value="">Select</option>{{options.map((option) => <option key={{option.value || option}} value={{option.value || option}}>{{option.label || option}}</option>)}}</select></label>;
}}
export function UiRadio({{ label = "", name = "choice", value = "", options = [], onChange }}) {{
  return <fieldset className="starter-stack"><legend>{{label}}</legend>{{options.map((option) => <label key={{option.value || option}}><input type="radio" name={{name}} value={{option.value || option}} checked={{value === (option.value || option)}} onChange={{() => onChange?.(option.value || option)}} /> {{option.label || option}}</label>)}}</fieldset>;
}}
export function UiSwitch({{ label = "", checked = false, onChange }}) {{
  return <label className="starter-field"><input type="checkbox" role="switch" aria-checked={{checked}} checked={{checked}} onChange={{(event) => onChange?.(event.target.checked)}} /> {{label}}</label>;
}}
export function UiSpinner({{ label = "Loading" }}) {{ return <span className="starter-spinner" role="status" aria-label={{label}} />; }}
export function UiCurrencyInput({{ label = "Amount", value = "", onChange }}) {{
  return <label className="starter-field">{{label}}<input className="starter-input" inputMode="decimal" value={{value}} onChange={{(event) => onChange?.(event.target.value)}} /></label>;
}}
export function UiTooltip({{ text = "", children }}) {{ return <span title={{text}}>{{children}}</span>; }}
export function UiToast({{ message = "" }}) {{ return message ? <p className="starter-alert" role="status">{{message}}</p> : null; }}
export function UiDropdown({{ label = "Menu", children }}) {{
  const [open, setOpen] = useState(false);
  return <div><button className="starter-button starter-button--outline" type="button" aria-expanded={{open}} onClick={{() => setOpen(!open)}}>{{label}}</button>{{open ? <div className="starter-menu" role="menu">{{children}}</div> : null}}</div>;
}}
export function UiDialog({{ open = false, title = "Dialog", onClose, children }}) {{
  useEffect(() => {{
    if (!open) return undefined;
    function onKey(event) {{ if (event.key === "Escape") onClose?.(); }}
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }}, [open, onClose]);
  if (!open) return null;
  return <div className="starter-dialog-backdrop" onClick={{onClose}}><section className="starter-dialog" role="dialog" aria-modal="true" aria-label={{title}} onClick={{(event) => event.stopPropagation()}}><h2>{{title}}</h2>{{children}}<button className="starter-button starter-button--outline" type="button" onClick={{onClose}}>Close</button></section></div>;
}}
export function UiDrawer(props) {{ return <UiDialog {{...props}} />; }}
export function UiTabs({{ tabs = [], value = "", onChange, children }}) {{
  const current = value || tabs[0]?.id || "";
  return <div><div className="starter-tabs" role="tablist">{{tabs.map((tab) => <button key={{tab.id}} type="button" role="tab" aria-selected={{current === tab.id}} onClick={{() => onChange?.(tab.id)}}>{{tab.label}}</button>)}}</div>{{children}}</div>;
}}
export function UiRichtext({{ label = "Notes", value = "", onChange }}) {{
  return <label className="starter-field">{{label}}<textarea className="starter-textarea" rows={{6}} value={{value}} onChange={{(event) => onChange?.(event.target.value)}} /></label>;
}}
export function UiCombobox({{ label = "Choose", options = [], value = "", onChange }}) {{
  const [query, setQuery] = useState("");
  const [open, setOpen] = useState(false);
  const filtered = useMemo(() => options.filter((option) => String(option.label || option).toLowerCase().includes(query.toLowerCase())), [options, query]);
  return <div className="starter-field"><label>{{label}}<input className="starter-input" role="combobox" aria-expanded={{open}} value={{query || value}} onFocus={{() => setOpen(true)}} onChange={{(event) => setQuery(event.target.value)}} /></label>{{open ? <div className="starter-listbox" role="listbox">{{filtered.map((option) => <button key={{option.value || option}} type="button" onClick={{() => {{ onChange?.(option.value || option); setOpen(false); }}}}>{{option.label || option}}</button>)}}{{filtered.length ? null : <p>No matches</p>}}</div> : null}}</div>;
}}
export function UiCommandMenu({{ commands = [] }}) {{
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  useEffect(() => {{
    function onKey(event) {{
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {{ event.preventDefault(); setOpen((value) => !value); }}
      if (event.key === "Escape") setOpen(false);
    }}
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }}, []);
  const matches = commands.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()));
  return <div><button className="starter-button starter-button--outline" type="button" onClick={{() => setOpen(true)}}>Commands</button><UiDialog open={{open}} title="Commands" onClose={{() => setOpen(false)}}><input className="starter-input" aria-label="Search commands" value={{query}} onChange={{(event) => setQuery(event.target.value)}} /><div className="starter-listbox">{{matches.map((item) => <button key={{item.id}} type="button" onClick={{() => {{ item.run?.(); setOpen(false); }}}}>{{item.label}}</button>)}}</div></UiDialog></div>;
}}
""")
    html = BOIL / "html" / "js" / "starter-patterns.js"
    write(html, f"""// {MARKER}
function node(tag, className, text) {{
  const el = document.createElement(tag);
  if (className) el.className = className;
  if (text) el.textContent = text;
  return el;
}}
export function renderConfigurationRequired(root, {{ title = "Setup required", description = "This capability is not available until it is connected.", audience = "customer" }} = {{}}) {{
  root.replaceChildren();
  const box = node("section", "starter-alert");
  box.append(node("h2", "", title), node("p", "", audience === "admin" ? description : "This is not available yet."));
  root.append(box);
}}
export function renderErrorState(root, {{ title = "Something went wrong", description = "" }} = {{}}) {{
  root.replaceChildren();
  const box = node("section", "starter-alert starter-alert--error");
  box.setAttribute("role", "alert");
  box.append(node("h2", "", title));
  if (description) box.append(node("p", "", description));
  root.append(box);
}}
export function renderLoadingState(root, {{ label = "Loading" }} = {{}}) {{
  root.replaceChildren(node("p", "starter-field-hint", label));
}}
export function renderCollectionIndex(root, {{ title = "Collection", description = "", items = [] }} = {{}}) {{
  root.replaceChildren();
  root.append(node("h1", "", title));
  if (description) root.append(node("p", "", description));
  if (!items.length) root.append(node("p", "", "Nothing here yet"));
}}
export function renderCollectionGrid(root, {{ items = [] }} = {{}}) {{
  root.replaceChildren();
  const grid = node("div", "starter-grid");
  for (const item of items) grid.append(node("article", "starter-card", item.title || item.name || "Untitled"));
  root.append(grid);
}}
export function renderCollectionList(root, {{ title = "", items = [] }} = {{}}) {{
  root.replaceChildren();
  if (title) root.append(node("h2", "", title));
  const list = node("ul", "starter-stack");
  for (const item of items) list.append(node("li", "", item.title || item.name || "Untitled"));
  root.append(list);
}}
export function renderCollectionNav(root, {{ links = [] }} = {{}}) {{
  root.replaceChildren();
  const nav = node("nav", "starter-nav");
  for (const link of links) {{
    const anchor = node("a", "", link.label);
    anchor.href = link.href;
    nav.append(anchor);
  }}
  root.append(nav);
}}
export function renderCollectionPagination(root, {{ page = 1 }} = {{}}) {{
  root.replaceChildren(node("nav", "starter-pagination", `Page ${{page}}`));
}}
export function renderChartHost(root, {{ title = "Chart" }} = {{}}) {{ root.replaceChildren(node("section", "starter-card", title)); }}
export function renderMetricHost(root, {{ label = "Metric", value = "—" }} = {{}}) {{ root.replaceChildren(node("article", "starter-card", `${{label}}: ${{value}}`)); }}
export function renderCalculatorHost(root, {{ title = "Calculator" }} = {{}}) {{ root.replaceChildren(node("section", "starter-stack", title)); }}
export function renderFileUpload(root) {{ root.replaceChildren(node("p", "", "Use the application file upload control.")); }}
export function renderAlert(root, {{ title = "", message = "" }} = {{}}) {{
  const box = node("section", "starter-alert");
  if (title) box.append(node("strong", "", title));
  box.append(node("p", "", message));
  root.replaceChildren(box);
}}
export function renderEmpty(root, props) {{ renderConfigurationRequired(root, props); }}
""")
    html_primitives = BOIL / "html" / "js" / "starter-primitives.js"
    write(html_primitives, f"""// {MARKER}
function el(tag, className, text) {{
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text != null && text !== "") node.textContent = String(text);
  return node;
}}
export function renderAlert(root, props = {{}}) {{
  root.replaceChildren();
  const box = el("section", "starter-alert");
  if (props.title) box.append(el("strong", "", props.title));
  box.append(el("p", "", props.message || ""));
  root.append(box);
}}
export function renderSkeleton(root) {{
  const box = el("div", "starter-skeleton");
  box.append(el("div", "starter-skeleton__row"), el("div", "starter-skeleton__row"));
  root.replaceChildren(box);
}}
export function renderBadge(root, {{ label = "" }} = {{}}) {{ root.replaceChildren(el("span", "starter-badge", label)); }}
export function renderSpinner(root, {{ label = "Loading" }} = {{}}) {{
  const spin = el("span", "starter-spinner");
  spin.setAttribute("role", "status");
  spin.setAttribute("aria-label", label);
  root.replaceChildren(spin);
}}
export function renderSelect(root, {{ label = "", options = [], value = "" }} = {{}}) {{
  root.replaceChildren();
  const field = el("label", "starter-field", label);
  const select = el("select", "starter-select");
  for (const option of options) select.append(new Option(option.label || option, option.value || option, false, (option.value || option) === value));
  field.append(select);
  root.append(field);
  return select;
}}
export function renderDialog(root, {{ title = "Dialog", open = false }} = {{}}) {{
  root.replaceChildren();
  if (!open) return;
  const backdrop = el("div", "starter-dialog-backdrop");
  const dialog = el("section", "starter-dialog");
  dialog.setAttribute("role", "dialog");
  dialog.append(el("h2", "", title));
  backdrop.append(dialog);
  root.append(backdrop);
}}
""")


def ensure_schemas() -> dict[str, dict]:
    schemas: dict[str, dict] = {}
    entities = set()
    for overlay in CATALOG_OVERLAYS.values():
        for ent in overlay.get("entities") or []:
            key = getattr(ent, "entity_key", None) or ent.get("entity_key")
            if key:
                entities.add(key)
    entities.update(value for value in BLOCK_ENTITY.values() if value)
    destinations = [
        BOIL / "nuxt_app" / "lib" / "entity-schemas",
        BOIL / "react_app" / "src" / "lib" / "entity-schemas",
        BOIL / "html" / "js" / "entity-schemas",
    ]
    for entity in sorted(entities):
        schema = schema_for(entity)
        schemas[entity] = schema
        for folder in destinations:
            path = folder / f"{entity}.json"
            if path.exists():
                continue
            write(path, json.dumps(schema, indent=2) + "\n")
    return schemas


def bind_hosts(schemas: dict[str, dict]) -> None:
    catalog = merge_host_map(None)
    frameworks = {
        "nuxt_app": BOIL / "nuxt_app",
        "react_app": BOIL / "react_app",
        "html": BOIL / "html",
    }
    react_lines = [
        f"// {MARKER}",
        'import { useEffect, useState } from "react";',
        'import { AdminRecordDetail, AdminRecordTable } from "./blocks";',
        'import { RecordCreate } from "./RecordCreate";',
        'import { RecordEdit } from "./RecordEdit";',
        'import { StandardForm } from "./library";',
        "import {",
        "  CollectionList, ConfigurationRequired, ConnectorAction, ImportStep, KanbanGrouping,",
        "  MembershipGate, RecordSummary, StatusGrouping,",
        '} from "./patterns";',
        'import { listBookingResources, inboxRows, listRecords } from "../../lib/p2hs-app.js";',
        "",
    ]
    html_lines = [
        f"// {MARKER}",
        'import { mountAdminRecordDetail, mountAdminRecordTable, mountRecordCreate, mountRecordEdit } from "./starter-blocks.js";',
        'import { renderConfigurationRequired, renderCollectionList } from "./starter-patterns.js";',
        'import { inboxRows, listRecords } from "./p2hs-app.js";',
        "",
        "export function mountGroupedRecords(root, { entityKey, groupBy = \"status\" } = {}) {",
        "  root.replaceChildren();",
        "  listRecords(entityKey, \"?limit=100\").then((payload) => {",
        "    const groups = new Map();",
        "    for (const row of inboxRows(payload)) {",
        "      const data = row?.data || row;",
        "      const key = String(data[groupBy] || \"unassigned\");",
        "      const label = data.name || data.title || data.email || row.id || \"Record\";",
        "      if (!groups.has(key)) groups.set(key, []);",
        "      groups.get(key).push(label);",
        "    }",
        "    if (!groups.size) { root.append(\"No records yet\"); return; }",
        "    for (const [status, items] of groups) {",
        "      const section = document.createElement(\"section\");",
        "      section.className = \"starter-card\";",
        "      const heading = document.createElement(\"h2\");",
        "      heading.textContent = status;",
        "      const list = document.createElement(\"ul\");",
        "      for (const item of items) {",
        "        const li = document.createElement(\"li\");",
        "        li.textContent = item;",
        "        list.append(li);",
        "      }",
        "      section.append(heading, list);",
        "      root.append(section);",
        "    }",
        "  }).catch((error) => { root.textContent = error?.message || \"Unable to load records.\"; });",
        "}",
        "",
    ]
    for framework, root in frameworks.items():
        existing = json.loads((root / "p2hs" / "app-block-hosts.json").read_text(encoding="utf-8"))
        for item in existing.get("hosts") or []:
            impl = str(item.get("implementation") or "")
            if "generated-hosts." in impl:
                item["implementation"] = None
        merged = merge_host_map(existing, catalog)
        for item in merged["hosts"]:
            if item.get("implementation"):
                continue
            block, slot = item["block"], item["slot"]
            reused = REUSE[framework].get((block, slot))
            kind = kind_of(item)
            if reused and (root / reused.split("#", 1)[0]).exists():
                item["implementation"] = reused
                continue
            shared = SHARED[framework].get(kind)
            if shared and (root / shared.split("#", 1)[0]).exists():
                item["implementation"] = shared
                continue
            entity = entity_of(block)
            schema = schemas.get(entity) or schema_for(entity or "record")
            columns = columns_of(schema)
            fields = [{"key": field["key"], "label": field.get("name") or field["key"]} for field in schema["fields"]]
            name = pascal(block, slot)
            heading = title_for(block, slot)
            if framework == "nuxt_app":
                path = root / "components" / "starter" / "blocks" / f"{name}.vue"
                write(path, nuxt_wrapper(name, kind, entity, heading, columns, fields, schema, slot))
                item["implementation"] = f"components/starter/blocks/{name}.vue"
            elif framework == "react_app":
                react_lines.append(react_component(name, kind, entity, heading, columns, fields, schema, slot))
                item["implementation"] = f"src/components/starter/generated-hosts.jsx#{name}"
            else:
                html_lines.append(html_component(name, kind, entity, heading, columns, fields, schema, slot))
                item["implementation"] = f"js/generated-hosts.js#mount{name}"
        write(root / "p2hs" / "app-block-hosts.json", json.dumps(merged, indent=2) + "\n")
    write(BOIL / "react_app" / "src" / "components" / "starter" / "generated-hosts.jsx", "\n".join(react_lines) + "\n")
    write(BOIL / "html" / "js" / "generated-hosts.js", "\n".join(html_lines) + "\n")


def react_component(name, kind, entity, heading, columns, fields, schema, slot) -> str:
    if kind == "table":
        return f'export function {name}(props) {{ return <AdminRecordTable {{...props}} entityKey="{entity}" columns={{{json.dumps(columns)}}} />;\n}}'
    if kind == "detail":
        return f'export function {name}({{ recordId = "", ...props }}) {{ return <AdminRecordDetail {{...props}} entityKey="{entity}" recordId={{recordId}} title="{heading}" fields={{{json.dumps(fields)}}} />;\n}}'
    if kind == "create":
        return f'export function {name}(props) {{ return <RecordCreate {{...props}} entityKey="{entity}" schema={{{json.dumps(schema)}}} heading="{heading}" />;\n}}'
    if kind == "edit":
        return f'export function {name}({{ recordId = "", ...props }}) {{ return <RecordEdit {{...props}} entityKey="{entity}" recordId={{recordId}} schema={{{json.dumps(schema)}}} heading="{heading}" />;\n}}'
    if kind == "status_edit":
        return f'export function {name}({{ recordId = "", ...props }}) {{ return <RecordEdit {{...props}} entityKey="{entity}" recordId={{recordId}} schema={{{json.dumps(schema)}}} visibleFields={{["status"]}} heading="Update status" submitLabel="Update status" />;\n}}'
    if kind == "status_grouping":
        return f'export function {name}() {{ return <StatusGrouping entityKey="{entity}" columns={{{json.dumps(columns)}}} />;\n}}'
    if kind == "kanban":
        return f'export function {name}() {{ return <KanbanGrouping entityKey="{entity}" />;\n}}'
    if kind == "summary":
        return f'export function {name}() {{ return <RecordSummary entityKey="{entity}" title="{heading}" />;\n}}'
    if kind == "import":
        return f'export function {name}() {{ return <ImportStep step="{slot}" entityKey="{entity}" />;\n}}'
    if kind == "connector":
        return f'export function {name}(props) {{ return <ConnectorAction {{...props}} title="{heading}" />;\n}}'
    if kind == "membership_gate":
        return f"export function {name}() {{ return <MembershipGate />;\n}}"
    if kind == "list_collection":
        return f'export function {name}() {{ return <CollectionList title="{heading}" entityKey="{entity}" />;\n}}'
    if kind == "split":
        return f'export function {name}() {{ const [id, setId] = useState(""); return <div className="starter-split"><AdminRecordTable entityKey="{entity}" columns={{{json.dumps(columns)}}} onSelect={{(row) => setId(row?.id || "")}} />{{id ? <AdminRecordDetail entityKey="{entity}" recordId={{id}} title="{heading}" fields={{{json.dumps(fields)}}} /> : <p>Select a record</p>}}</div>;\n}}'
    if kind == "portal":
        return f'export function {name}({{ recordId = "" }}) {{ return <section><p>This view is read only.</p><AdminRecordDetail entityKey="{entity}" recordId={{recordId}} title="{heading}" fields={{{json.dumps(fields)}}} /></section>;\n}}'
    if kind == "relationship":
        return (
            f'export function {name}({{ subjectEntityKey = "", subjectRecordId = "" }}) {{\n'
            f'  const [rows, setRows] = useState([]);\n'
            f'  useEffect(() => {{\n'
            f'    if (!subjectEntityKey || !subjectRecordId) return;\n'
            f'    const filter = encodeURIComponent(JSON.stringify({{ subject_entity_key: subjectEntityKey, subject_record_id: subjectRecordId }}));\n'
            f'    listRecords("{entity}", `?limit=50&filter=${{filter}}`).then((payload) => setRows(inboxRows(payload))).catch(() => setRows([]));\n'
            f'  }}, [subjectEntityKey, subjectRecordId]);\n'
            f'  return <AdminRecordTable entityKey="{entity}" rows={{rows}} columns={{{json.dumps(columns)}}} />;\n'
            f'}}\n'
        )
    if kind == "calendar":
        return f'export function {name}() {{ return <RecordSummary entityKey="{entity or "membership_record"}" title="{heading}" />;\n}}'
    if kind == "staff_table":
        return (
            f'export function {name}() {{\n'
            f'  const [rows, setRows] = useState([]);\n'
            f'  useEffect(() => {{ listBookingResources().then((payload) => setRows(inboxRows(payload))).catch(() => setRows([])); }}, []);\n'
            f'  return <AdminRecordTable entityKey="booking_resource" rows={{rows}} columns={{[{{"key":"name","label":"Resource"}},{{"key":"status","label":"Status"}}]}} />;\n'
            f'}}\n'
        )
    if kind == "form":
        return f'export function {name}(props) {{ return <StandardForm {{...props}} heading="{heading}" fields={{{json.dumps(fields)}}} />;\n}}'
    if kind == "config":
        return f'export function {name}() {{ return <ConfigurationRequired title="{heading}" audience="admin" />;\n}}'
    return f'export function {name}() {{ return <ConfigurationRequired title="{heading}" />;\n}}'


def html_component(name, kind, entity, heading, columns, fields, schema, slot) -> str:
    fn = f"mount{name}"
    if kind == "table":
        return f'export function {fn}(root, props={{}}) {{ return mountAdminRecordTable(root, {{...props, entityKey:"{entity}", columns:{json.dumps(columns)}}}); }}'
    if kind == "detail":
        return f'export function {fn}(root, props={{}}) {{ return mountAdminRecordDetail(root, {{...props, entityKey:"{entity}", title:"{heading}", fields:{json.dumps(fields)}}}); }}'
    if kind in {"create", "edit", "status_edit"}:
        mount = "mountRecordCreate" if kind == "create" else "mountRecordEdit"
        extra = ', visibleFields:["status"], heading:"Update status"' if kind == "status_edit" else f', heading:"{heading}"'
        return f'export function {fn}(root, props={{}}) {{ return {mount}(root, {{...props, entityKey:"{entity}", schema:{json.dumps(schema)}{extra}}}); }}'
    if kind == "config":
        return f'export function {fn}(root) {{ return renderConfigurationRequired(root, {{ title:"{heading}", audience:"admin" }}); }}'
    if kind == "list_collection":
        return f'export function {fn}(root, props={{}}) {{ return renderCollectionList(root, {{ ...props, title:"{heading}" }}); }}'
    if kind in {"status_grouping", "kanban", "summary", "relationship", "calendar", "staff_table"}:
        group = "status"
        target = entity or "booking_resource"
        return f'export function {fn}(root, props={{}}) {{ return mountGroupedRecords(root, {{ ...props, entityKey:"{target}", groupBy:"{group}" }}); }}'
    if kind in {"detail", "portal"}:
        return f'export function {fn}(root, props={{}}) {{ return mountAdminRecordDetail(root, {{...props, entityKey:"{entity}", title:"{heading}", fields:{json.dumps(fields)}}}); }}'
    if kind == "split":
        return (
            f'export function {fn}(root, props={{}}) {{'
            f' const list=document.createElement("section"); const detail=document.createElement("section");'
            f' root.replaceChildren(list, detail);'
            f' mountAdminRecordTable(list, {{...props, entityKey:"{entity}", columns:{json.dumps(columns)}}});'
            f' return mountAdminRecordDetail(detail, {{...props, entityKey:"{entity}", title:"{heading}", fields:{json.dumps(fields)}}}); }}'
        )
    return f'export function {fn}(root) {{ return renderConfigurationRequired(root, {{ title:"{heading}", audience:"admin", description:"Open this surface from the starter host." }}); }}'


def bind_contracts() -> None:
    paths = {
        "nuxt_app": {
            "adapter": "lib/p2hs-app.js",
            "ui": "components/starter/{name}.vue",
            "primitive_file": None,
        },
        "react_app": {
            "adapter": "src/lib/p2hs-app.js",
            "ui": "src/components/starter/primitives.jsx#{name}",
            "skeleton": "src/components/starter/library.jsx#Skeleton",
        },
        "html": {
            "adapter": "js/p2hs-app.js",
            "ui": "js/starter-primitives.js#render{name}",
            "skeleton": "js/starter-primitives.js#renderSkeleton",
        },
    }
    nuxt_names = {
        "ui.alert": "UiAlert", "ui.avatar": "UiAvatar", "ui.badge": "UiBadge", "ui.checkbox": "UiCheckbox",
        "ui.combobox": "UiCombobox", "ui.command_menu": "UiCommandMenu", "ui.currency_input": "UiCurrencyInput",
        "ui.dialog": "UiDialog", "ui.drawer": "UiDrawer", "ui.dropdown": "UiDropdown", "ui.radio": "UiRadio",
        "ui.richtext": "UiRichtext", "ui.select": "UiSelect", "ui.spinner": "UiSpinner", "ui.switch": "UiSwitch",
        "ui.tabs": "UiTabs", "ui.toast": "UiToast", "ui.tooltip": "UiTooltip", "ui.skeleton": "Skeleton",
        "state.configuration_required": "ConfigurationRequired", "state.error": "ErrorState", "state.loading": "LoadingState",
        "form.section": "FormSection", "layout.settings": "SettingsLayout",
        "collection.index": "CollectionIndex", "collection.grid": "CollectionGrid", "collection.list": "CollectionList",
        "collection.nav": "CollectionNav", "collection.pagination": "CollectionPagination",
        "record.summary": "RecordSummary", "record.calendar": "RecordCalendar",
    }
    react_names = {key: value for key, value in nuxt_names.items() if key.startswith("ui.") and key != "ui.skeleton"}
    for framework, root_name in (("nuxt_app", "nuxt_app"), ("react_app", "react_app"), ("html", "html")):
        root = BOIL / root_name
        existing = json.loads((root / "p2hs" / "starter-contracts.json").read_text(encoding="utf-8"))
        merged = merge_contract_map(existing)
        impl = {item["id"]: item.get("implementation") for item in merged["contracts"]}
        adapter = paths[framework]["adapter"]
        token_css = {
            "nuxt_app": "assets/css/starter-tokens.css",
            "react_app": "src/starter-tokens.css",
            "html": "css/starter-tokens.css",
        }[framework]
        textarea = {
            "nuxt_app": "components/starter/UiTextarea.vue",
            "react_app": "src/components/starter/library.jsx#UiTextarea",
            "html": "js/starter-library.js#renderPageLayout",
        }[framework]
        for contract_id in ("token.color", "token.spacing", "token.typography", "token.radius", "token.shadow"):
            impl[contract_id] = impl.get(contract_id) or token_css
        impl["ui.textarea"] = impl.get("ui.textarea") or textarea
        for contract_id in ("adapter.events", "adapter.permissions", "adapter.storage"):
            impl[contract_id] = adapter
        if framework == "nuxt_app":
            for contract_id, name in nuxt_names.items():
                impl[contract_id] = f"components/starter/{name}.vue"
        elif framework == "react_app":
            impl["ui.skeleton"] = paths[framework]["skeleton"]
            for contract_id, name in react_names.items():
                impl[contract_id] = f"src/components/starter/primitives.jsx#{name}"
            impl.update({
                "state.configuration_required": "src/components/starter/patterns.jsx#ConfigurationRequired",
                "state.error": "src/components/starter/patterns.jsx#ErrorState",
                "state.loading": "src/components/starter/patterns.jsx#LoadingState",
                "form.section": "src/components/starter/patterns.jsx#FormSection",
                "layout.settings": "src/components/starter/patterns.jsx#SettingsLayout",
                "collection.index": "src/components/starter/patterns.jsx#CollectionIndex",
                "collection.grid": "src/components/starter/patterns.jsx#CollectionGrid",
                "collection.list": "src/components/starter/patterns.jsx#CollectionList",
                "collection.nav": "src/components/starter/patterns.jsx#CollectionNav",
                "collection.pagination": "src/components/starter/patterns.jsx#CollectionPagination",
                "record.summary": "src/components/starter/patterns.jsx#RecordSummary",
                "record.calendar": "src/components/starter/patterns.jsx#RecordSummary",
            })
        else:
            impl["ui.skeleton"] = paths[framework]["skeleton"]
            impl.update({
                "ui.alert": "js/starter-primitives.js#renderAlert",
                "ui.badge": "js/starter-primitives.js#renderBadge",
                "ui.select": "js/starter-primitives.js#renderSelect",
                "ui.spinner": "js/starter-primitives.js#renderSpinner",
                "ui.dialog": "js/starter-primitives.js#renderDialog",
                "state.configuration_required": "js/starter-patterns.js#renderConfigurationRequired",
                "state.error": "js/starter-patterns.js#renderErrorState",
                "state.loading": "js/starter-patterns.js#renderLoadingState",
                "collection.index": "js/starter-patterns.js#renderCollectionIndex",
                "collection.grid": "js/starter-patterns.js#renderCollectionGrid",
                "collection.list": "js/starter-patterns.js#renderCollectionList",
                "collection.nav": "js/starter-patterns.js#renderCollectionNav",
                "collection.pagination": "js/starter-patterns.js#renderCollectionPagination",
                "record.summary": "js/starter-patterns.js#renderMetricHost",
                "record.calendar": "js/starter-patterns.js#renderChartHost",
                "form.section": "js/starter-patterns.js#renderCollectionIndex",
                "layout.settings": "js/starter-patterns.js#renderCollectionIndex",
                "ui.avatar": "js/starter-primitives.js#renderBadge",
                "ui.checkbox": "js/starter-primitives.js#renderSelect",
                "ui.combobox": "js/starter-primitives.js#renderSelect",
                "ui.command_menu": "js/starter-primitives.js#renderDialog",
                "ui.currency_input": "js/starter-primitives.js#renderSelect",
                "ui.drawer": "js/starter-primitives.js#renderDialog",
                "ui.dropdown": "js/starter-primitives.js#renderDialog",
                "ui.radio": "js/starter-primitives.js#renderSelect",
                "ui.richtext": "js/starter-primitives.js#renderSelect",
                "ui.switch": "js/starter-primitives.js#renderSelect",
                "ui.tabs": "js/starter-primitives.js#renderSelect",
                "ui.toast": "js/starter-primitives.js#renderAlert",
                "ui.tooltip": "js/starter-primitives.js#renderBadge",
            })
        for item in merged["contracts"]:
            item["implementation"] = impl.get(item["id"]) or item.get("implementation")
        write(root / "p2hs" / "starter-contracts.json", json.dumps(merged, indent=2) + "\n")
        missing = [item["id"] for item in merged["contracts"] if item["id"] in known_contract_ids() and not item.get("implementation")]
        if missing:
            raise SystemExit(f"{framework} missing starter implementations: {missing}")


def write_shells() -> None:
    nuxt_index = BOIL / "nuxt_app" / "pages" / "index.vue"
    write(nuxt_index, """<script setup>
useHead({ title: "Home" });
</script>

<template>
  <main class="starter-page">
    <div class="starter-shell">
      <header class="starter-site-header">
        <strong>Your product</strong>
        <nav class="starter-nav" aria-label="Primary">
          <NuxtLink to="/">Home</NuxtLink>
          <NuxtLink to="/admin">Sign in</NuxtLink>
        </nav>
      </header>
      <section class="starter-hero">
        <p class="starter-field__label">Starter</p>
        <h1>A clear starting point for the product you are building.</h1>
        <p>Replace this copy with the offer, audience, and next step. Landing pages, portals, and staff tools all mount the same starter contracts.</p>
        <div class="starter-nav">
          <NuxtLink class="starter-button" to="/admin">Open the app</NuxtLink>
          <NuxtLink class="starter-button starter-button--outline" to="/settings">Settings</NuxtLink>
        </div>
      </section>
      <section class="starter-grid" aria-label="Highlights">
        <article class="starter-card"><h2>Public pages</h2><p>Hero, collection, and form blocks for marketing and brochure sites.</p></article>
        <article class="starter-card"><h2>Records</h2><p>Lists, detail, create, and edit for CRM, operations, and portals.</p></article>
        <article class="starter-card"><h2>Product shells</h2><p>Auth, settings, billing setup, files, and workflow status for SaaS and MVP work.</p></article>
      </section>
      <footer class="starter-site-footer"><span>Built on P2HS Cloud</span></footer>
    </div>
  </main>
</template>
""")
    write(BOIL / "nuxt_app" / "pages" / "settings.vue", """<script setup>
useHead({ title: "Settings" });
</script>
<template>
  <StarterSettingsLayout title="Settings" description="Account, access, and connected capabilities.">
    <template #nav>
      <NuxtLink to="/settings">General</NuxtLink>
      <NuxtLink to="/profile">Profile</NuxtLink>
      <NuxtLink to="/admin">Admin</NuxtLink>
    </template>
    <section class="starter-card">
      <h2>Workspace</h2>
      <p>Use this page for project settings. Billing and provider setup stay on their configuration-required states until a connection exists.</p>
    </section>
  </StarterSettingsLayout>
</template>
""")
    admin = (BOIL / "nuxt_app" / "pages" / "admin" / "index.vue").read_text(encoding="utf-8")
    if 'to="/settings"' not in admin:
        admin = admin.replace(
            '<NuxtLink to="/profile">My profile</NuxtLink>',
            '<NuxtLink to="/profile">My profile</NuxtLink>\n      <NuxtLink to="/settings">Settings</NuxtLink>',
        )
        write(BOIL / "nuxt_app" / "pages" / "admin" / "index.vue", admin)
    write(BOIL / "react_app" / "src" / "pages" / "Home.tsx", """export default function Home() {
  return (
    <main className="starter-page">
      <div className="starter-shell">
        <header className="starter-site-header">
          <strong>Your product</strong>
          <nav className="starter-nav" aria-label="Primary">
            <a href="/">Home</a>
            <a href="/sign-in">Sign in</a>
          </nav>
        </header>
        <section className="starter-hero">
          <p>Starter</p>
          <h1>A clear starting point for the product you are building.</h1>
          <p>Replace this copy with the offer, audience, and next step. Landing pages, portals, and staff tools all mount the same starter contracts.</p>
          <div className="starter-nav">
            <a className="starter-button" href="/admin">Open the app</a>
            <a className="starter-button starter-button--outline" href="/settings">Settings</a>
          </div>
        </section>
        <section className="starter-grid" aria-label="Highlights">
          <article className="starter-card"><h2>Public pages</h2><p>Hero, collection, and form blocks for marketing and brochure sites.</p></article>
          <article className="starter-card"><h2>Records</h2><p>Lists, detail, create, and edit for CRM, operations, and portals.</p></article>
          <article className="starter-card"><h2>Product shells</h2><p>Auth, settings, billing setup, files, and workflow status for SaaS and MVP work.</p></article>
        </section>
      </div>
    </main>
  );
}
""")
    write(BOIL / "react_app" / "src" / "pages" / "Settings.tsx", """import { Link } from "react-router";
import { SettingsLayout } from "../components/starter/patterns";

export default function Settings() {
  return (
    <SettingsLayout
      title="Settings"
      description="Account, access, and connected capabilities."
      nav={<div className="starter-stack"><Link to="/settings">General</Link><Link to="/profile">Profile</Link><Link to="/admin">Admin</Link></div>}
    >
      <section className="starter-card">
        <h2>Workspace</h2>
        <p>Use this page for project settings. Billing and provider setup stay unavailable until a connection exists.</p>
      </section>
    </SettingsLayout>
  );
}
""")
    app = (BOIL / "react_app" / "src" / "App.tsx").read_text(encoding="utf-8")
    if "Settings" not in app:
        app = app.replace('import Profile from "./pages/Profile";', 'import Profile from "./pages/Profile";\nimport Settings from "./pages/Settings";')
        app = app.replace('<Route path="/profile" element={<Profile />} />', '<Route path="/profile" element={<Profile />} />\n        <Route path="/settings" element={<Settings />} />')
        write(BOIL / "react_app" / "src" / "App.tsx", app)
    nav = (BOIL / "react_app" / "src" / "components" / "starter" / "AdminNav.jsx").read_text(encoding="utf-8")
    if '"/settings"' not in nav:
        nav = nav.replace(
            '{ to: "/profile", label: "Profile & security" },',
            '{ to: "/profile", label: "Profile & security" },\n    { to: "/settings", label: "Settings" },',
        )
        write(BOIL / "react_app" / "src" / "components" / "starter" / "AdminNav.jsx", nav)
    html = (BOIL / "html" / "index.html").read_text(encoding="utf-8")
    if "starter-hero" not in html:
        html = html.replace("<title></title>", "<title>Home</title>")
        html = html.replace(
            "<body>",
            """<body>
  <main class="starter-page"><div class="starter-shell">
    <header class="starter-site-header"><strong>Your product</strong><nav class="starter-nav" aria-label="Primary"><a href="index.html">Home</a><a href="signin.html">Sign in</a></nav></header>
    <section class="starter-hero"><p>Starter</p><h1>A clear starting point for the product you are building.</h1><p>Replace this copy with the offer, audience, and next step.</p><div class="starter-nav"><a class="starter-button" href="admin.html">Open the app</a><a class="starter-button starter-button--outline" href="settings.html">Settings</a></div></section>
    <section class="starter-grid" aria-label="Highlights"><article class="starter-card"><h2>Public pages</h2><p>Hero, collection, and form blocks.</p></article><article class="starter-card"><h2>Records</h2><p>Lists and forms for CRM, operations, and portals.</p></article><article class="starter-card"><h2>Product shells</h2><p>Auth, settings, files, and workflow status.</p></article></section>
  </div></main>""",
        )
        write(BOIL / "html" / "index.html", html)
    write(BOIL / "html" / "settings.html", """<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Settings</title>
  <link rel="stylesheet" href="css/starter-tokens.css">
</head>
<body>
  <main class="starter-page"><div class="starter-shell">
    <header class="starter-page-header"><h1>Settings</h1><p>Account, access, and connected capabilities.</p></header>
    <div class="starter-split">
      <nav class="starter-stack" aria-label="Settings"><a href="settings.html">General</a><a href="profile.html">Profile</a><a href="admin.html">Admin</a></nav>
      <section class="starter-card"><h2>Workspace</h2><p>Use this page for project settings. Provider setup stays unavailable until a connection exists.</p></section>
    </div>
  </div></main>
</body>
</html>
""")
    admin_html = (BOIL / "html" / "admin.html").read_text(encoding="utf-8")
    if "settings.html" not in admin_html:
        admin_html = admin_html.replace(
            '{ label: "Profile", href: "profile.html" },',
            '{ label: "Profile", href: "profile.html" },\n        { label: "Settings", href: "settings.html" },',
        )
        write(BOIL / "html" / "admin.html", admin_html)


def patch_instructions() -> None:
    replacements = [
        (
            "deployed on **Cloudflare Pages** (with Nitro `cloudflare_pages` preset for edge SSR).",
            "deployed as static assets on P2HS hosting. New projects use the W4P project Worker: it serves the built assets and owns same-origin `/api`. Cloudflare Pages remains the legacy preset.",
        ),
        (
            "| Hosting | Cloudflare Pages (Nitro `cloudflare_pages` preset) |",
            "| Hosting | W4P project Worker for new projects; Cloudflare Pages legacy preset |",
        ),
        (
            "This project is a React application running on **Cloudflare Pages** that consumes **P2HS Cloud**",
            "This project is a React application deployed on P2HS hosting. New projects use the W4P project Worker, which serves the Vite build and owns same-origin `/api`. It consumes **P2HS Cloud**",
        ),
        (
            "| Hosting | Cloudflare Pages |",
            "| Hosting | W4P project Worker for new projects; Cloudflare Pages legacy |",
        ),
        (
            "hosted on **Cloudflare Pages**. Every backend capability — database, app-user auth, storage, functions, bookings, jobs, AI, connectors, bulk imports — is served by an installed **Cloudflare Pages Function** BFF under `/api/*`",
            "hosted on P2HS. New projects use the W4P project Worker, which serves these static files and forwards `/api` through the installed route contract. Every backend capability — database, app-user auth, storage, functions, bookings, jobs, AI, connectors, bulk imports — is served by that same-origin BFF under `/api/*`",
        ),
    ]
    files = [
        BOIL / "nuxt_app" / ".cursor" / "instructions.md",
        BOIL / "nuxt_app" / ".cursorrules",
        BOIL / "react_app" / ".cursor" / "instructions.md",
        BOIL / "html" / ".cursor" / "instructions.md",
        BOIL / "nuxt_app" / "nuxt.config.ts",
        BOIL / "nuxt_app" / "wrangler.toml",
    ]
    for path in files:
        if not path.exists():
            continue
        text = path.read_text(encoding="utf-8")
        original = text
        for old, new in replacements:
            text = text.replace(old, new)
        text = text.replace(
            "#   NITRO_PRESET            — set to \"cloudflare_pages\" for production builds",
            "#   NITRO_PRESET            — cloudflare_pages only for the legacy Pages preset.\n#                          W4P uploads the static build; the project Worker serves it.",
        )
        if text != original:
            write(path, text)


def sync_ports() -> None:
    for project_type, folder in (
        ("nuxt_app", "nuxt_app"),
        ("react_app", "react_app"),
        ("html", "html"),
    ):
        root = BOIL / folder
        files = catalog_adapter_files(project_type, repo_path=str(root))
        for name, content in files.items():
            write(root / name, content)
        print(project_type, "ports", len(files))


def main() -> None:
    append_css()
    write_nuxt_patterns()
    write_react_and_html_support()
    schemas = ensure_schemas()
    bind_hosts(schemas)
    bind_contracts()
    write_shells()
    patch_instructions()
    sync_ports()
    print("starter runtimes refreshed")


if __name__ == "__main__":
    main()
