diff --git a/account/templates/fragments/auth_menu.html b/account/templates/fragments/auth_menu.html
deleted file mode 100644
index eb68cdc..0000000
--- a/account/templates/fragments/auth_menu.html
+++ /dev/null
@@ -1,36 +0,0 @@
-{# Desktop auth menu #}
-
-{# Mobile auth menu #}
-
diff --git a/blog/templates/fragments/link_card.html b/blog/templates/fragments/link_card.html
deleted file mode 100644
index cdd2575..0000000
--- a/blog/templates/fragments/link_card.html
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
- {% if feature_image %}
-

- {% else %}
-
-
-
- {% endif %}
-
-
{{ title }}
- {% if excerpt %}
-
{{ excerpt }}
- {% endif %}
- {% if published_at %}
-
{{ published_at.strftime('%d %b %Y') }}
- {% endif %}
-
-
-
diff --git a/cart/templates/fragments/cart_mini.html b/cart/templates/fragments/cart_mini.html
deleted file mode 100644
index 3b500e6..0000000
--- a/cart/templates/fragments/cart_mini.html
+++ /dev/null
@@ -1,27 +0,0 @@
-
diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py
index 3f8e7a3..8d46096 100644
--- a/events/bp/fragments/routes.py
+++ b/events/bp/fragments/routes.py
@@ -176,6 +176,7 @@ def register():
async def _link_card_handler():
from shared.infrastructure.urls import events_url
+ from shared.sexp.jinja_bridge import sexp as render_sexp
slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "")
@@ -193,12 +194,10 @@ def register():
g.s, "page", post.id,
)
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
- parts.append(await render_template(
- "fragments/link_card.html",
- title=post.title,
- feature_image=post.feature_image,
- calendar_names=cal_names,
- link=events_url(f"/{post.slug}"),
+ parts.append(render_sexp(
+ '(~link-card :title title :image image :subtitle subtitle :link link)',
+ title=post.title, image=post.feature_image,
+ subtitle=cal_names, link=events_url(f"/{post.slug}"),
))
return "\n".join(parts)
@@ -213,12 +212,10 @@ def register():
g.s, "page", post.id,
)
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
- return await render_template(
- "fragments/link_card.html",
- title=post.title,
- feature_image=post.feature_image,
- calendar_names=cal_names,
- link=events_url(f"/{post.slug}"),
+ return render_sexp(
+ '(~link-card :title title :image image :subtitle subtitle :link link)',
+ title=post.title, image=post.feature_image,
+ subtitle=cal_names, link=events_url(f"/{post.slug}"),
)
_handlers["link-card"] = _link_card_handler
diff --git a/events/templates/fragments/container_nav_calendars.html b/events/templates/fragments/container_nav_calendars.html
deleted file mode 100644
index cdf50e3..0000000
--- a/events/templates/fragments/container_nav_calendars.html
+++ /dev/null
@@ -1,10 +0,0 @@
-{# Calendar links nav — served as fragment from events app #}
-{% for calendar in calendars %}
- {% set local_href=events_url('/' + post_slug + '/calendars/' + calendar.slug + '/') %}
-
-
- {{calendar.name}}
-
-{% endfor %}
diff --git a/events/templates/fragments/container_nav_entries.html b/events/templates/fragments/container_nav_entries.html
deleted file mode 100644
index d217565..0000000
--- a/events/templates/fragments/container_nav_entries.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{# Calendar entries nav — served as fragment from events app #}
-{% for entry in entries %}
- {% set _entry_path = '/' + post_slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
-
-
-
-
{{ entry.name }}
-
- {{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
- {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
-
-
-
-{% endfor %}
-
-{# Infinite scroll sentinel — URL points back to the consumer app #}
-{% if has_more and paginate_url_base %}
-
-
-{% endif %}
diff --git a/events/templates/fragments/link_card.html b/events/templates/fragments/link_card.html
deleted file mode 100644
index 9417330..0000000
--- a/events/templates/fragments/link_card.html
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
- {% if feature_image %}
-

- {% else %}
-
-
-
- {% endif %}
-
-
{{ title }}
- {% if calendar_names %}
-
{{ calendar_names }}
- {% endif %}
-
-
-
diff --git a/federation/templates/fragments/link_card.html b/federation/templates/fragments/link_card.html
deleted file mode 100644
index 357f819..0000000
--- a/federation/templates/fragments/link_card.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
- {% if avatar_url %}
-

- {% else %}
-
-
-
- {% endif %}
-
-
{{ display_name or username }}
-
@{{ username }}
- {% if summary %}
-
{{ summary }}
- {% endif %}
-
-
-
diff --git a/market/bp/fragments/routes.py b/market/bp/fragments/routes.py
index 358253a..a3dce41 100644
--- a/market/bp/fragments/routes.py
+++ b/market/bp/fragments/routes.py
@@ -6,7 +6,7 @@ by other coop apps via the fragment client.
from __future__ import annotations
-from quart import Blueprint, Response, g, render_template, request
+from quart import Blueprint, Response, g, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
@@ -65,6 +65,7 @@ def register():
from sqlalchemy import select
from shared.models.market import Product
from shared.infrastructure.urls import market_url
+ from shared.sexp.jinja_bridge import sexp as render_sexp
slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "")
@@ -79,14 +80,16 @@ def register():
await g.s.execute(select(Product).where(Product.slug == s))
).scalar_one_or_none()
if product:
- parts.append(await render_template(
- "fragments/link_card.html",
- title=product.title,
- image=product.image,
- description_short=product.description_short,
- brand=product.brand,
- regular_price=product.regular_price,
- special_price=product.special_price,
+ subtitle = product.brand or ""
+ detail = ""
+ if product.special_price:
+ detail = f"{product.regular_price} {product.special_price}"
+ elif product.regular_price:
+ detail = str(product.regular_price)
+ parts.append(render_sexp(
+ '(~link-card :title title :image image :subtitle subtitle :detail detail :link link)',
+ title=product.title, image=product.image,
+ subtitle=subtitle, detail=detail,
link=market_url(f"/product/{product.slug}/"),
))
return "\n".join(parts)
@@ -99,14 +102,16 @@ def register():
).scalar_one_or_none()
if not product:
return ""
- return await render_template(
- "fragments/link_card.html",
- title=product.title,
- image=product.image,
- description_short=product.description_short,
- brand=product.brand,
- regular_price=product.regular_price,
- special_price=product.special_price,
+ subtitle = product.brand or ""
+ detail = ""
+ if product.special_price:
+ detail = f"{product.regular_price} {product.special_price}"
+ elif product.regular_price:
+ detail = str(product.regular_price)
+ return render_sexp(
+ '(~link-card :title title :image image :subtitle subtitle :detail detail :link link)',
+ title=product.title, image=product.image,
+ subtitle=subtitle, detail=detail,
link=market_url(f"/product/{product.slug}/"),
)
diff --git a/market/templates/fragments/container_nav_markets.html b/market/templates/fragments/container_nav_markets.html
deleted file mode 100644
index 3c8814d..0000000
--- a/market/templates/fragments/container_nav_markets.html
+++ /dev/null
@@ -1,9 +0,0 @@
-{# Market links nav — served as fragment from market app #}
-{% for m in markets %}
-
-
- {{m.name}}
-
-{% endfor %}
diff --git a/market/templates/fragments/link_card.html b/market/templates/fragments/link_card.html
deleted file mode 100644
index c3e52a4..0000000
--- a/market/templates/fragments/link_card.html
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
- {% if image %}
-

- {% else %}
-
-
-
- {% endif %}
-
-
{{ title }}
- {% if brand %}
-
{{ brand }}
- {% endif %}
- {% if description_short %}
-
{{ description_short }}
- {% endif %}
-
- {% if special_price %}
- £{{ "%.2f"|format(special_price) }}
- £{{ "%.2f"|format(regular_price) }}
- {% elif regular_price %}
- £{{ "%.2f"|format(regular_price) }}
- {% endif %}
-
-
-
-
diff --git a/shared/sexp/components.py b/shared/sexp/components.py
index f6db5eb..8e103d2 100644
--- a/shared/sexp/components.py
+++ b/shared/sexp/components.py
@@ -2,818 +2,17 @@
Shared s-expression component definitions.
Loaded at app startup via ``load_shared_components()``. Each component
-replaces a per-service Jinja fragment template with a single reusable
-s-expression definition.
+is defined in an external ``.sexp`` file under ``templates/``.
"""
from __future__ import annotations
-from .jinja_bridge import register_components
+import os
+
+from .jinja_bridge import load_sexp_dir
def load_shared_components() -> None:
"""Register all shared s-expression components."""
- register_components(_LINK_CARD)
- register_components(_CART_MINI)
- register_components(_AUTH_MENU)
- register_components(_ACCOUNT_NAV_ITEM)
- register_components(_CALENDAR_ENTRY_NAV)
- register_components(_CALENDAR_LINK_NAV)
- register_components(_MARKET_LINK_NAV)
- register_components(_POST_CARD)
- register_components(_BASE_SHELL)
- register_components(_ERROR_PAGE)
- # Phase 6: layout infrastructure
- register_components(_APP_SHELL)
- register_components(_APP_LAYOUT)
- register_components(_OOB_RESPONSE)
- register_components(_HEADER_ROW)
- register_components(_MENU_ROW)
- register_components(_NAV_LINK)
- register_components(_INFINITE_SCROLL)
- register_components(_STATUS_PILL)
- register_components(_SEARCH_MOBILE)
- register_components(_SEARCH_DESKTOP)
- register_components(_MOBILE_FILTER)
- register_components(_ORDER_SUMMARY_CARD)
- # Relation-driven components
- register_components(_RELATION_NAV)
- register_components(_RELATION_ATTACH)
- register_components(_RELATION_DETACH)
-
-
-# ---------------------------------------------------------------------------
-# ~link-card
-# ---------------------------------------------------------------------------
-# Replaces: blog/templates/fragments/link_card.html
-# market/templates/fragments/link_card.html
-# events/templates/fragments/link_card.html
-# federation/templates/fragments/link_card.html
-# artdag/l1/app/templates/fragments/link_card.html
-#
-# Usage:
-# sexp('(~link-card :link "/post/apple/" :title "Apple" :image "/img/a.jpg")')
-# sexp('(~link-card :link url :title title :icon "fas fa-file-alt")', **ctx)
-# ---------------------------------------------------------------------------
-
-_LINK_CARD = '''
-(defcomp ~link-card (&key link title image icon subtitle detail data-app)
- (a :href link
- :class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
- :data-fragment "link-card"
- :data-app data-app
- :data-hx-disable true
- (div :class "flex flex-row items-start gap-3 p-3"
- (if image
- (img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
- (div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
- (i :class icon)))
- (div :class "flex-1 min-w-0"
- (div :class "font-medium text-stone-900 text-sm clamp-2" title)
- (when subtitle
- (div :class "text-xs text-stone-500 mt-0.5" subtitle))
- (when detail
- (div :class "text-xs text-stone-400 mt-1" detail))))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~cart-mini
-# ---------------------------------------------------------------------------
-# Replaces: cart/templates/fragments/cart_mini.html
-#
-# Usage:
-# sexp('(~cart-mini :cart-count count :blog-url burl :cart-url curl)',
-# count=0, burl="https://blog.rose-ash.com", curl="https://cart.rose-ash.com")
-# ---------------------------------------------------------------------------
-
-_CART_MINI = '''
-(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
- (div :id "cart-mini"
- :hx-swap-oob oob
- (if (= cart-count 0)
- (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
- (a :href blog-url
- :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
- (img :src (str blog-url "static/img/logo.jpg")
- :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))
- (a :href cart-url
- :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
- (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
- (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
- cart-count)))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~auth-menu
-# ---------------------------------------------------------------------------
-# Replaces: account/templates/fragments/auth_menu.html
-#
-# Usage:
-# sexp('(~auth-menu :user-email email :account-url aurl)',
-# email="user@example.com", aurl="https://account.rose-ash.com")
-# ---------------------------------------------------------------------------
-
-_AUTH_MENU = '''
-(defcomp ~auth-menu (&key user-email account-url)
- (<>
- (span :id "auth-menu-desktop" :class "hidden md:inline-flex"
- (if user-email
- (a :href account-url
- :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
- :data-close-details true
- (i :class "fa-solid fa-user")
- (span user-email))
- (a :href account-url
- :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
- :data-close-details true
- (i :class "fa-solid fa-key")
- (span "sign in or register"))))
- (span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold"
- (if user-email
- (a :href account-url :data-close-details true
- (i :class "fa-solid fa-user")
- (span user-email))
- (a :href account-url
- (i :class "fa-solid fa-key")
- (span "sign in or register"))))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~account-nav-item
-# ---------------------------------------------------------------------------
-# Replaces: hardcoded HTML in cart/bp/fragments/routes.py
-# and orders/bp/fragments/routes.py
-#
-# Usage:
-# sexp('(~account-nav-item :href url :label "orders")', url=cart_url("/orders/"))
-# ---------------------------------------------------------------------------
-
-_ACCOUNT_NAV_ITEM = '''
-(defcomp ~account-nav-item (&key href label)
- (div :class "relative nav-group"
- (a :href href
- :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
- :data-hx-disable true
- label)))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~calendar-entry-nav
-# ---------------------------------------------------------------------------
-# Replaces: events/templates/fragments/container_nav_entries.html (per-entry)
-#
-# Usage:
-# sexp('(~calendar-entry-nav :href url :name name :date-str "Jan 15, 2026 at 14:00")',
-# url="/events/...", name="Workshop")
-# ---------------------------------------------------------------------------
-
-_CALENDAR_ENTRY_NAV = '''
-(defcomp ~calendar-entry-nav (&key href name date-str nav-class)
- (a :href href :class nav-class
- (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
- (div :class "flex-1 min-w-0"
- (div :class "font-medium truncate" name)
- (div :class "text-xs text-stone-600 truncate" date-str))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~calendar-link-nav
-# ---------------------------------------------------------------------------
-# Replaces: events/templates/fragments/container_nav_calendars.html (per-calendar)
-#
-# Usage:
-# sexp('(~calendar-link-nav :href url :name "My Calendar")', url="/events/...")
-# ---------------------------------------------------------------------------
-
-_CALENDAR_LINK_NAV = '''
-(defcomp ~calendar-link-nav (&key href name nav-class)
- (a :href href :class nav-class
- (i :class "fa fa-calendar" :aria-hidden "true")
- (div name)))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~market-link-nav
-# ---------------------------------------------------------------------------
-# Replaces: market/templates/fragments/container_nav_markets.html (per-market)
-#
-# Usage:
-# sexp('(~market-link-nav :href url :name "Farm Shop")', url="/market/...")
-# ---------------------------------------------------------------------------
-
-_MARKET_LINK_NAV = '''
-(defcomp ~market-link-nav (&key href name nav-class)
- (a :href href :class nav-class
- (i :class "fa fa-shopping-bag" :aria-hidden "true")
- (div name)))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~post-card
-# ---------------------------------------------------------------------------
-# Replaces: blog/templates/_types/blog/_card.html
-#
-# A simplified s-expression version of the blog listing card.
-# The full card is complex (like buttons, card widgets, at_bar with tag/author
-# filtering). This component covers the core card structure; the at_bar and
-# card_widgets are passed as pre-rendered HTML via :at-bar-html and
-# :widgets-html kwargs for incremental migration.
-#
-# Usage:
-# sexp('(~post-card :title t :slug s :href h ...)', **ctx)
-# ---------------------------------------------------------------------------
-
-_POST_CARD = '''
-(defcomp ~post-card (&key title slug href feature-image excerpt
- status published-at updated-at publish-requested
- hx-select like-html widgets-html at-bar-html)
- (article :class "border-b pb-6 last:border-b-0 relative"
- (when like-html (raw! like-html))
- (a :href href
- :hx-get href
- :hx-target "#main-panel"
- :hx-select hx-select
- :hx-swap "outerHTML"
- :hx-push-url "true"
- :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
- (header :class "mb-2 text-center"
- (h2 :class "text-4xl font-bold text-stone-900" title)
- (cond
- (= status "draft")
- (begin
- (div :class "flex justify-center gap-2 mt-1"
- (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
- (when publish-requested
- (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
- (when updated-at
- (p :class "text-sm text-stone-500" (str "Updated: " updated-at))))
- published-at
- (p :class "text-sm text-stone-500" (str "Published: " published-at))))
- (when feature-image
- (div :class "mb-4"
- (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
- (when excerpt
- (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
- (when widgets-html (raw! widgets-html))
- (when at-bar-html (raw! at-bar-html))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~base-shell — full HTML document wrapper
-# ---------------------------------------------------------------------------
-# Replaces: shared/browser/templates/_types/root/index.html (the shell)
-#
-# Usage: For full-page s-expression rendering (Step 4 proof of concept)
-# ---------------------------------------------------------------------------
-
-_BASE_SHELL = '''
-(defcomp ~base-shell (&key title asset-url &rest children)
- (<>
- (raw! "")
- (html :lang "en"
- (head
- (meta :charset "utf-8")
- (meta :name "viewport" :content "width=device-width, initial-scale=1")
- (title title)
- (style
- "body{margin:0;min-height:100vh;display:flex;align-items:center;"
- "justify-content:center;font-family:system-ui,sans-serif;"
- "background:#fafaf9;color:#1c1917}")
- (script :src "https://cdn.tailwindcss.com")
- (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")))
- (body :class "bg-stone-50 text-stone-900"
- children))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~error-page — styled error page
-# ---------------------------------------------------------------------------
-# Replaces: shared/browser/templates/_types/root/exceptions/_.html
-# + base.html + 404/message.html + 404/img.html
-#
-# Usage:
-# sexp('(~error-page :title "Not Found" :message "NOT FOUND" :image img-url :asset-url aurl)',
-# img_url="/static/errors/404.gif", aurl="/static")
-# ---------------------------------------------------------------------------
-
-_ERROR_PAGE = '''
-(defcomp ~error-page (&key title message image asset-url)
- (~base-shell :title title :asset-url asset-url
- (div :class "text-center p-8 max-w-lg mx-auto"
- (div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4"
- (div message))
- (when image
- (div :class "flex justify-center"
- (img :src image :width "300" :height "300"))))))
-'''
-
-
-# ===================================================================
-# Phase 6: Layout infrastructure components
-# ===================================================================
-
-# ---------------------------------------------------------------------------
-# ~app-shell — full HTML document with all required CSS/JS assets
-# ---------------------------------------------------------------------------
-# Replaces: _types/root/index.html ... shell
-#
-# This includes htmx, hyperscript, tailwind, fontawesome, prism, and
-# all shared CSS/JS. ``~base-shell`` remains the lightweight error-page
-# shell; ``~app-shell`` is for real app pages.
-#
-# Usage:
-# sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx)
-# ---------------------------------------------------------------------------
-
-_APP_SHELL = r'''
-(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
- (<>
- (raw! "")
- (html :lang "en"
- (head
- (meta :charset "utf-8")
- (meta :name "viewport" :content "width=device-width, initial-scale=1")
- (meta :name "robots" :content "index,follow")
- (meta :name "theme-color" :content "#ffffff")
- (title title)
- (when meta-html (raw! meta-html))
- (style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }")
- (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
- (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
- (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
- (script :src "https://unpkg.com/htmx.org@2.0.8")
- (script :src "https://unpkg.com/hyperscript.org@0.9.12")
- (script :src "https://cdn.tailwindcss.com")
- (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
- (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css"))
- (link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet")
- (script :src "https://unpkg.com/prismjs/prism.js")
- (script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
- (script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
- (script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
- (script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
- (script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}")
- (script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})")
- (style
- "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}"
- "details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}"
- "@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}"
- "img{max-width:100%;height:auto}"
- ".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}"
- ".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}"
- ".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}"
- "details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}"
- ".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}"
- ".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))
- (body :class "bg-stone-50 text-stone-900"
- (raw! body-html)
- (when body-end-html (raw! body-end-html))
- (script :src (str asset-url "/scripts/body.js"))))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~app-layout — page body layout (header + filter + aside + main-panel)
-# ---------------------------------------------------------------------------
-# Replaces: _types/root/index.html body structure
-#
-# The header uses a / pattern for mobile menu toggle.
-# All content sections are passed as pre-rendered HTML strings.
-#
-# Usage:
-# sexp('(~app-layout :title t :asset-url a :header-rows-html h
-# :menu-html m :filter-html f :aside-html a :content-html c)', **ctx)
-# ---------------------------------------------------------------------------
-
-_APP_LAYOUT = r'''
-(defcomp ~app-layout (&key title asset-url meta-html menu-colour
- header-rows-html menu-html
- filter-html aside-html content-html
- body-end-html)
- (let* ((colour (or menu-colour "sky")))
- (~app-shell :title (or title "Rose Ash") :asset-url asset-url
- :meta-html meta-html :body-end-html body-end-html
- :body-html (str
- ""
- "
"
- ""
- ""
- ""
- "
"
- ""
- " "
- "
"
- "
"
- (or filter-html "")
- "
"
- "
"
- ""
- "
"
- "
"
- "
"
- (or content-html "")
- ""
- ""
- "
"
- "
"
- ""
- "
"))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~oob-response — HTMX OOB multi-target swap wrapper
-# ---------------------------------------------------------------------------
-# Replaces: oob_elements.html base template
-#
-# Each named region gets hx-swap-oob="outerHTML" on its wrapper div.
-# The oobs-html param contains any extra OOB elements (header row swaps).
-#
-# Usage:
-# sexp('(~oob-response :oobs-html oh :filter-html fh :aside-html ah
-# :menu-html mh :content-html ch)', **ctx)
-# ---------------------------------------------------------------------------
-
-_OOB_RESPONSE = '''
-(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
- (<>
- (when oobs-html (raw! oobs-html))
- (div :id "filter" :hx-swap-oob "outerHTML"
- (when filter-html (raw! filter-html)))
- (aside :id "aside" :hx-swap-oob "outerHTML"
- :class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
- (when aside-html (raw! aside-html)))
- (div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
- (when menu-html (raw! menu-html)))
- (section :id "main-panel"
- :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
- (when content-html (raw! content-html)))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~header-row — root header bar (cart-mini, title, nav-tree, auth-menu)
-# ---------------------------------------------------------------------------
-# Replaces: _types/root/header/_header.html header_row macro
-#
-# Usage:
-# sexp('(~header-row :cart-mini-html cm :blog-url bu :site-title st
-# :nav-tree-html nh :auth-menu-html ah :nav-panel-html np
-# :settings-url su :is-admin ia)', **ctx)
-# ---------------------------------------------------------------------------
-
-_HEADER_ROW = '''
-(defcomp ~header-row (&key cart-mini-html blog-url site-title
- nav-tree-html auth-menu-html nav-panel-html
- settings-url is-admin oob hamburger-html)
- (<>
- (div :id "root-row"
- :hx-swap-oob (if oob "outerHTML" nil)
- :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
- (div :class "w-full flex flex-row items-top"
- (when cart-mini-html (raw! cart-mini-html))
- (div :class "font-bold text-5xl flex-1"
- (a :href (or blog-url "/") :class "flex justify-center md:justify-start"
- (h1 (or site-title ""))))
- (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
- (when nav-tree-html (raw! nav-tree-html))
- (when auth-menu-html (raw! auth-menu-html))
- (when nav-panel-html (raw! nav-panel-html))
- (when (and is-admin settings-url)
- (a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
- (i :class "fa fa-cog" :aria-hidden "true"))))
- (when hamburger-html (raw! hamburger-html))))
- (div :class "block md:hidden text-md font-bold"
- (when auth-menu-html (raw! auth-menu-html)))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~menu-row — section header row (wraps in colored bar)
-# ---------------------------------------------------------------------------
-# Replaces: macros/links.html menu_row macro
-#
-# Each nested header row gets a progressively lighter background.
-# The route handler passes the level (0-based depth after root).
-#
-# Usage:
-# sexp('(~menu-row :id "auth-row" :level 1 :colour "sky"
-# :link-href url :link-label "account" :icon "fa-solid fa-user"
-# :nav-html nh :child-id "auth-header-child" :child-html ch)', **ctx)
-# ---------------------------------------------------------------------------
-
-_MENU_ROW = '''
-(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon
- hx-select nav-html child-id child-html oob)
- (let* ((c (or colour "sky"))
- (lv (or level 1))
- (shade (str (- 500 (* lv 100)))))
- (<>
- (div :id id
- :hx-swap-oob (if oob "outerHTML" nil)
- :class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
- (div :class "relative nav-group"
- (a :href link-href
- :hx-get link-href
- :hx-target "#main-panel"
- :hx-select (or hx-select "#main-panel")
- :hx-swap "outerHTML"
- :hx-push-url "true"
- :class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
- (when icon (i :class icon :aria-hidden "true"))
- (if link-label-html (raw! link-label-html)
- (when link-label (div link-label)))))
- (when nav-html
- (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
- (raw! nav-html))))
- (when child-id
- (div :id child-id :class "flex flex-col w-full items-center"
- (when child-html (raw! child-html)))))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~nav-link — HTMX navigation link (replaces macros/links.html link macro)
-# ---------------------------------------------------------------------------
-
-_NAV_LINK = '''
-(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
- (div :class "relative nav-group"
- (a :href href
- :hx-get href
- :hx-target "#main-panel"
- :hx-select (or hx-select "#main-panel")
- :hx-swap "outerHTML"
- :hx-push-url "true"
- :class (or aclass
- (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
- (or select-colours "")))
- (when icon (i :class icon :aria-hidden "true"))
- (when label (span label)))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~infinite-scroll — pagination sentinel for table-based lists
-# ---------------------------------------------------------------------------
-# Replaces: sentinel pattern in _rows.html templates
-#
-# For table rows (orders, etc.): renders with intersection observer.
-# Uses hyperscript for retry with exponential backoff.
-#
-# Usage:
-# sexp('(~infinite-scroll :url next-url :page p :total-pages tp
-# :id-prefix "orders" :colspan 5)', **ctx)
-# ---------------------------------------------------------------------------
-
-_INFINITE_SCROLL = r'''
-(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
- (if (< page total-pages)
- (raw! (str
- "
htmx.trigger(me, 'sentinel:retry'), myMs) "
- "end "
- "on htmx:beforeRequest "
- "set me.style.pointerEvents to 'none' "
- "set me.style.opacity to '0' "
- "end "
- "on htmx:afterSwap set me.dataset.retryMs to 1000 end "
- "on htmx:sendError call backoff() "
- "on htmx:responseError call backoff() "
- "on htmx:timeout call backoff()"
- "\""
- " role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
- ""
- ""
- " loading… " page " / " total-pages " "
- " "
- " "
- ""
- " loading… " page " / " total-pages " "
- " "
- " "
- " |
"))
- (raw! (str
- "| End of results |
"))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~status-pill — colored status indicator
-# ---------------------------------------------------------------------------
-# Replaces: inline Jinja status pill patterns across templates
-#
-# Usage:
-# sexp('(~status-pill :status s :size "sm")', status="paid")
-# ---------------------------------------------------------------------------
-
-_STATUS_PILL = '''
-(defcomp ~status-pill (&key status size)
- (let* ((s (or status "pending"))
- (lower (lower s))
- (sz (or size "xs"))
- (colours (cond
- (= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
- (= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
- (= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
- (or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
- (= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
- (= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
- true "border-stone-300 bg-stone-50 text-stone-700")))
- (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
- s)))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~search-mobile — mobile search input with htmx
-# ---------------------------------------------------------------------------
-
-_SEARCH_MOBILE = '''
-(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
- (div :id "search-mobile-wrapper"
- :class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
- (input :id "search-mobile"
- :type "text" :name "search" :aria-label "search"
- :class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
- :hx-preserve true
- :value (or search "")
- :placeholder "search"
- :hx-trigger "input changed delay:300ms"
- :hx-target "#main-panel"
- :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
- :hx-get current-local-href
- :hx-swap "outerHTML"
- :hx-push-url "true"
- :hx-headers search-headers-mobile
- :hx-sync "this:replace"
- :autocomplete "off")
- (div :id "search-count-mobile" :aria-label "search count"
- :class (if (not search-count) "text-xl text-red-500" "")
- (when search (raw! (str search-count))))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~search-desktop — desktop search input with htmx
-# ---------------------------------------------------------------------------
-
-_SEARCH_DESKTOP = '''
-(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
- (div :id "search-desktop-wrapper"
- :class "flex flex-row gap-2 items-center"
- (input :id "search-desktop"
- :type "text" :name "search" :aria-label "search"
- :class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
- :hx-preserve true
- :value (or search "")
- :placeholder "search"
- :hx-trigger "input changed delay:300ms"
- :hx-target "#main-panel"
- :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
- :hx-get current-local-href
- :hx-swap "outerHTML"
- :hx-push-url "true"
- :hx-headers search-headers-desktop
- :hx-sync "this:replace"
- :autocomplete "off")
- (div :id "search-count-desktop" :aria-label "search count"
- :class (if (not search-count) "text-xl text-red-500" "")
- (when search (raw! (str search-count))))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~mobile-filter — mobile filter details/summary panel
-# ---------------------------------------------------------------------------
-# Replaces: blog/templates/_types/blog/mobile/_filter/summary.html
-# + macros/layout.html details/filter_summary
-#
-# Usage:
-# sexp('(~mobile-filter :filter-summary-html fsh :action-buttons-html abh
-# :filter-details-html fdh)',
-# fsh="...", abh="...", fdh="...")
-# ---------------------------------------------------------------------------
-
-_MOBILE_FILTER = '''
-(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html)
- (details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
- (summary :class "bg-white/90"
- (div :class "flex flex-row items-start"
- (div
- (div :class "md:hidden mx-2 bg-stone-200 rounded"
- (span :class "flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start"
- (i :class "fa-solid fa-filter"))
- (span
- (svg :aria-hidden "true" :viewBox "0 0 24 24"
- :class "w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start"
- (path :d "M6 9l6 6 6-6" :fill "currentColor")))))
- (div :id "filter-summary-mobile"
- :class "flex-1 md:hidden grid grid-cols-12 items-center gap-3"
- (div :class "flex flex-col items-start gap-2"
- (raw! filter-summary-html)))))
- (raw! (or action-buttons-html ""))
- (div :id "filter-details-mobile" :style "display:contents"
- (raw! (or filter-details-html "")))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~order-summary-card — reusable order summary card
-# ---------------------------------------------------------------------------
-_ORDER_SUMMARY_CARD = r'''
-(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
- (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
- (p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
- (p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
- (p (span :class "font-medium" "Description:") " " (or description "\u2013"))
- (p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
- (p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
- (p (span :class "font-medium" "Total:") " "
- (if total-amount
- (str (or currency "GBP") " " total-amount)
- "\u2013"))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~relation-nav
-# ---------------------------------------------------------------------------
-
-_RELATION_NAV = '''
-(defcomp ~relation-nav (&key href name icon nav-class relation-type)
- (a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")
- (when icon
- (div :class "w-8 h-8 rounded bg-stone-200 flex items-center justify-center flex-shrink-0"
- (i :class icon :aria-hidden "true")))
- (div :class "flex-1 min-w-0"
- (div :class "font-medium truncate" name))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~relation-attach
-# ---------------------------------------------------------------------------
-
-_RELATION_ATTACH = '''
-(defcomp ~relation-attach (&key create-url label icon)
- (a :href create-url
- :hx-get create-url
- :hx-target "#main-panel"
- :hx-swap "outerHTML"
- :hx-push-url "true"
- :class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
- (when icon (i :class icon))
- (span (or label "Add"))))
-'''
-
-
-# ---------------------------------------------------------------------------
-# ~relation-detach
-# ---------------------------------------------------------------------------
-
-_RELATION_DETACH = '''
-(defcomp ~relation-detach (&key detach-url name)
- (button :hx-delete detach-url
- :hx-confirm (str "Remove " (or name "this item") "?")
- :class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
- (i :class "fa fa-times" :aria-hidden "true")))
-'''
+ templates_dir = os.path.join(os.path.dirname(__file__), "templates")
+ load_sexp_dir(templates_dir)
diff --git a/shared/sexp/jinja_bridge.py b/shared/sexp/jinja_bridge.py
index 4857821..4160583 100644
--- a/shared/sexp/jinja_bridge.py
+++ b/shared/sexp/jinja_bridge.py
@@ -20,6 +20,8 @@ Setup::
from __future__ import annotations
+import glob
+import os
from typing import Any
from .types import NIL, Symbol
@@ -41,6 +43,13 @@ def get_component_env() -> dict[str, Any]:
return _COMPONENT_ENV
+def load_sexp_dir(directory: str) -> None:
+ """Load all .sexp files from a directory and register components."""
+ for filepath in sorted(glob.glob(os.path.join(directory, "*.sexp"))):
+ with open(filepath, encoding="utf-8") as f:
+ register_components(f.read())
+
+
def register_components(sexp_source: str) -> None:
"""Parse and evaluate s-expression component definitions into the
shared environment.
diff --git a/shared/sexp/templates/cards.sexp b/shared/sexp/templates/cards.sexp
new file mode 100644
index 0000000..10c974e
--- /dev/null
+++ b/shared/sexp/templates/cards.sexp
@@ -0,0 +1,44 @@
+(defcomp ~post-card (&key title slug href feature-image excerpt
+ status published-at updated-at publish-requested
+ hx-select like-html widgets-html at-bar-html)
+ (article :class "border-b pb-6 last:border-b-0 relative"
+ (when like-html (raw! like-html))
+ (a :href href
+ :hx-get href
+ :hx-target "#main-panel"
+ :hx-select hx-select
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
+ (header :class "mb-2 text-center"
+ (h2 :class "text-4xl font-bold text-stone-900" title)
+ (cond
+ (= status "draft")
+ (begin
+ (div :class "flex justify-center gap-2 mt-1"
+ (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
+ (when publish-requested
+ (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
+ (when updated-at
+ (p :class "text-sm text-stone-500" (str "Updated: " updated-at))))
+ published-at
+ (p :class "text-sm text-stone-500" (str "Published: " published-at))))
+ (when feature-image
+ (div :class "mb-4"
+ (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
+ (when excerpt
+ (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
+ (when widgets-html (raw! widgets-html))
+ (when at-bar-html (raw! at-bar-html))))
+
+(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
+ (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
+ (p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
+ (p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
+ (p (span :class "font-medium" "Description:") " " (or description "\u2013"))
+ (p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
+ (p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
+ (p (span :class "font-medium" "Total:") " "
+ (if total-amount
+ (str (or currency "GBP") " " total-amount)
+ "\u2013"))))
diff --git a/shared/sexp/templates/controls.sexp b/shared/sexp/templates/controls.sexp
new file mode 100644
index 0000000..3c41a54
--- /dev/null
+++ b/shared/sexp/templates/controls.sexp
@@ -0,0 +1,126 @@
+(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
+ (div :id "search-mobile-wrapper"
+ :class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
+ (input :id "search-mobile"
+ :type "text" :name "search" :aria-label "search"
+ :class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
+ :hx-preserve true
+ :value (or search "")
+ :placeholder "search"
+ :hx-trigger "input changed delay:300ms"
+ :hx-target "#main-panel"
+ :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
+ :hx-get current-local-href
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :hx-headers search-headers-mobile
+ :hx-sync "this:replace"
+ :autocomplete "off")
+ (div :id "search-count-mobile" :aria-label "search count"
+ :class (if (not search-count) "text-xl text-red-500" "")
+ (when search (raw! (str search-count))))))
+
+(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
+ (div :id "search-desktop-wrapper"
+ :class "flex flex-row gap-2 items-center"
+ (input :id "search-desktop"
+ :type "text" :name "search" :aria-label "search"
+ :class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
+ :hx-preserve true
+ :value (or search "")
+ :placeholder "search"
+ :hx-trigger "input changed delay:300ms"
+ :hx-target "#main-panel"
+ :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
+ :hx-get current-local-href
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :hx-headers search-headers-desktop
+ :hx-sync "this:replace"
+ :autocomplete "off")
+ (div :id "search-count-desktop" :aria-label "search count"
+ :class (if (not search-count) "text-xl text-red-500" "")
+ (when search (raw! (str search-count))))))
+
+(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html)
+ (details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
+ (summary :class "bg-white/90"
+ (div :class "flex flex-row items-start"
+ (div
+ (div :class "md:hidden mx-2 bg-stone-200 rounded"
+ (span :class "flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start"
+ (i :class "fa-solid fa-filter"))
+ (span
+ (svg :aria-hidden "true" :viewBox "0 0 24 24"
+ :class "w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start"
+ (path :d "M6 9l6 6 6-6" :fill "currentColor")))))
+ (div :id "filter-summary-mobile"
+ :class "flex-1 md:hidden grid grid-cols-12 items-center gap-3"
+ (div :class "flex flex-col items-start gap-2"
+ (raw! filter-summary-html)))))
+ (raw! (or action-buttons-html ""))
+ (div :id "filter-details-mobile" :style "display:contents"
+ (raw! (or filter-details-html "")))))
+
+(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
+ (if (< page total-pages)
+ (raw! (str
+ " htmx.trigger(me, 'sentinel:retry'), myMs) "
+ "end "
+ "on htmx:beforeRequest "
+ "set me.style.pointerEvents to 'none' "
+ "set me.style.opacity to '0' "
+ "end "
+ "on htmx:afterSwap set me.dataset.retryMs to 1000 end "
+ "on htmx:sendError call backoff() "
+ "on htmx:responseError call backoff() "
+ "on htmx:timeout call backoff()"
+ "\""
+ " role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
+ ""
+ ""
+ " loading\u2026 " page " / " total-pages " "
+ " "
+ " "
+ ""
+ " loading\u2026 " page " / " total-pages " "
+ " "
+ " "
+ " |
"))
+ (raw! (str
+ "| End of results |
"))))
+
+(defcomp ~status-pill (&key status size)
+ (let* ((s (or status "pending"))
+ (lower (lower s))
+ (sz (or size "xs"))
+ (colours (cond
+ (= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
+ (= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
+ (= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
+ (or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
+ (= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
+ (= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
+ true "border-stone-300 bg-stone-50 text-stone-700")))
+ (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
+ s)))
diff --git a/shared/sexp/templates/fragments.sexp b/shared/sexp/templates/fragments.sexp
new file mode 100644
index 0000000..babdf05
--- /dev/null
+++ b/shared/sexp/templates/fragments.sexp
@@ -0,0 +1,62 @@
+(defcomp ~link-card (&key link title image icon subtitle detail data-app)
+ (a :href link
+ :class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
+ :data-fragment "link-card"
+ :data-app data-app
+ :data-hx-disable true
+ (div :class "flex flex-row items-start gap-3 p-3"
+ (if image
+ (img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
+ (div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
+ (i :class icon)))
+ (div :class "flex-1 min-w-0"
+ (div :class "font-medium text-stone-900 text-sm clamp-2" title)
+ (when subtitle
+ (div :class "text-xs text-stone-500 mt-0.5" subtitle))
+ (when detail
+ (div :class "text-xs text-stone-400 mt-1" detail))))))
+
+(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
+ (div :id "cart-mini"
+ :hx-swap-oob oob
+ (if (= cart-count 0)
+ (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
+ (a :href blog-url
+ :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
+ (img :src (str blog-url "static/img/logo.jpg")
+ :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))
+ (a :href cart-url
+ :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
+ (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
+ (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
+ cart-count)))))
+
+(defcomp ~auth-menu (&key user-email account-url)
+ (<>
+ (span :id "auth-menu-desktop" :class "hidden md:inline-flex"
+ (if user-email
+ (a :href account-url
+ :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
+ :data-close-details true
+ (i :class "fa-solid fa-user")
+ (span user-email))
+ (a :href account-url
+ :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
+ :data-close-details true
+ (i :class "fa-solid fa-key")
+ (span "sign in or register"))))
+ (span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold"
+ (if user-email
+ (a :href account-url :data-close-details true
+ (i :class "fa-solid fa-user")
+ (span user-email))
+ (a :href account-url
+ (i :class "fa-solid fa-key")
+ (span "sign in or register"))))))
+
+(defcomp ~account-nav-item (&key href label)
+ (div :class "relative nav-group"
+ (a :href href
+ :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
+ :data-hx-disable true
+ label)))
diff --git a/shared/sexp/templates/layout.sexp b/shared/sexp/templates/layout.sexp
new file mode 100644
index 0000000..9d96a9c
--- /dev/null
+++ b/shared/sexp/templates/layout.sexp
@@ -0,0 +1,164 @@
+(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
+ (<>
+ (raw! "")
+ (html :lang "en"
+ (head
+ (meta :charset "utf-8")
+ (meta :name "viewport" :content "width=device-width, initial-scale=1")
+ (meta :name "robots" :content "index,follow")
+ (meta :name "theme-color" :content "#ffffff")
+ (title title)
+ (when meta-html (raw! meta-html))
+ (style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }")
+ (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
+ (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
+ (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
+ (script :src "https://unpkg.com/htmx.org@2.0.8")
+ (script :src "https://unpkg.com/hyperscript.org@0.9.12")
+ (script :src "https://cdn.tailwindcss.com")
+ (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
+ (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css"))
+ (link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet")
+ (script :src "https://unpkg.com/prismjs/prism.js")
+ (script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
+ (script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
+ (script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
+ (script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
+ (script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}")
+ (script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})")
+ (style
+ "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}"
+ "details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}"
+ "@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}"
+ "img{max-width:100%;height:auto}"
+ ".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}"
+ ".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}"
+ ".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}"
+ "details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}"
+ ".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}"
+ ".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))
+ (body :class "bg-stone-50 text-stone-900"
+ (raw! body-html)
+ (when body-end-html (raw! body-end-html))
+ (script :src (str asset-url "/scripts/body.js"))))))
+
+(defcomp ~app-layout (&key title asset-url meta-html menu-colour
+ header-rows-html menu-html
+ filter-html aside-html content-html
+ body-end-html)
+ (let* ((colour (or menu-colour "sky")))
+ (~app-shell :title (or title "Rose Ash") :asset-url asset-url
+ :meta-html meta-html :body-end-html body-end-html
+ :body-html (str
+ ""
+ "
"
+ ""
+ ""
+ ""
+ "
"
+ ""
+ " "
+ "
"
+ "
"
+ (or filter-html "")
+ "
"
+ "
"
+ ""
+ "
"
+ "
"
+ "
"
+ (or content-html "")
+ ""
+ ""
+ "
"
+ "
"
+ ""
+ "
"))))
+
+(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
+ (<>
+ (when oobs-html (raw! oobs-html))
+ (div :id "filter" :hx-swap-oob "outerHTML"
+ (when filter-html (raw! filter-html)))
+ (aside :id "aside" :hx-swap-oob "outerHTML"
+ :class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
+ (when aside-html (raw! aside-html)))
+ (div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
+ (when menu-html (raw! menu-html)))
+ (section :id "main-panel"
+ :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
+ (when content-html (raw! content-html)))))
+
+(defcomp ~header-row (&key cart-mini-html blog-url site-title
+ nav-tree-html auth-menu-html nav-panel-html
+ settings-url is-admin oob hamburger-html)
+ (<>
+ (div :id "root-row"
+ :hx-swap-oob (if oob "outerHTML" nil)
+ :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
+ (div :class "w-full flex flex-row items-top"
+ (when cart-mini-html (raw! cart-mini-html))
+ (div :class "font-bold text-5xl flex-1"
+ (a :href (or blog-url "/") :class "flex justify-center md:justify-start"
+ (h1 (or site-title ""))))
+ (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
+ (when nav-tree-html (raw! nav-tree-html))
+ (when auth-menu-html (raw! auth-menu-html))
+ (when nav-panel-html (raw! nav-panel-html))
+ (when (and is-admin settings-url)
+ (a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
+ (i :class "fa fa-cog" :aria-hidden "true"))))
+ (when hamburger-html (raw! hamburger-html))))
+ (div :class "block md:hidden text-md font-bold"
+ (when auth-menu-html (raw! auth-menu-html)))))
+
+(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon
+ hx-select nav-html child-id child-html oob)
+ (let* ((c (or colour "sky"))
+ (lv (or level 1))
+ (shade (str (- 500 (* lv 100)))))
+ (<>
+ (div :id id
+ :hx-swap-oob (if oob "outerHTML" nil)
+ :class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
+ (div :class "relative nav-group"
+ (a :href link-href
+ :hx-get link-href
+ :hx-target "#main-panel"
+ :hx-select (or hx-select "#main-panel")
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
+ (when icon (i :class icon :aria-hidden "true"))
+ (if link-label-html (raw! link-label-html)
+ (when link-label (div link-label)))))
+ (when nav-html
+ (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
+ (raw! nav-html))))
+ (when child-id
+ (div :id child-id :class "flex flex-col w-full items-center"
+ (when child-html (raw! child-html)))))))
+
+(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
+ (div :class "relative nav-group"
+ (a :href href
+ :hx-get href
+ :hx-target "#main-panel"
+ :hx-select (or hx-select "#main-panel")
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :class (or aclass
+ (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
+ (or select-colours "")))
+ (when icon (i :class icon :aria-hidden "true"))
+ (when label (span label)))))
diff --git a/shared/sexp/templates/navigation.sexp b/shared/sexp/templates/navigation.sexp
new file mode 100644
index 0000000..5c5cd0c
--- /dev/null
+++ b/shared/sexp/templates/navigation.sexp
@@ -0,0 +1,24 @@
+(defcomp ~calendar-entry-nav (&key href name date-str nav-class)
+ (a :href href :class nav-class
+ (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
+ (div :class "flex-1 min-w-0"
+ (div :class "font-medium truncate" name)
+ (div :class "text-xs text-stone-600 truncate" date-str))))
+
+(defcomp ~calendar-link-nav (&key href name nav-class)
+ (a :href href :class nav-class
+ (i :class "fa fa-calendar" :aria-hidden "true")
+ (div name)))
+
+(defcomp ~market-link-nav (&key href name nav-class)
+ (a :href href :class nav-class
+ (i :class "fa fa-shopping-bag" :aria-hidden "true")
+ (div name)))
+
+(defcomp ~relation-nav (&key href name icon nav-class relation-type)
+ (a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")
+ (when icon
+ (div :class "w-8 h-8 rounded bg-stone-200 flex items-center justify-center flex-shrink-0"
+ (i :class icon :aria-hidden "true")))
+ (div :class "flex-1 min-w-0"
+ (div :class "font-medium truncate" name))))
diff --git a/shared/sexp/templates/pages.sexp b/shared/sexp/templates/pages.sexp
new file mode 100644
index 0000000..9f3e39e
--- /dev/null
+++ b/shared/sexp/templates/pages.sexp
@@ -0,0 +1,25 @@
+(defcomp ~base-shell (&key title asset-url &rest children)
+ (<>
+ (raw! "")
+ (html :lang "en"
+ (head
+ (meta :charset "utf-8")
+ (meta :name "viewport" :content "width=device-width, initial-scale=1")
+ (title title)
+ (style
+ "body{margin:0;min-height:100vh;display:flex;align-items:center;"
+ "justify-content:center;font-family:system-ui,sans-serif;"
+ "background:#fafaf9;color:#1c1917}")
+ (script :src "https://cdn.tailwindcss.com")
+ (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")))
+ (body :class "bg-stone-50 text-stone-900"
+ children))))
+
+(defcomp ~error-page (&key title message image asset-url)
+ (~base-shell :title title :asset-url asset-url
+ (div :class "text-center p-8 max-w-lg mx-auto"
+ (div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4"
+ (div message))
+ (when image
+ (div :class "flex justify-center"
+ (img :src image :width "300" :height "300"))))))
diff --git a/shared/sexp/templates/relations.sexp b/shared/sexp/templates/relations.sexp
new file mode 100644
index 0000000..ba50984
--- /dev/null
+++ b/shared/sexp/templates/relations.sexp
@@ -0,0 +1,15 @@
+(defcomp ~relation-attach (&key create-url label icon)
+ (a :href create-url
+ :hx-get create-url
+ :hx-target "#main-panel"
+ :hx-swap "outerHTML"
+ :hx-push-url "true"
+ :class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
+ (when icon (i :class icon))
+ (span (or label "Add"))))
+
+(defcomp ~relation-detach (&key detach-url name)
+ (button :hx-delete detach-url
+ :hx-confirm (str "Remove " (or name "this item") "?")
+ :class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
+ (i :class "fa fa-times" :aria-hidden "true")))