Compare commits

..

No commits in common. "main" and "v0.1.1" have entirely different histories.
main ... v0.1.1

139 changed files with 4442 additions and 2772 deletions

1
.gitignore vendored
View File

@ -3,4 +3,3 @@
tmp/
.idea/
.vscode/
*.bnp

224
Makefile
View File

@ -1,55 +1,195 @@
# coffee — build & publish helpers (CODELESS .bnp workflow, WO-WZ-021)
# Coffee — build & deploy helpers (.so plugin workflow)
#
# This theme is a CODELESS .bnp: pure declaration the host runs — blocks
# (blocks/blocks.yaml + .ninjatpl), page/system templates, template overrides,
# an email wrapper, master pages, presets, fonts and CSS in manifest.yaml. No
# Go, no templ, no plugin.wasm, no block/core dependency. `ninja plugin build`
# classifies the repo as codeless (no Go at the root) and packs the artifact
# with manifest.pb (codeless: true) and NO plugin.wasm.
# The plugin compiles to a .so shared object loaded by the CMS at runtime.
# `make rebuild` copies source to the container, builds the .so, and restarts.
#
# Usage:
# make build # pack coffee-<version>.bnp (codeless — no wasm)
# make verify # validate the built .bnp (loader-identical checks)
# make archive-check # prove a clean `git archive HEAD` still builds codeless
# make publish # ninja plugin publish --bnp (to the active registry)
# make clean # remove build artefacts
# make rebuild # Full rebuild: frontend + .so + CSS + migrations, restart
# make backend # Build .so + migrations, restart
# make build-css # Rebuild Tailwind CSS
# make logs # Tail instance logs
# make status # Show instance container status
.PHONY: build verify archive-check publish clean help
.PHONY: rebuild backend build-frontend build-base-binary build-so copy-plugin-source sync-migrations build-css deploy-css logs status help spinup templ bump-patch bump-minor bump-major sync-version clean
# Paths
BLOCKNINJA_DIR := $(HOME)/src/blockninja
PLUGIN_SRC := $(CURDIR)
PLUGIN_NAME := coffee
NINJA := ninja
BNP := $(PLUGIN_NAME)-$(shell grep '^version' plugin.mod | sed 's/.*"\(.*\)"/\1/').bnp
MIGRATIONS_SRC := $(BLOCKNINJA_DIR)/cms/backend/sql/migrations
GO_BUILDER := localhost/blockninja-go-builder:latest
CONTAINER := instance-coffee
ACCOUNT_SLUG := blockninja
INSTANCE_SLUG := coffee
STYLES_DIR := /var/lib/blockninja/$(ACCOUNT_SLUG)/$(INSTANCE_SLUG)/styles
PLUGIN_DEST := /app/data/plugins/src/$(PLUGIN_NAME)
# Pack the codeless .bnp (manifest.pb synthesized from plugin.mod + manifest.yaml
# + blocks/ + templates/; no wasm compile, no DESCRIBE probe).
build:
$(NINJA) plugin build --dir .
# Default target: build the .so locally for development.
all: $(PLUGIN_NAME).so
# Validate the built .bnp exactly as the registry/loader will.
verify:
$(NINJA) plugin verify $(BNP)
# Local plugin build (no container). Useful for CI / quick checks.
$(PLUGIN_NAME).so: $(wildcard *.go) plugin.mod go.mod
CGO_ENABLED=1 go build -buildmode=plugin -ldflags="-s -w" -o $(PLUGIN_NAME).so .
# Prove a clean `git archive HEAD` (what publish ships) still packs codeless.
archive-check:
@T=$$(mktemp -d /tmp/archive-check-$(PLUGIN_NAME).XXXXXX) && \
trap 'rm -rf "$$T"' EXIT && \
git archive HEAD | tar -x -C "$$T" && \
cd "$$T" && $(NINJA) plugin build --dir . && \
echo "archive-check OK"
# Publish the prebuilt .bnp to the active registry.
# Pass --host https://my.blockninja.dev for the dev orchestrator.
publish: build
$(NINJA) plugin publish --bnp $(BNP)
# Remove build artefacts.
# Clean local build artifacts.
clean:
rm -f $(PLUGIN_NAME)-*.bnp plugin.wasm
rm -f $(PLUGIN_NAME).so
# Regenerate templ Go files locally (for development).
templ:
cd $(PLUGIN_SRC) && templ generate
# Ensure blockninja core services and the instance container are running.
spinup:
$(MAKE) -C $(BLOCKNINJA_DIR) spinup
# Full rebuild: frontend + .so plugin + CSS + migrations, restart.
rebuild: spinup
$(MAKE) build-frontend
$(MAKE) build-base-binary
$(MAKE) copy-plugin-source
$(MAKE) build-so
$(MAKE) build-css
$(MAKE) sync-migrations
podman restart $(CONTAINER)
@sleep 2
$(MAKE) deploy-css
@echo ""
@echo "Done. https://$(INSTANCE_SLUG).localdev.blockninjacms.com/"
# Backend-only rebuild: .so plugin + migrations, restart.
backend: spinup
$(MAKE) build-base-binary
$(MAKE) copy-plugin-source
$(MAKE) build-so
$(MAKE) sync-migrations
podman restart $(CONTAINER)
@echo "Backend updated."
# Build host admin UI and deploy to container.
build-frontend:
@echo "==> Building @block-ninja/ui ..."
cd $(BLOCKNINJA_DIR)/cms/packages/ui && pnpm run build
@echo "==> Building host admin UI ..."
cd $(BLOCKNINJA_DIR)/cms/web && pnpm run build
@echo "==> Deploying frontend to container ..."
podman exec $(CONTAINER) rm -rf /app/web/dist
podman cp $(BLOCKNINJA_DIR)/cms/web/dist $(CONTAINER):/app/web/dist
@echo "Frontend deployed."
# Build the base CMS binary (without external plugins) and copy to container.
build-base-binary:
@echo "==> Building base CMS binary ..."
podman run --rm \
-v $(BLOCKNINJA_DIR)/cms/backend:/src/backend:ro \
-v blockninja_go_cache:/go/pkg/mod \
-v /tmp:/out \
-w /src/backend \
$(GO_BUILDER) \
go build -o /out/blockninja-server ./cmd/server
podman cp /tmp/blockninja-server $(CONTAINER):/app/server
rm -f /tmp/blockninja-server
# Copy plugin source into the container's plugin source directory.
copy-plugin-source:
@echo "==> Copying $(PLUGIN_NAME) source to container ..."
podman exec $(CONTAINER) rm -rf $(PLUGIN_DEST)
podman exec $(CONTAINER) mkdir -p $(PLUGIN_DEST)
podman cp $(PLUGIN_SRC)/. $(CONTAINER):$(PLUGIN_DEST)/
podman exec $(CONTAINER) rm -rf $(PLUGIN_DEST)/.git $(PLUGIN_DEST)/Makefile
@echo "Plugin source copied."
# Build the .so using the go-builder container (same toolchain as CMS binary).
build-so:
@echo "==> Building $(PLUGIN_NAME).so ..."
podman run --rm \
-v $(PLUGIN_SRC):/src/plugin:ro \
-v blockninja_go_cache:/go/pkg/mod \
-v /tmp:/out \
-w /src/plugin \
-e CGO_ENABLED=1 \
$(GO_BUILDER) \
go build -buildmode=plugin -ldflags="-s -w" -o /out/$(PLUGIN_NAME).so .
podman exec $(CONTAINER) mkdir -p /app/data/plugins/so
podman cp /tmp/$(PLUGIN_NAME).so $(CONTAINER):/app/data/plugins/so/$(PLUGIN_NAME).so
rm -f /tmp/$(PLUGIN_NAME).so
@echo "$(PLUGIN_NAME).so built."
# Sync base blockninja migration files from host to container.
sync-migrations:
@echo "==> Syncing migrations ..."
@podman unshare bash -c ' \
M=$$(podman mount $(CONTAINER)) && \
rm -rf "$$M/app/migrations" && \
mkdir -p "$$M/app/migrations" && \
podman umount $(CONTAINER)'
@podman cp $(MIGRATIONS_SRC)/. $(CONTAINER):/app/migrations/
@echo "Migrations synced."
# Rebuild Tailwind CSS.
build-css:
@echo "==> Building CSS ..."
cd $(BLOCKNINJA_DIR) && make css
# Copy built CSS to instance styles dir and container.
deploy-css:
@mkdir -p $(STYLES_DIR)
cp $(BLOCKNINJA_DIR)/cms/data/styles/styles.css $(STYLES_DIR)/styles.css
podman cp $(BLOCKNINJA_DIR)/cms/data/styles/styles.css $(CONTAINER):/app/data/styles/styles.css
podman cp $(BLOCKNINJA_DIR)/cms/styles/input.base.css $(CONTAINER):/app/styles/input.base.css
@echo "CSS deployed."
# Tail instance logs.
logs:
podman logs -f $(CONTAINER)
# Show instance container status.
status:
@podman inspect $(CONTAINER) --format \
'Name: {{.Name}}\nImage: {{.Config.Image}}\nStatus: {{.State.Status}}\nHealth: {{.State.Health.Status}}\nStarted: {{.State.StartedAt}}' \
2>/dev/null || echo "Container $(CONTAINER) not found."
help:
@echo "Targets:"
@echo " build Pack the codeless .bnp (no wasm)"
@echo " verify Validate the built .bnp (loader-identical checks)"
@echo " archive-check Prove a clean git archive HEAD packs codeless"
@echo " publish Publish the .bnp to the active registry"
@echo " clean Remove build artefacts"
@echo " all Build $(PLUGIN_NAME).so locally (default)"
@echo " clean Remove $(PLUGIN_NAME).so"
@echo " templ Regenerate templ Go files locally"
@echo " spinup Start blockninja core services + instance container if stopped"
@echo " rebuild Full rebuild: frontend + .so + CSS + migrations, restart"
@echo " backend Build .so + migrations, restart"
@echo " build-frontend Build host admin UI, deploy to container"
@echo " build-base-binary Build base CMS binary, copy to container"
@echo " copy-plugin-source Copy plugin source into container"
@echo " build-so Build .so inside container"
@echo " sync-migrations Copy migration files from host to container"
@echo " build-css Rebuild Tailwind CSS"
@echo " deploy-css Copy CSS to instance styles dir"
@echo " logs Tail instance container logs"
@echo " status Show instance container status"
# --- Version bump targets ---
CURRENT_VERSION := $(shell grep '^version' plugin.mod | sed 's/.*"\(.*\)"/\1/')
bump-patch:
@NEW=$$(echo $(CURRENT_VERSION) | awk -F. '{printf "%d.%d.%d", $$1, $$2, $$3+1}'); \
sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$NEW'"/' plugin.mod; \
git add plugin.mod && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
echo "Bumped to $$NEW and tagged v$$NEW"
bump-minor:
@NEW=$$(echo $(CURRENT_VERSION) | awk -F. '{printf "%d.%d.0", $$1, $$2+1}'); \
sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$NEW'"/' plugin.mod; \
git add plugin.mod && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
echo "Bumped to $$NEW and tagged v$$NEW"
bump-major:
@NEW=$$(echo $(CURRENT_VERSION) | awk -F. '{printf "%d.0.0", $$1+1}'); \
sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$NEW'"/' plugin.mod; \
git add plugin.mod && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
echo "Bumped to $$NEW and tagged v$$NEW"
sync-version:
@TAG=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//'); \
if [ -z "$$TAG" ]; then echo "No tags found"; exit 1; fi; \
sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$TAG'"/' plugin.mod; \
echo "Synced plugin.mod to $$TAG"

View File

@ -1,24 +0,0 @@
# Coffee
A warm, hand-made theme in roasted browns over kraft paper. Hand-drawn doodles, chalk-menu lettering, and stamped badges give it the feel of a neighbourhood café that does everything by hand. It reads friendly and tactile. Nothing about it looks mass-produced.
## Good for
We built Coffee for cafés, roasteries, and slow-craft makers. Treat these as starting points, not limits. If the mood fits your brand, use it:
- Coffee shops and roasteries
- Bakeries and pastry counters
- Small-batch food and drink brands
- Farmers' market and artisan stalls
## What you get
- Five color presets, each with a tuned light mode and a tuned dark mode: Morning Pour, Dark Roast, Kraft Cream, Chalkboard, and Matcha and Oat.
- Fraunces for headings, Work Sans for body, and Caveat for hand-written touches, all bundled. Fonts stay admin-controlled, so you can swap them from the typography settings.
- Four persona blocks: a featured pour to spotlight a drink, a roast profile with tasting notes, a stamp-style loyalty card, and a step-by-step brew guide.
- A demo site that installs when you activate the theme, styled around a roastery: a home page, an about page, a short blog, and a contact page whose form emails your admins.
- Every core block restyled to match, plus a themed login screen and a themed 404 page.
## Install
Install from the BlockNinja registry, then pick a preset and assign your fonts in the admin. Activate the demo content if you want a fully built starting point.

View File

@ -1,50 +1,49 @@
# Coffee — Recommended fonts
The Coffee theme **bundles** its type as woff2 in `assets/fonts/` (latin
subsets, SIL Open Font License 1.1 — see `assets/fonts/OFL.txt`). The three
families become `source=template` rows in the fonts table and are selectable
in the admin typography picker. Templates always resolve font families through
the BlockNinja CSS variables `--font-heading`, `--font-body`, `--font-mono`
with the fallback stacks defined in the theme CSS — no family is hardcoded.
The Coffee theme ships `fonts.json = []` per the wave-1
[FONTS.md](../docs/FONTS.md) policy. No woff2s are bundled in this pass.
Templates resolve font families through the BlockNinja CSS variables
`--font-heading`, `--font-body`, and `--font-mono` with the fallback stacks
defined in `assets/style.css`. The site admin assigns fonts in the
typography panel; the picks below are the spec-aligned defaults that match
the visual identity.
## Bundled families
## How to apply
| Slot | Family | Weights | Role |
|-----------|------------|-----------|------|
| Heading | Fraunces | 400, 600 | Ligature-rich display serif for headings and prices. Warm, hand-cut character. |
| Body | Work Sans | 400, 600 | Relaxed grotesque for long copy at 17px+. Reads well on cream and espresso backgrounds. |
| Accent | Caveat | 400, 700 | Hand-drawn script for badges, stamps, and doodle captions. |
1. Open the admin → Theme → Typography panel.
2. Switch to the **Google Fonts** tab.
3. Search and pick each family below.
4. Assign to the matching slot (Heading / Body / Mono).
5. Save. The picks are persisted as `google:<Family>` and rendered as
`@import` URLs by `theme.GenerateCSS()`.
Mono is intentionally **not** bundled: prices and tabular figures fall back to
`"JetBrains Mono", ..., monospace` via `--font-mono`. Assign a mono family from
the Google Fonts tab if you want a specific face.
## Picks
## Default behaviour (no admin picks)
| Slot | Source | Family | Why |
|----------|------------------|------------------|-----|
| Heading | `google:Fraunces` | Fraunces | Ligature-rich display serif. Italic stylistic alternates land on h2/h3 headings; pairs with the spec's "Quentin Blake ink lines" aesthetic. |
| Body | `google:Inter` | Inter | Relaxed body sans for long copy at 17px+. Excellent screen rendering across cream and espresso backgrounds. |
| Mono | `google:JetBrains Mono` | JetBrains Mono | Tabular-numerals mono for prices in `menu_board` and `featured_pour`, and hours in `hours_strip`. |
On a fresh install the admin has not assigned any slot, so `--font-heading` /
`--font-body` / `--font-mono` are unset and the CSS fallbacks apply. Because the
bundled families emit `@font-face` from the fonts table, the fallbacks resolve
to the bundled woff2:
All three are already in the curated Google Fonts list in the picker and
have permissive SIL OFL 1.1 licences.
- Headings render in **Fraunces**.
- Body renders in **Work Sans**.
- Hand-drawn accents (`.coffee-hand`) render in **Caveat**.
## Fallback stacks (used before admin picks fonts)
## How to change
1. Open admin → Theme → Typography.
2. Pick a family per slot (bundled families appear as **Template** fonts; the
Google Fonts tab offers ~50 more).
3. Assign to Heading / Body / Mono and save.
Note: the platform exposes three controllable slots. The hand-drawn accent
utility resolves through `--font-heading` (falling back to Caveat), so if you
deliberately change the heading font the badge accents follow it — that is the
three-slot model working as intended.
## Fallback stacks (theme CSS)
Defined in `assets/style.css`:
- `--coffee-heading-fallback`: `"Fraunces", "Playfair Display", Georgia, "Times New Roman", serif`
- `--coffee-body-fallback`: `"Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`
- `--coffee-hand-fallback`: `"Caveat", "Segoe Print", "Bradley Hand", cursive`
- `--coffee-body-fallback`: `"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`
- `--coffee-mono-fallback`: `"JetBrains Mono", "Fira Code", Menlo, Consolas, monospace`
Templates always consume the variable form, e.g.
`font-family: var(--font-heading, var(--coffee-heading-fallback))`.
## Wave-2 follow-up
If/when bundling becomes necessary (offline-first deployments, brand
exclusivity), commission/license the spec's exact Fraunces weights
(Regular 400, SemiBold 600, BoldItalic 700), Inter (Regular 400, Medium
500), and JetBrains Mono (Regular 400), and re-populate `fonts.json` per
[FONTS.md §"fonts.json schema"](../docs/FONTS.md). A `LICENSES.md` at the
theme root must also land in that pass.

View File

@ -1,26 +0,0 @@
# Coffee — Recommended icon packs
The Coffee theme renders icons through the CMS sprite system only
(`<svg><use href="/icons/<pack>.svg#<name>"/></svg>`). It relies on two
bundled packs, declared in `plugin.mod` `required_icon_packs`:
| Pack | Used for |
|----------------|----------|
| `lucide` | UI glyphs in builtin overrides (arrows, chevrons, check, map-pin, navigation, star, play, download, x). Bundled with the CMS — no install needed. |
| `simple-icons` | Brand marks in author-bio-hero and social-links (x, linkedin, github, instagram, facebook). Bundled with the CMS — no install needed. |
Both packs ship with the CMS, so the theme renders with no manual setup.
## If the loader does not auto-install
The `required_icon_packs` field is forward-declared. Until the loader honors
it, confirm the packs are present:
1. Open `/admin/icons`.
2. Confirm **Lucide** and **Simple Icons** are installed and enabled (both are
bundled defaults).
3. No downloads are required.
Block content stores icon references as `"pack:name"` strings (e.g.
`"lucide:coffee"`, `"simple-icons:instagram"`). The theme never inlines SVG
paths.

View File

@ -1,111 +0,0 @@
Bundled fonts — SIL Open Font License 1.1
=========================================
This directory bundles latin-subset woff2 files for three families, all
licensed under the SIL Open Font License, Version 1.1 (OFL-1.1).
Copyright notices
-----------------
Fraunces: Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
Work Sans: Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
Caveat: Copyright 2019 The Caveat Project Authors (https://github.com/googlefonts/caveat)
Files
-----
fraunces-latin-400.woff2, fraunces-latin-600.woff2 — Fraunces (heading)
worksans-latin-400.woff2, worksans-latin-600.woff2 — Work Sans (body)
caveat-latin-400.woff2, caveat-latin-700.woff2 — Caveat (hand-drawn accents)
Full license text (SIL OFL 1.1)
-------------------------------
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

View File

@ -1,49 +1,51 @@
/* Coffee theme styles
*
* Dual-mode via the 19 shadcn-style HSL token CSS variables (--background,
* --foreground, --primary, --accent, --border, --muted, --card, ...) consumed
* as hsl(var(--token)). No literal colors. Font families resolve ONLY through
* the BlockNinja font variables (--font-heading, --font-body, --font-mono) with
* the fallback stacks below never hardcoded, so the admin font picker works.
* Uses the 19 shadcn-style HSL token CSS variables (--background,
* --foreground, --primary, --accent, --border, --muted, --card, ...) via
* `hsl(var(--token))`. Font families are resolved through the BlockNinja font
* variables (--font-heading, --font-body, --font-mono) with fallback stacks
* derived from the spec §3 typography list.
*/
/* --- Font-family fallbacks ---------------------------------------------
*
* Templates use the variable form; the second argument is the fallback the
* site shows before the admin assigns fonts via the typography picker.
*/
:root {
--coffee-heading-fallback: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif;
--coffee-body-fallback: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--coffee-hand-fallback: "Caveat", "Segoe Print", "Bradley Hand", cursive;
--coffee-body-fallback: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--coffee-mono-fallback: "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
}
/* --- Paper grain background --------------------------------------------- */
/* --- Paper grain background --------------------------------------------
*
* Inline SVG noise overlay applied as the body background. Keeps file size
* tiny and palette-neutral so it works with all three presets.
*/
body.coffee-paper {
background-color: hsl(var(--background));
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='paper-grain'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.13 0 0 0 0 0.09 0 0 0 0.07 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23paper-grain)'/%3E%3C/svg%3E");
background-repeat: repeat;
}
/* --- Type roles --------------------------------------------------------- */
/* --- Headings ----------------------------------------------------------- */
.coffee-display {
font-family: var(--font-heading, var(--coffee-heading-fallback));
font-feature-settings: "liga" 1, "dlig" 1;
letter-spacing: -0.01em;
}
.coffee-body {
font-family: var(--font-body, var(--coffee-body-fallback));
line-height: 1.65;
}
.coffee-mono {
font-family: var(--font-mono, var(--coffee-mono-fallback));
}
/* Hand-drawn accents (badges, stamps, captions). Routed through --font-heading
* with a Caveat fallback: unassigned it renders Caveat; if the admin sets a
* heading font the accents follow it (the 3-slot model working as intended). */
.coffee-hand {
font-family: var(--font-heading, var(--coffee-hand-fallback));
letter-spacing: 0.01em;
line-height: 1.15;
}
/* --- Doodle underline --------------------------------------------------- */
/* --- Doodle underline (heading override) -------------------------------- */
.coffee-doodle-underline {
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 12' preserveAspectRatio='none'%3E%3Cpath d='M2 8 Q 20 2 40 7 T 80 6 T 118 7' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' opacity='0.55'/%3E%3C/svg%3E");
background-repeat: no-repeat;
@ -52,7 +54,7 @@ body.coffee-paper {
padding-bottom: 0.18em;
}
/* --- Drop-cap ----------------------------------------------------------- */
/* --- Drop-cap (text override / article body) ---------------------------- */
.coffee-dropcap > p:first-of-type::first-letter {
font-family: var(--font-heading, var(--coffee-heading-fallback));
font-size: 4em;
@ -62,7 +64,7 @@ body.coffee-paper {
color: hsl(var(--primary));
}
/* --- Kraft-tag button (stamp press on hover) ---------------------------- */
/* --- Kraft-tag button --------------------------------------------------- */
.kraft-tag {
position: relative;
display: inline-flex;
@ -76,223 +78,74 @@ body.coffee-paper {
font-family: var(--font-body, var(--coffee-body-fallback));
font-weight: 500;
letter-spacing: 0.02em;
box-shadow: 0 2px 0 hsl(var(--border));
box-shadow: 0 1px 0 hsl(var(--border));
transition: transform 120ms ease, box-shadow 120ms ease;
cursor: pointer;
}
.kraft-tag:hover {
transform: translateY(1px) rotate(-1.2deg);
box-shadow: 0 1px 0 hsl(var(--border));
transform: rotate(-1.2deg);
box-shadow: 0 2px 0 hsl(var(--border));
}
.kraft-tag:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 3px;
}
.kraft-tag--ghost {
background-color: transparent;
color: hsl(var(--primary));
box-shadow: none;
border-style: dashed;
}
.kraft-tag--ghost:hover {
background-color: hsl(var(--muted));
}
/* --- Torn-paper edges ---------------------------------------------------
* The deckled edge is a FIXED ~13px band at the named edge; the rest of the
* element is masked solid. (The old `mask-size:100% 100%` stretched the 12px
* torn SVG across the whole element, eating ~40% of tall cards as a transparent
* wavy cut-out the source of the hero/featured-pour/footer clipping.) */
/* --- Torn-edge utility -------------------------------------------------- */
.coffee-torn-top {
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black);
mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black);
-webkit-mask-position: top, bottom;
mask-position: top, bottom;
-webkit-mask-size: 100% 13px, 100% calc(100% - 12px);
mask-size: 100% 13px, 100% calc(100% - 12px);
-webkit-mask-repeat: no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat;
}
.coffee-torn-bottom {
-webkit-mask-image: linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
mask-image: linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: top, bottom;
mask-position: top, bottom;
-webkit-mask-size: 100% calc(100% - 12px), 100% 13px;
mask-size: 100% calc(100% - 12px), 100% 13px;
-webkit-mask-repeat: no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat;
}
.coffee-torn-top.coffee-torn-bottom {
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: top, center, bottom;
mask-position: top, center, bottom;
-webkit-mask-size: 100% 13px, 100% calc(100% - 24px), 100% 13px;
mask-size: 100% 13px, 100% calc(100% - 24px), 100% 13px;
-webkit-mask-repeat: no-repeat, no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat, no-repeat;
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* --- Surfaces ----------------------------------------------------------- */
.coffee-torn-bottom {
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* --- Coffee surfaces ---------------------------------------------------- */
.coffee-card {
background-color: hsl(var(--card));
color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
}
.coffee-frame {
border: 1px solid hsl(var(--border));
background-color: hsl(var(--card));
}
.coffee-pencil-rule {
border-color: hsl(var(--border));
border-style: solid;
}
/* --- Chalkboard menu panel (inverts foreground/background for a hung board;
* light mode = dark board + cream chalk, dark mode = light menu paper) --- */
.coffee-chalkboard {
position: relative;
background-color: hsl(var(--foreground));
color: hsl(var(--background));
border-radius: 0.5rem;
box-shadow: inset 0 0 0 3px hsl(var(--background) / 0.12), inset 0 0 0 4px hsl(var(--foreground));
}
.coffee-chalk-rule {
border-color: hsl(var(--background) / 0.28);
border-style: solid;
}
.coffee-chalk-muted {
color: hsl(var(--background) / 0.72);
}
/* --- Hours: today highlight ---------------------------------------------
* Applied to the whole <tr> so both cells share one readable band. (Was on the
* <th> only with accent-foreground text over a 15%-accent tint light-on-light,
* unreadable.) accent text over a 12% tint of the same accent reads cleanly. */
.coffee-hours-today > th,
.coffee-hours-today > td {
background-color: hsl(var(--accent) / 0.12);
}
.coffee-hours-today > th {
box-shadow: inset 3px 0 0 hsl(var(--accent));
/* --- Today highlight ---------------------------------------------------- */
.coffee-hours-today {
background-color: hsl(var(--accent) / 0.15);
color: hsl(var(--accent-foreground));
border-left: 3px solid hsl(var(--accent));
padding-left: 0.75rem;
color: hsl(var(--accent));
}
/* --- Layout helpers ------------------------------------------------------
* coffee-pour-grid keeps an explicit md 2fr:3fr split a non-standard grid
* ratio with no plain Tailwind utility. Single-column base collapses on
* mobile. */
.coffee-pour-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
@media (min-width: 768px) {
.coffee-pour-grid { grid-template-columns: 2fr 3fr; align-items: center; }
}
/* --- Price + spec chips ------------------------------------------------- */
/* --- Price typography --------------------------------------------------- */
.coffee-price {
font-family: var(--font-mono, var(--coffee-mono-fallback));
font-variant-numeric: tabular-nums;
color: hsl(var(--accent));
}
.coffee-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
border: 1px dashed hsl(var(--border));
border-radius: 9999px;
background-color: hsl(var(--muted));
color: hsl(var(--foreground));
font-family: var(--font-body, var(--coffee-body-fallback));
font-size: 0.85rem;
}
/* --- Circular stamp / badge (press on hover) ---------------------------- */
.coffee-stamp {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4.25rem;
height: 4.25rem;
border-radius: 9999px;
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
border: 2px solid hsl(var(--accent-foreground) / 0.35);
box-shadow: inset 0 0 0 3px hsl(var(--accent));
font-family: var(--font-heading, var(--coffee-hand-fallback));
font-size: 0.95rem;
text-align: center;
line-height: 1.05;
transform: rotate(-6deg);
transition: transform 140ms ease;
}
.coffee-stamp:hover {
transform: rotate(-6deg) scale(0.94);
}
.coffee-stamp-sm {
width: 2.25rem;
height: 2.25rem;
font-size: 1rem;
transform: rotate(-4deg);
box-shadow: none;
}
/* --- Loyalty punch stamps ----------------------------------------------- */
.coffee-punch {
display: inline-flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
border-radius: 9999px;
border: 2px dashed hsl(var(--border));
color: hsl(var(--muted-foreground));
}
.coffee-punch-on {
border-style: solid;
border-color: hsl(var(--accent));
background-color: hsl(var(--accent) / 0.15);
color: hsl(var(--accent));
transform: rotate(-5deg);
}
.coffee-punch-reward {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
}
/* --- Location pin ------------------------------------------------------- */
/* --- Doodle pin overlay for location card ------------------------------- */
.coffee-pin {
color: hsl(var(--accent));
}
/* --- Doodle divider draw-in --------------------------------------------- */
.coffee-doodle-draw path,
.coffee-doodle-draw ellipse,
.coffee-doodle-draw circle {
stroke-dasharray: 240;
stroke-dashoffset: 240;
animation: coffee-draw 1.4s ease forwards;
}
@keyframes coffee-draw {
to { stroke-dashoffset: 0; }
}
/* --- Motion safety: static fallback ------------------------------------- */
@media (prefers-reduced-motion: reduce) {
.kraft-tag,
.coffee-stamp,
.coffee-punch-on { transition: none; }
.kraft-tag:hover,
.coffee-stamp:hover { transform: none; }
.coffee-doodle-draw path,
.coffee-doodle-draw ellipse,
.coffee-doodle-draw circle {
stroke-dasharray: none;
stroke-dashoffset: 0;
animation: none;
}
}

View File

@ -1,32 +0,0 @@
# Coffee theme block definitions (codeless .bnp).
# Only true persona furniture stays a theme block. Everything that duplicated a
# builtin (menu, hours, location, footer, divider) is now a template override of
# the builtin key under templates/overrides/coffee/ — not a block here.
#
# Keys are fully qualified (coffee:<key>): the codeless loader registers
# definitions verbatim (no PluginBlockRegistry prefixing in the codeless path).
blocks:
- key: coffee:featured_pour
title: Featured Pour
description: Single-origin spotlight card with hand-drawn badge, tasting notes and price.
category: content
schema: featured_pour.schema.json
template: featured_pour.ninjatpl
- key: coffee:roast_profile
title: Roast Profile
description: Roastery spec card — origin, process, roast level and tasting notes with a circular roast stamp.
category: content
schema: roast_profile.schema.json
template: roast_profile.ninjatpl
- key: coffee:loyalty_card
title: Loyalty Card
description: Ten-stamp coffee punch card with a reward on the tenth stamp.
category: content
schema: loyalty_card.schema.json
template: loyalty_card.ninjatpl
- key: coffee:brew_guide
title: Brew Guide
description: Step-by-step brew method card with a ratio/grind/water/temp/time strip and numbered steps.
category: content
schema: brew_guide.schema.json
template: brew_guide.ninjatpl

View File

@ -1,29 +0,0 @@
<section data-block="coffee:brew_guide" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-3xl px-4">
<div class="coffee-card p-6 md:p-8">
{% if kicker %}<p class="coffee-hand text-2xl text-accent">{{ kicker }}</p>{% endif %}
{% if method %}<h3 class="coffee-display text-3xl text-primary"><span class="coffee-doodle-underline">{{ method }}</span></h3>{% endif %}
{% if intro %}<p class="coffee-body mt-3 text-muted-foreground">{{ intro }}</p>{% endif %}
<div class="mt-6 flex flex-wrap gap-2">
{% if ratio %}<span class="coffee-chip"><span class="coffee-hand text-base text-accent">Ratio</span> <span class="coffee-mono">{{ ratio }}</span></span>{% endif %}
{% if grind %}<span class="coffee-chip"><span class="coffee-hand text-base text-accent">Grind</span> {{ grind }}</span>{% endif %}
{% if water %}<span class="coffee-chip"><span class="coffee-hand text-base text-accent">Water</span> <span class="coffee-mono">{{ water }}</span></span>{% endif %}
{% if temperature %}<span class="coffee-chip"><span class="coffee-hand text-base text-accent">Temp</span> <span class="coffee-mono">{{ temperature }}</span></span>{% endif %}
{% if time %}<span class="coffee-chip"><span class="coffee-hand text-base text-accent">Time</span> <span class="coffee-mono">{{ time }}</span></span>{% endif %}
</div>
{% if steps %}
<ol class="mt-8 space-y-5">
{% for step in steps %}
<li class="flex gap-4">
<span class="coffee-stamp coffee-stamp-sm shrink-0">{{ forloop.Counter }}</span>
<div class="pt-1">
{% if step.title %}<p class="coffee-display text-lg text-primary">{{ step.title }}</p>{% endif %}
{% if step.text %}<p class="coffee-body text-foreground">{{ step.text }}</p>{% endif %}
</div>
</li>
{% endfor %}
</ol>
{% endif %}
</div>
</div>
</section>

View File

@ -1,67 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Brew Guide",
"description": "Step-by-step brew method card with a ratio/grind/water/temp/time spec strip and numbered steps",
"type": "object",
"properties": {
"kicker": {
"type": "string",
"title": "Kicker (hand-drawn)",
"default": "How we brew it",
"x-editor": "text"
},
"method": {
"type": "string",
"title": "Method",
"description": "e.g. \"V60 Pour-Over\"",
"x-editor": "text"
},
"intro": {
"type": "string",
"title": "Intro",
"x-editor": "textarea"
},
"ratio": {
"type": "string",
"title": "Ratio",
"description": "e.g. \"1:16\"",
"x-editor": "text"
},
"grind": {
"type": "string",
"title": "Grind",
"description": "e.g. \"Medium-fine\"",
"x-editor": "text"
},
"water": {
"type": "string",
"title": "Water",
"description": "e.g. \"300g\"",
"x-editor": "text"
},
"temperature": {
"type": "string",
"title": "Temperature",
"description": "e.g. \"94C\"",
"x-editor": "text"
},
"time": {
"type": "string",
"title": "Total Time",
"description": "e.g. \"3:00\"",
"x-editor": "text"
},
"steps": {
"type": "array",
"title": "Steps",
"x-editor": "collection",
"items": {
"type": "object",
"properties": {
"title": { "type": "string", "title": "Step Title", "x-editor": "text" },
"text": { "type": "string", "title": "Step Text", "x-editor": "textarea" }
}
}
}
}
}

View File

@ -1,14 +0,0 @@
<section data-block="coffee:featured_pour" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="coffee-card coffee-torn-bottom coffee-pour-grid relative mx-auto max-w-4xl p-6">
<span class="coffee-hand absolute -top-4 left-6 inline-flex rotate-[-3deg] items-center gap-1 rounded-full bg-accent px-4 py-1 text-lg text-accent-foreground shadow-sm">{{ label|default:"Featured pour" }}</span>
<div class="aspect-square overflow-hidden rounded-sm bg-secondary">
{% if image %}{% img image alt=name|default:"Featured pour" class="h-full w-full object-cover" %}{% else %}<div class="flex h-full w-full items-center justify-center bg-secondary text-primary/20"><svg width="96" height="96" class="h-24 w-24" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg></div>{% endif %}
</div>
<div class="flex flex-col gap-3">
{% if origin %}<p class="coffee-hand text-xl text-accent">{{ origin }}</p>{% endif %}
{% if name %}<h3 class="coffee-display text-3xl text-primary"><span class="coffee-doodle-underline">{{ name }}</span></h3>{% else %}<h3 class="coffee-display text-3xl italic text-muted-foreground">Featured pour</h3>{% endif %}
{% if tasting %}<div class="coffee-body max-w-none text-foreground [&_strong]:text-primary">{{ tasting|safe }}</div>{% endif %}
{% if price %}<div class="coffee-price mt-1 text-2xl">{{ currency|default:"$" }}{{ price }}</div>{% endif %}
</div>
</div>
</section>

View File

@ -1,24 +0,0 @@
{% with filled=filled|default:0 %}
<section data-block="coffee:loyalty_card" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-md px-4">
<div class="coffee-card coffee-torn-top coffee-torn-bottom relative p-6 text-center">
{% if title %}<h3 class="coffee-display text-2xl text-primary">{{ title }}</h3>{% endif %}
{% if subtitle %}<p class="coffee-hand mt-1 text-xl text-accent">{{ subtitle }}</p>{% endif %}
<div class="mt-6 grid grid-cols-5 gap-3">
<span class="coffee-punch{% if filled >= 1 %} coffee-punch-on{% endif %}">{% if filled >= 1 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 2 %} coffee-punch-on{% endif %}">{% if filled >= 2 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 3 %} coffee-punch-on{% endif %}">{% if filled >= 3 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 4 %} coffee-punch-on{% endif %}">{% if filled >= 4 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 5 %} coffee-punch-on{% endif %}">{% if filled >= 5 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 6 %} coffee-punch-on{% endif %}">{% if filled >= 6 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 7 %} coffee-punch-on{% endif %}">{% if filled >= 7 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 8 %} coffee-punch-on{% endif %}">{% if filled >= 8 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 9 %} coffee-punch-on{% endif %}">{% if filled >= 9 %}<svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}</span>
<span class="coffee-punch{% if filled >= 10 %} coffee-punch-on coffee-punch-reward{% endif %}"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#gift"></use></svg></span>
</div>
{% if reward %}<p class="coffee-body mt-6 text-sm text-muted-foreground">{{ reward }}</p>{% endif %}
{% if footnote %}<p class="coffee-hand mt-2 text-lg text-accent">{{ footnote }}</p>{% endif %}
</div>
</div>
</section>
{% endwith %}

View File

@ -1,38 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Loyalty Card",
"description": "Ten-stamp coffee punch card. The tenth stamp is the reward. Set how many stamps are filled for the demo state.",
"type": "object",
"properties": {
"title": {
"type": "string",
"title": "Title",
"default": "Buy nine, tenth on us",
"x-editor": "text"
},
"subtitle": {
"type": "string",
"title": "Subtitle (hand-drawn)",
"x-editor": "text"
},
"filled": {
"type": "number",
"title": "Stamps Filled",
"description": "How many of the ten stamps are punched (0-10)",
"default": 0,
"minimum": 0,
"maximum": 10,
"x-editor": "number"
},
"reward": {
"type": "string",
"title": "Reward Line",
"x-editor": "text"
},
"footnote": {
"type": "string",
"title": "Footnote (hand-drawn)",
"x-editor": "text"
}
}
}

View File

@ -1,26 +0,0 @@
<section data-block="coffee:roast_profile" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-4xl px-4">
<div class="coffee-card grid gap-8 p-6 md:grid-cols-[3fr_2fr] md:p-8">
<div class="flex flex-col gap-4">
<div class="flex items-start justify-between gap-4">
<div>
{% if origin %}<p class="coffee-hand text-2xl text-accent">{{ origin }}</p>{% endif %}
{% if name %}<h3 class="coffee-display text-3xl text-primary">{{ name }}</h3>{% endif %}
{% if producer %}<p class="coffee-body mt-1 text-sm text-muted-foreground">{{ producer }}</p>{% endif %}
</div>
{% if roastLevel %}<span class="coffee-stamp shrink-0">{{ roastLevel }}</span>{% endif %}
</div>
{% if notes %}<div class="coffee-body text-foreground">{{ notes|safe }}</div>{% endif %}
<dl class="mt-2 grid grid-cols-2 gap-x-6 gap-y-3 border-t border-dashed border-border pt-4 text-sm">
{% if process %}<div><dt class="coffee-hand text-base text-accent">Process</dt><dd class="coffee-body font-medium">{{ process }}</dd></div>{% endif %}
{% if varietal %}<div><dt class="coffee-hand text-base text-accent">Varietal</dt><dd class="coffee-body font-medium">{{ varietal }}</dd></div>{% endif %}
{% if altitude %}<div><dt class="coffee-hand text-base text-accent">Altitude</dt><dd class="coffee-body font-medium">{{ altitude }}</dd></div>{% endif %}
{% if harvest %}<div><dt class="coffee-hand text-base text-accent">Harvest</dt><dd class="coffee-body font-medium">{{ harvest }}</dd></div>{% endif %}
</dl>
</div>
<div class="overflow-hidden rounded-sm bg-secondary coffee-torn-top">
{% if image %}{% img image alt=name|default:"Roast profile" class="h-full w-full object-cover" %}{% else %}<div class="flex h-full min-h-[12rem] items-center justify-center coffee-hand text-2xl text-muted-foreground">Bean photo</div>{% endif %}
</div>
</div>
</div>
</section>

View File

@ -1,64 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Roast Profile",
"description": "Single-origin roastery spec card: origin, process, roast level and tasting notes with a circular roast stamp",
"type": "object",
"properties": {
"origin": {
"type": "string",
"title": "Origin / Kicker",
"description": "Hand-drawn line above the name (e.g. \"Single origin\")",
"x-editor": "text"
},
"name": {
"type": "string",
"title": "Coffee Name",
"description": "e.g. \"Guji Highlands\"",
"x-editor": "text"
},
"producer": {
"type": "string",
"title": "Producer / Farm",
"x-editor": "text"
},
"roastLevel": {
"type": "string",
"title": "Roast Level (stamp)",
"description": "Short label shown in the circular stamp (e.g. \"Medium\")",
"x-editor": "text"
},
"notes": {
"type": "string",
"title": "Tasting Notes",
"description": "Rich text tasting description",
"x-editor": "richtext"
},
"process": {
"type": "string",
"title": "Process",
"description": "e.g. Washed, Natural, Honey",
"x-editor": "text"
},
"varietal": {
"type": "string",
"title": "Varietal",
"x-editor": "text"
},
"altitude": {
"type": "string",
"title": "Altitude",
"description": "e.g. \"1900-2100 masl\"",
"x-editor": "text"
},
"harvest": {
"type": "string",
"title": "Harvest",
"x-editor": "text"
},
"image": {
"type": "string",
"title": "Image",
"x-editor": "media"
}
}
}

18
button_override.go Normal file
View File

@ -0,0 +1,18 @@
package main
import (
"bytes"
"context"
)
// CoffeeButtonBlock renders a button with the kraft-tag override styling.
// Content shape: {"text": "Click me", "url": "...", "variant": "primary|secondary"}
func CoffeeButtonBlock(ctx context.Context, content map[string]any) string {
text := getString(content, "text")
url := getString(content, "url")
variant := getString(content, "variant")
var buf bytes.Buffer
_ = coffeeButtonComponent(text, url, variant).Render(ctx, &buf)
return buf.String()
}

23
button_override.templ Normal file
View File

@ -0,0 +1,23 @@
package main
// coffeeButtonVariantClass appends a variant-specific tone to the kraft-tag.
func coffeeButtonVariantClass(variant string) string {
switch variant {
case "secondary":
return "bg-secondary text-secondary-foreground"
case "destructive":
return "bg-destructive text-destructive-foreground"
default:
return ""
}
}
// coffeeButtonComponent renders a button or link styled like a kraft tag with
// a slight rotation on hover.
templ coffeeButtonComponent(text, url, variant string) {
if url != "" {
<a href={ templ.SafeURL(url) } class={ "kraft-tag", coffeeButtonVariantClass(variant) }>{ text }</a>
} else {
<button type="button" class={ "kraft-tag", coffeeButtonVariantClass(variant) }>{ text }</button>
}
}

136
button_override_templ.go Normal file
View File

@ -0,0 +1,136 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// coffeeButtonVariantClass appends a variant-specific tone to the kraft-tag.
func coffeeButtonVariantClass(variant string) string {
switch variant {
case "secondary":
return "bg-secondary text-secondary-foreground"
case "destructive":
return "bg-destructive text-destructive-foreground"
default:
return ""
}
}
// coffeeButtonComponent renders a button or link styled like a kraft tag with
// a slight rotation on hover.
func coffeeButtonComponent(text, url, variant string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if url != "" {
var templ_7745c5c3_Var2 = []any{"kraft-tag", coffeeButtonVariantClass(variant)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(url))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button_override.templ`, Line: 19, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button_override.templ`, Line: 19, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var6 = []any{"kraft-tag", coffeeButtonVariantClass(variant)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<button type=\"button\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button_override.templ`, Line: 21, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

32
doodle_divider.go Normal file
View File

@ -0,0 +1,32 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// DoodleDividerBlockMeta defines metadata for the doodle_divider block.
var DoodleDividerBlockMeta = blocks.BlockMeta{
Key: "doodle_divider",
Title: "Doodle Divider",
Description: "Hand-drawn divider in one of four motifs (beans, croissant, cup, leaf)",
Source: "coffee",
}
// DoodleDividerBlock renders an inline SVG divider based on the motif.
// Content shape: {"motif": "beans"} where motif is one of beans/croissant/cup/leaf.
func DoodleDividerBlock(ctx context.Context, content map[string]any) string {
motif := getString(content, "motif")
switch motif {
case "beans", "croissant", "cup", "leaf":
// allowed
default:
motif = "beans"
}
var buf bytes.Buffer
_ = doodleDividerComponent(motif).Render(ctx, &buf)
return buf.String()
}

47
doodle_divider.templ Normal file
View File

@ -0,0 +1,47 @@
package main
// doodleDividerComponent renders an inline SVG divider whose motif is
// selected by the editor. Each motif draws a distinct hand-drawn shape so
// they look visibly different even at thumbnail size.
templ doodleDividerComponent(motif string) {
<div data-block="coffee:doodle_divider" data-motif={ motif } class="my-8 flex items-center justify-center text-accent">
switch motif {
case "croissant":
<svg viewBox="0 0 200 32" class="w-48 h-8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 18 L70 18"></path>
<path d="M130 18 L196 18"></path>
<path d="M85 22 Q 90 8 100 8 Q 110 8 115 22"></path>
<path d="M88 20 L92 14"></path>
<path d="M96 18 L100 10"></path>
<path d="M104 18 L108 10"></path>
<path d="M112 20 L108 14"></path>
</svg>
case "cup":
<svg viewBox="0 0 200 32" class="w-48 h-8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 18 L75 18"></path>
<path d="M125 18 L196 18"></path>
<path d="M88 8 L112 8 L110 24 L90 24 Z"></path>
<path d="M112 12 Q 120 12 120 16 Q 120 20 112 20"></path>
<path d="M93 4 Q 95 1 97 4"></path>
<path d="M99 4 Q 101 1 103 4"></path>
<path d="M105 4 Q 107 1 109 4"></path>
</svg>
case "leaf":
<svg viewBox="0 0 200 32" class="w-48 h-8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 18 L80 18"></path>
<path d="M120 18 L196 18"></path>
<path d="M90 20 Q 100 4 110 20 Q 100 24 90 20 Z"></path>
<path d="M93 19 L107 19"></path>
</svg>
default:
<svg viewBox="0 0 200 32" class="w-48 h-8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 18 L80 18"></path>
<path d="M120 18 L196 18"></path>
<ellipse cx="95" cy="18" rx="6" ry="9"></ellipse>
<path d="M95 9 L95 27"></path>
<ellipse cx="108" cy="18" rx="6" ry="9"></ellipse>
<path d="M108 9 L108 27"></path>
</svg>
}
</div>
}

82
doodle_divider_templ.go Normal file
View File

@ -0,0 +1,82 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// doodleDividerComponent renders an inline SVG divider whose motif is
// selected by the editor. Each motif draws a distinct hand-drawn shape so
// they look visibly different even at thumbnail size.
func doodleDividerComponent(motif string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div data-block=\"coffee:doodle_divider\" data-motif=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(motif)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `doodle_divider.templ`, Line: 7, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"my-8 flex items-center justify-center text-accent\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
switch motif {
case "croissant":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<svg viewBox=\"0 0 200 32\" class=\"w-48 h-8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 18 L70 18\"></path> <path d=\"M130 18 L196 18\"></path> <path d=\"M85 22 Q 90 8 100 8 Q 110 8 115 22\"></path> <path d=\"M88 20 L92 14\"></path> <path d=\"M96 18 L100 10\"></path> <path d=\"M104 18 L108 10\"></path> <path d=\"M112 20 L108 14\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "cup":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<svg viewBox=\"0 0 200 32\" class=\"w-48 h-8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 18 L75 18\"></path> <path d=\"M125 18 L196 18\"></path> <path d=\"M88 8 L112 8 L110 24 L90 24 Z\"></path> <path d=\"M112 12 Q 120 12 120 16 Q 120 20 112 20\"></path> <path d=\"M93 4 Q 95 1 97 4\"></path> <path d=\"M99 4 Q 101 1 103 4\"></path> <path d=\"M105 4 Q 107 1 109 4\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "leaf":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg viewBox=\"0 0 200 32\" class=\"w-48 h-8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 18 L80 18\"></path> <path d=\"M120 18 L196 18\"></path> <path d=\"M90 20 Q 100 4 110 20 Q 100 24 90 20 Z\"></path> <path d=\"M93 19 L107 19\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<svg viewBox=\"0 0 200 32\" class=\"w-48 h-8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 18 L80 18\"></path> <path d=\"M120 18 L196 18\"></path> <ellipse cx=\"95\" cy=\"18\" rx=\"6\" ry=\"9\"></ellipse> <path d=\"M95 9 L95 27\"></path> <ellipse cx=\"108\" cy=\"18\" rx=\"6\" ry=\"9\"></ellipse> <path d=\"M108 9 L108 27\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

204
email_wrapper.templ Normal file
View File

@ -0,0 +1,204 @@
package main
import (
"bytes"
"context"
"fmt"
"git.dev.alexdunmow.com/block/core/templates"
)
// CoffeeEmailWrapper renders a single-column 560px cream-and-espresso wrapper
// for transactional and newsletter emails. Table-only layout so it survives
// Outlook; doodle divider rendered inline as SVG so it survives email
// clients that strip styles.
func CoffeeEmailWrapper(body string, emailCtx templates.EmailContext) string {
var buf bytes.Buffer
_ = coffeeEmailTemplate(emailCtx, body).Render(context.Background(), &buf)
return buf.String()
}
templ coffeeEmailTemplate(emailCtx templates.EmailContext, body string) {
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="x-apple-disable-message-reformatting"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>{ emailCtx.SiteSettings.SiteName }</title>
<style type="text/css">
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
}
h1, h2, h3, h4, h5, h6 {
font-family: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif;
font-weight: 600;
}
@media only screen and (max-width: 620px) {
.coffee-email-container {
width: 100% !important;
max-width: 100% !important;
}
.coffee-email-padding {
padding-left: 24px !important;
padding-right: 24px !important;
}
}
</style>
</head>
<body style={ fmt.Sprintf("background-color: %s; margin: 0; padding: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;", coffeeBgColor(emailCtx)) }>
if emailCtx.PreviewText != "" {
<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">
{ emailCtx.PreviewText }
</div>
}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" style={ fmt.Sprintf("padding: 40px 10px; background-color: %s;", coffeeBgColor(emailCtx)) }>
<table role="presentation" class="coffee-email-container" width="560" cellspacing="0" cellpadding="0" border="0" style={ fmt.Sprintf("max-width: 560px; background-color: %s; border: 1px solid %s; border-radius: 4px;", coffeeCardColor(emailCtx), coffeeBorderColor(emailCtx)) }>
<tr>
<td align="center" style={ fmt.Sprintf("padding: 32px 40px 16px; border-bottom: 1px dashed %s;", coffeeBorderColor(emailCtx)) }>
if emailCtx.SiteSettings.LogoURL != "" {
<img src={ emailCtx.SiteSettings.LogoURL } alt={ emailCtx.SiteSettings.SiteName } style="max-height: 48px; width: auto; display: block;"/>
} else if emailCtx.SiteSettings.SiteName != "" {
<h1 style={ fmt.Sprintf("margin: 0; font-size: 24px; letter-spacing: -0.01em; color: %s;", coffeePrimaryColor(emailCtx)) }>
{ emailCtx.SiteSettings.SiteName }
</h1>
}
</td>
</tr>
<tr>
<td class="coffee-email-padding" style={ fmt.Sprintf("padding: 32px 40px; color: %s; font-size: 16px; line-height: 1.65;", coffeeFgColor(emailCtx)) }>
@templ.Raw(body)
</td>
</tr>
<tr>
<td align="center" style="padding: 16px 40px 8px;">
<!-- inline SVG doodle divider, survives Outlook -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0"><tr><td>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="32" viewBox="0 0 200 32" fill="none" stroke={ coffeeAccentColor(emailCtx) } stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 18 L80 18"></path>
<path d="M120 18 L196 18"></path>
<ellipse cx="95" cy="18" rx="6" ry="9"></ellipse>
<path d="M95 9 L95 27"></path>
<ellipse cx="108" cy="18" rx="6" ry="9"></ellipse>
<path d="M108 9 L108 27"></path>
</svg>
</td></tr></table>
</td>
</tr>
<tr>
<td style={ fmt.Sprintf("padding: 16px 40px 32px; color: %s; font-size: 12px; line-height: 1.6;", coffeeMutedFgColor(emailCtx)) }>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center">
if emailCtx.SiteSettings.SiteName != "" {
<p style={ fmt.Sprintf("margin: 0 0 6px; font-size: 13px; color: %s;", coffeeFgColor(emailCtx)) }>
{ emailCtx.SiteSettings.SiteName }
</p>
}
<p style="margin: 0 0 6px;">Pull up a seat. Pastries from 7, coffee until late.</p>
if emailCtx.SiteSettings.SiteURL != "" {
<p style="margin: 0 0 8px;">
<a href={ templ.SafeURL(emailCtx.SiteSettings.SiteURL) } style={ fmt.Sprintf("color: %s; text-decoration: none; border-bottom: 1px dashed %s;", coffeeAccentColor(emailCtx), coffeeAccentColor(emailCtx)) }>
{ emailCtx.SiteSettings.SiteURL }
</a>
</p>
}
if emailCtx.UnsubscribeURL != "" {
<p style="margin: 0; font-size: 11px;">
<a href={ templ.SafeURL(emailCtx.UnsubscribeURL) } style={ fmt.Sprintf("color: %s; text-decoration: none;", coffeeMutedFgColor(emailCtx)) }>
Unsubscribe
</a>
</p>
}
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
}
// Coffee email color helpers — cream paper background, espresso ink, terracotta accent.
// EmailColors carries the resolved theme palette; we use Primary for the
// accent fallback because EmailColors has no dedicated Accent field.
func coffeeBgColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Background != "" {
return emailCtx.Colors.Background
}
return "#f4ece1"
}
func coffeeCardColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Card != "" {
return emailCtx.Colors.Card
}
return "#ece1d0"
}
func coffeeFgColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Foreground != "" {
return emailCtx.Colors.Foreground
}
return "#3d2a1a"
}
func coffeePrimaryColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Primary != "" {
return emailCtx.Colors.Primary
}
return "#8a4a23"
}
// coffeeAccentColor reuses Primary when no dedicated accent is in scope —
// EmailColors does not expose Accent; the resolved palette still applies via
// Primary which the CMS sets per-email.
func coffeeAccentColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Primary != "" {
return emailCtx.Colors.Primary
}
return "#c95b2f"
}
func coffeeMutedFgColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.MutedForeground != "" {
return emailCtx.Colors.MutedForeground
}
return "#6b5440"
}
func coffeeBorderColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Border != "" {
return emailCtx.Colors.Border
}
return "#c9b69e"
}

432
email_wrapper_templ.go Normal file
View File

@ -0,0 +1,432 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"bytes"
"context"
"fmt"
"git.dev.alexdunmow.com/block/core/templates"
)
// CoffeeEmailWrapper renders a single-column 560px cream-and-espresso wrapper
// for transactional and newsletter emails. Table-only layout so it survives
// Outlook; doodle divider rendered inline as SVG so it survives email
// clients that strip styles.
func CoffeeEmailWrapper(body string, emailCtx templates.EmailContext) string {
var buf bytes.Buffer
_ = coffeeEmailTemplate(emailCtx, body).Render(context.Background(), &buf)
return buf.String()
}
func coffeeEmailTemplate(emailCtx templates.EmailContext, body string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta name=\"x-apple-disable-message-reformatting\"><meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 29, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><style type=\"text/css\">\n\t\t\t\tbody, table, td, p, a, li, blockquote {\n\t\t\t\t\t-webkit-text-size-adjust: 100%;\n\t\t\t\t\t-ms-text-size-adjust: 100%;\n\t\t\t\t}\n\t\t\t\ttable, td {\n\t\t\t\t\tmso-table-lspace: 0pt;\n\t\t\t\t\tmso-table-rspace: 0pt;\n\t\t\t\t}\n\t\t\t\timg {\n\t\t\t\t\t-ms-interpolation-mode: bicubic;\n\t\t\t\t\tborder: 0;\n\t\t\t\t\theight: auto;\n\t\t\t\t\tline-height: 100%;\n\t\t\t\t\toutline: none;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t}\n\t\t\t\tbody {\n\t\t\t\t\tmargin: 0 !important;\n\t\t\t\t\tpadding: 0 !important;\n\t\t\t\t\twidth: 100% !important;\n\t\t\t\t}\n\t\t\t\ta[x-apple-data-detectors] {\n\t\t\t\t\tcolor: inherit !important;\n\t\t\t\t\ttext-decoration: none !important;\n\t\t\t\t}\n\t\t\t\th1, h2, h3, h4, h5, h6 {\n\t\t\t\t\tfont-family: \"Fraunces\", \"Playfair Display\", Georgia, \"Times New Roman\", serif;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t}\n\t\t\t\t@media only screen and (max-width: 620px) {\n\t\t\t\t\t.coffee-email-container {\n\t\t\t\t\t\twidth: 100% !important;\n\t\t\t\t\t\tmax-width: 100% !important;\n\t\t\t\t\t}\n\t\t\t\t\t.coffee-email-padding {\n\t\t\t\t\t\tpadding-left: 24px !important;\n\t\t\t\t\t\tpadding-right: 24px !important;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t</style></head><body style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background-color: %s; margin: 0; padding: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;", coffeeBgColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 72, Col: 189}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.PreviewText != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div style=\"display: none; max-height: 0; overflow: hidden; mso-hide: all;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.PreviewText)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 75, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\"><tr><td align=\"center\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 40px 10px; background-color: %s;", coffeeBgColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 80, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><table role=\"presentation\" class=\"coffee-email-container\" width=\"560\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("max-width: 560px; background-color: %s; border: 1px solid %s; border-radius: 4px;", coffeeCardColor(emailCtx), coffeeBorderColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 81, Col: 279}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><tr><td align=\"center\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 32px 40px 16px; border-bottom: 1px dashed %s;", coffeeBorderColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 83, Col: 133}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.SiteSettings.LogoURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(emailCtx.SiteSettings.LogoURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 85, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 85, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" style=\"max-height: 48px; width: auto; display: block;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if emailCtx.SiteSettings.SiteName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h1 style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("margin: 0; font-size: 24px; letter-spacing: -0.01em; color: %s;", coffeePrimaryColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 87, Col: 130}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 88, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td></tr><tr><td class=\"coffee-email-padding\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 32px 40px; color: %s; font-size: 16px; line-height: 1.65;", coffeeFgColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 94, Col: 155}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(body).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td></tr><tr><td align=\"center\" style=\"padding: 16px 40px 8px;\"><!-- inline SVG doodle divider, survives Outlook --><table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\"><tr><td><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"32\" viewBox=\"0 0 200 32\" fill=\"none\" stroke=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(coffeeAccentColor(emailCtx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 102, Col: 143}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 18 L80 18\"></path> <path d=\"M120 18 L196 18\"></path> <ellipse cx=\"95\" cy=\"18\" rx=\"6\" ry=\"9\"></ellipse> <path d=\"M95 9 L95 27\"></path> <ellipse cx=\"108\" cy=\"18\" rx=\"6\" ry=\"9\"></ellipse> <path d=\"M108 9 L108 27\"></path></svg></td></tr></table></td></tr><tr><td style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 16px 40px 32px; color: %s; font-size: 12px; line-height: 1.6;", coffeeMutedFgColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 114, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\"><tr><td align=\"center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.SiteSettings.SiteName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("margin: 0 0 6px; font-size: 13px; color: %s;", coffeeFgColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 119, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 120, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p style=\"margin: 0 0 6px;\">Pull up a seat. Pastries from 7, coffee until late.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.SiteSettings.SiteURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p style=\"margin: 0 0 8px;\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(emailCtx.SiteSettings.SiteURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 126, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color: %s; text-decoration: none; border-bottom: 1px dashed %s;", coffeeAccentColor(emailCtx), coffeeAccentColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 126, Col: 215}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 127, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if emailCtx.UnsubscribeURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<p style=\"margin: 0; font-size: 11px;\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(emailCtx.UnsubscribeURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 133, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color: %s; text-decoration: none;", coffeeMutedFgColor(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 133, Col: 151}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">Unsubscribe</a></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td></tr></table></td></tr></table></td></tr></table></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Coffee email color helpers — cream paper background, espresso ink, terracotta accent.
// EmailColors carries the resolved theme palette; we use Primary for the
// accent fallback because EmailColors has no dedicated Accent field.
func coffeeBgColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Background != "" {
return emailCtx.Colors.Background
}
return "#f4ece1"
}
func coffeeCardColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Card != "" {
return emailCtx.Colors.Card
}
return "#ece1d0"
}
func coffeeFgColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Foreground != "" {
return emailCtx.Colors.Foreground
}
return "#3d2a1a"
}
func coffeePrimaryColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Primary != "" {
return emailCtx.Colors.Primary
}
return "#8a4a23"
}
// coffeeAccentColor reuses Primary when no dedicated accent is in scope —
// EmailColors does not expose Accent; the resolved palette still applies via
// Primary which the CMS sets per-email.
func coffeeAccentColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Primary != "" {
return emailCtx.Colors.Primary
}
return "#c95b2f"
}
func coffeeMutedFgColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.MutedForeground != "" {
return emailCtx.Colors.MutedForeground
}
return "#6b5440"
}
func coffeeBorderColor(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Border != "" {
return emailCtx.Colors.Border
}
return "#c9b69e"
}
var _ = templruntime.GeneratedTemplate

66
embed.go Normal file
View File

@ -0,0 +1,66 @@
package main
import (
"embed"
"io/fs"
"net/http"
"git.dev.alexdunmow.com/block/core/plugin"
)
//go:embed assets/*
var assetsFS embed.FS
//go:embed schemas/*
var schemasFS embed.FS
//go:embed presets.json
var presetsData []byte
//go:embed fonts.json
var fontsData []byte
//go:embed plugin.mod
var pluginModBytes []byte
// Assets returns the embedded assets filesystem.
func Assets() fs.FS {
sub, _ := fs.Sub(assetsFS, "assets")
return sub
}
// Schemas returns the embedded schemas filesystem.
func Schemas() fs.FS {
sub, _ := fs.Sub(schemasFS, "schemas")
return sub
}
// AssetsHandler returns an http.Handler that serves the embedded assets.
func AssetsHandler() http.Handler {
return http.FileServer(http.FS(Assets()))
}
// ThemePresets returns the embedded theme presets JSON.
func ThemePresets() []byte {
return presetsData
}
// BundledFonts returns the embedded fonts manifest JSON.
// Coffee ships fonts.json = [] per FONTS.md wave-1 policy; recommended fonts
// (Fraunces, Inter, JetBrains Mono) are documented in RECOMMENDED_FONTS.md.
func BundledFonts() []byte {
return fontsData
}
// ThemeCSSManifest returns the additional CSS that Tailwind should include
// when this theme is active (paper texture, torn-edge mask, kraft-tag button,
// drop-cap, font-family variable fallbacks).
func ThemeCSSManifest() *plugin.CSSManifest {
css, err := assetsFS.ReadFile("assets/style.css")
if err != nil {
return &plugin.CSSManifest{}
}
return &plugin.CSSManifest{
InputCSSAppend: string(css),
}
}

39
featured_pour.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// FeaturedPourBlockMeta defines metadata for the featured_pour block.
var FeaturedPourBlockMeta = blocks.BlockMeta{
Key: "featured_pour",
Title: "Featured Pour",
Description: "Hero card for a featured coffee, tea or pastry with tasting notes and price",
Source: "coffee",
}
// FeaturedPourBlock renders a featured pour card.
// Content shape: {"name": "...", "tasting": "...rich text...", "image": "...", "price": "..."}
func FeaturedPourBlock(ctx context.Context, content map[string]any) string {
data := FeaturedPourData{
Name: getString(content, "name"),
Tasting: getString(content, "tasting"),
Image: getString(content, "image"),
Price: getString(content, "price"),
}
var buf bytes.Buffer
_ = featuredPourComponent(data).Render(ctx, &buf)
return buf.String()
}
// FeaturedPourData holds the data for the component.
type FeaturedPourData struct {
Name string
Tasting string
Image string
Price string
}

36
featured_pour.templ Normal file
View File

@ -0,0 +1,36 @@
package main
// featuredPourComponent renders the featured pour hero card.
templ featuredPourComponent(data FeaturedPourData) {
<section data-block="coffee:featured_pour" class="my-10">
<div class="coffee-card max-w-4xl mx-auto p-6 grid gap-6 md:grid-cols-[2fr_3fr] items-center relative">
<span class="absolute -top-3 left-6 inline-flex items-center gap-1 px-3 py-1 text-xs uppercase tracking-wider rounded-sm bg-accent text-accent-foreground coffee-body">
Featured
</span>
<div class="aspect-square overflow-hidden rounded-sm bg-secondary">
if data.Image != "" {
<img src={ data.Image } alt={ data.Name } class="w-full h-full object-cover" loading="lazy"/>
} else {
<div class="w-full h-full flex items-center justify-center coffee-body text-sm text-muted-foreground italic">
Add a hero image
</div>
}
</div>
<div class="flex flex-col gap-3">
if data.Name != "" {
<h3 class="coffee-display text-3xl text-primary">{ data.Name }</h3>
} else {
<h3 class="coffee-display text-3xl text-muted-foreground italic">Featured pour</h3>
}
if data.Tasting != "" {
<div class="coffee-body text-foreground prose-sm max-w-none">
@templ.Raw(data.Tasting)
</div>
}
if data.Price != "" {
<div class="coffee-price price text-xl">{ data.Price }</div>
}
</div>
</div>
</section>
}

143
featured_pour_templ.go Normal file
View File

@ -0,0 +1,143 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// featuredPourComponent renders the featured pour hero card.
func featuredPourComponent(data FeaturedPourData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"coffee:featured_pour\" class=\"my-10\"><div class=\"coffee-card max-w-4xl mx-auto p-6 grid gap-6 md:grid-cols-[2fr_3fr] items-center relative\"><span class=\"absolute -top-3 left-6 inline-flex items-center gap-1 px-3 py-1 text-xs uppercase tracking-wider rounded-sm bg-accent text-accent-foreground coffee-body\">Featured</span><div class=\"aspect-square overflow-hidden rounded-sm bg-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Image != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Image)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `featured_pour.templ`, Line: 12, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `featured_pour.templ`, Line: 12, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"w-full h-full object-cover\" loading=\"lazy\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"w-full h-full flex items-center justify-center coffee-body text-sm text-muted-foreground italic\">Add a hero image</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"flex flex-col gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Name != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h3 class=\"coffee-display text-3xl text-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `featured_pour.templ`, Line: 21, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<h3 class=\"coffee-display text-3xl text-muted-foreground italic\">Featured pour</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.Tasting != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"coffee-body text-foreground prose-sm max-w-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Tasting).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.Price != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"coffee-price price text-xl\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Price)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `featured_pour.templ`, Line: 31, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,26 +1 @@
[
{
"name": "Fraunces",
"family": "Fraunces",
"variants": [
{ "weight": "400", "style": "normal", "file": "fonts/fraunces-latin-400.woff2" },
{ "weight": "600", "style": "normal", "file": "fonts/fraunces-latin-600.woff2" }
]
},
{
"name": "Work Sans",
"family": "Work Sans",
"variants": [
{ "weight": "400", "style": "normal", "file": "fonts/worksans-latin-400.woff2" },
{ "weight": "600", "style": "normal", "file": "fonts/worksans-latin-600.woff2" }
]
},
{
"name": "Caveat",
"family": "Caveat",
"variants": [
{ "weight": "400", "style": "normal", "file": "fonts/caveat-latin-400.woff2" },
{ "weight": "700", "style": "normal", "file": "fonts/caveat-latin-700.woff2" }
]
}
]
[]

35
footer.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// FooterBlockMeta defines metadata for the Coffee footer block.
var FooterBlockMeta = blocks.BlockMeta{
Key: "footer",
Title: "Footer",
Description: "Torn-edge footer with optional location summary and newsletter caption",
Source: "coffee",
}
// FooterBlock renders the coffee footer.
// Content shape: {"showLocation": "true", "newsletterText": "..."}
func FooterBlock(ctx context.Context, content map[string]any) string {
data := FooterData{
ShowLocation: getBool(content, "showLocation", true),
NewsletterText: getString(content, "newsletterText"),
}
var buf bytes.Buffer
_ = footerComponent(data).Render(ctx, &buf)
return buf.String()
}
// FooterData holds the data for the footer component.
type FooterData struct {
ShowLocation bool
NewsletterText string
}

37
footer.templ Normal file
View File

@ -0,0 +1,37 @@
package main
// footerComponent renders the kraft-paper footer with a torn top edge.
templ footerComponent(data FooterData) {
<div data-block="coffee:footer" class="coffee-torn-top bg-card text-card-foreground pt-12 pb-8 mt-12">
<div class="max-w-5xl mx-auto px-6 grid gap-8 md:grid-cols-3 coffee-body">
<div>
<h4 class="coffee-display text-xl text-primary mb-2">Hello there</h4>
<p class="text-sm text-muted-foreground">Pull up a seat. Pastries from 7, coffee until late.</p>
</div>
if data.ShowLocation {
<div>
<h4 class="coffee-display text-xl text-primary mb-2">Visit</h4>
<address class="not-italic text-sm text-foreground whitespace-pre-line">42 Roastery Lane
City, State 9000</address>
</div>
}
<div>
<h4 class="coffee-display text-xl text-primary mb-2">Stay in touch</h4>
if data.NewsletterText != "" {
<p class="text-sm text-muted-foreground mb-3">{ data.NewsletterText }</p>
} else {
<p class="text-sm text-muted-foreground mb-3">Slow notes from the bar. Brew tips, seasonal pours, no spam.</p>
}
<form class="flex gap-2" action="#" method="post" onsubmit="event.preventDefault();">
<label class="sr-only" for="coffee-newsletter-email">Email</label>
<input id="coffee-newsletter-email" name="email" type="email" required placeholder="you@cafe.com" class="flex-1 px-3 py-2 text-sm coffee-body bg-input text-foreground border border-border rounded-sm focus:outline-none focus:ring-2 focus:ring-ring"/>
<button type="submit" class="kraft-tag">Subscribe</button>
</form>
</div>
</div>
<div class="max-w-5xl mx-auto px-6 mt-10 pt-6 border-t coffee-pencil-rule text-xs coffee-body text-muted-foreground flex flex-wrap gap-2 justify-between">
<span>© Coffee theme — kraft paper edition.</span>
<span>Hand-drawn with care.</span>
</div>
</div>
}

79
footer_templ.go Normal file
View File

@ -0,0 +1,79 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// footerComponent renders the kraft-paper footer with a torn top edge.
func footerComponent(data FooterData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div data-block=\"coffee:footer\" class=\"coffee-torn-top bg-card text-card-foreground pt-12 pb-8 mt-12\"><div class=\"max-w-5xl mx-auto px-6 grid gap-8 md:grid-cols-3 coffee-body\"><div><h4 class=\"coffee-display text-xl text-primary mb-2\">Hello there</h4><p class=\"text-sm text-muted-foreground\">Pull up a seat. Pastries from 7, coffee until late.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ShowLocation {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div><h4 class=\"coffee-display text-xl text-primary mb-2\">Visit</h4><address class=\"not-italic text-sm text-foreground whitespace-pre-line\">42 Roastery Lane City, State 9000</address></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><h4 class=\"coffee-display text-xl text-primary mb-2\">Stay in touch</h4>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NewsletterText != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p class=\"text-sm text-muted-foreground mb-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.NewsletterText)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `footer.templ`, Line: 21, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p class=\"text-sm text-muted-foreground mb-3\">Slow notes from the bar. Brew tips, seasonal pours, no spam.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form class=\"flex gap-2\" action=\"#\" method=\"post\" onsubmit=\"event.preventDefault();\"><label class=\"sr-only\" for=\"coffee-newsletter-email\">Email</label> <input id=\"coffee-newsletter-email\" name=\"email\" type=\"email\" required placeholder=\"you@cafe.com\" class=\"flex-1 px-3 py-2 text-sm coffee-body bg-input text-foreground border border-border rounded-sm focus:outline-none focus:ring-2 focus:ring-ring\"> <button type=\"submit\" class=\"kraft-tag\">Subscribe</button></form></div></div><div class=\"max-w-5xl mx-auto px-6 mt-10 pt-6 border-t coffee-pencil-rule text-xs coffee-body text-muted-foreground flex flex-wrap gap-2 justify-between\"><span>© Coffee theme — kraft paper edition.</span> <span>Hand-drawn with care.</span></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

20
go.mod Normal file
View File

@ -0,0 +1,20 @@
module git.dev.alexdunmow.com/block/themes/coffee
go 1.26.4
require (
git.dev.alexdunmow.com/block/core v0.14.1
github.com/a-h/templ v0.3.1020
)
require (
connectrpc.com/connect v1.20.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

44
go.sum Normal file
View File

@ -0,0 +1,44 @@
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/core v0.14.0 h1:cO6QQQPndhxwh1zbDvDOzebwrFS8Ka3WsxD8JBJLw/Y=
git.dev.alexdunmow.com/block/core v0.14.0/go.mod h1:S0ZfpGZ9BQhhmuTUd78ailH7m2vo7QRCTIionvCVm9s=
git.dev.alexdunmow.com/block/core v0.14.1 h1:63b25yzWoqrhBd3wdDFhsJzyGNM99wYmdh4WkN35hDk=
git.dev.alexdunmow.com/block/core v0.14.1/go.mod h1:S0ZfpGZ9BQhhmuTUd78ailH7m2vo7QRCTIionvCVm9s=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

40
heading_override.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"bytes"
"context"
"strconv"
)
// CoffeeHeadingBlock renders a heading with Coffee styling.
// Content shape: {"text": "...", "level": 1-6, "textClass": "..."}
func CoffeeHeadingBlock(ctx context.Context, content map[string]any) string {
text := getString(content, "text")
textClass := getString(content, "textClass")
level := parseHeadingLevel(content)
var buf bytes.Buffer
_ = coffeeHeadingComponent(level, text, textClass).Render(ctx, &buf)
return buf.String()
}
// parseHeadingLevel parses the heading level, defaulting to 2.
func parseHeadingLevel(content map[string]any) int {
if level, ok := content["level"].(float64); ok {
l := int(level)
if l >= 1 && l <= 6 {
return l
}
}
if level, ok := content["level"].(int); ok {
if level >= 1 && level <= 6 {
return level
}
}
if level, ok := content["level"].(string); ok {
if l, err := strconv.Atoi(level); err == nil && l >= 1 && l <= 6 {
return l
}
}
return 2
}

48
heading_override.templ Normal file
View File

@ -0,0 +1,48 @@
package main
// coffeeHeadingBaseClass returns base Tailwind classes for each heading level.
func coffeeHeadingBaseClass(level int) string {
switch level {
case 1:
return "coffee-display text-5xl leading-tight"
case 2:
return "coffee-display text-3xl italic"
case 3:
return "coffee-display text-2xl"
case 4:
return "coffee-display text-xl"
case 5:
return "coffee-display text-lg"
case 6:
return "coffee-display text-base"
default:
return "coffee-display text-3xl"
}
}
// coffeeHeadingComponent renders a heading with Coffee display styling and an
// optional doodle underline for h2+ levels.
templ coffeeHeadingComponent(level int, text, textClass string) {
switch level {
case 1:
<h1 class={ coffeeHeadingBaseClass(1), "text-primary", textClass }>{ text }</h1>
case 2:
<h2 class={ coffeeHeadingBaseClass(2), "text-primary", textClass }>
<span class="coffee-doodle-underline">{ text }</span>
</h2>
case 3:
<h3 class={ coffeeHeadingBaseClass(3), "text-primary", textClass }>
<span class="coffee-doodle-underline">{ text }</span>
</h3>
case 4:
<h4 class={ coffeeHeadingBaseClass(4), "text-primary", textClass }>{ text }</h4>
case 5:
<h5 class={ coffeeHeadingBaseClass(5), "text-primary", textClass }>{ text }</h5>
case 6:
<h6 class={ coffeeHeadingBaseClass(6), "text-primary", textClass }>{ text }</h6>
default:
<h2 class={ coffeeHeadingBaseClass(2), "text-primary", textClass }>
<span class="coffee-doodle-underline">{ text }</span>
</h2>
}
}

312
heading_override_templ.go Normal file
View File

@ -0,0 +1,312 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// coffeeHeadingBaseClass returns base Tailwind classes for each heading level.
func coffeeHeadingBaseClass(level int) string {
switch level {
case 1:
return "coffee-display text-5xl leading-tight"
case 2:
return "coffee-display text-3xl italic"
case 3:
return "coffee-display text-2xl"
case 4:
return "coffee-display text-xl"
case 5:
return "coffee-display text-lg"
case 6:
return "coffee-display text-base"
default:
return "coffee-display text-3xl"
}
}
// coffeeHeadingComponent renders a heading with Coffee display styling and an
// optional doodle underline for h2+ levels.
func coffeeHeadingComponent(level int, text, textClass string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch level {
case 1:
var templ_7745c5c3_Var2 = []any{coffeeHeadingBaseClass(1), "text-primary", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 28, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 2:
var templ_7745c5c3_Var5 = []any{coffeeHeadingBaseClass(2), "text-primary", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><span class=\"coffee-doodle-underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 31, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 3:
var templ_7745c5c3_Var8 = []any{coffeeHeadingBaseClass(3), "text-primary", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h3 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><span class=\"coffee-doodle-underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 35, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span></h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 4:
var templ_7745c5c3_Var11 = []any{coffeeHeadingBaseClass(4), "text-primary", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<h4 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 38, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h4>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 5:
var templ_7745c5c3_Var14 = []any{coffeeHeadingBaseClass(5), "text-primary", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h5 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 40, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h5>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 6:
var templ_7745c5c3_Var17 = []any{coffeeHeadingBaseClass(6), "text-primary", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<h6 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var17).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 42, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h6>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
var templ_7745c5c3_Var20 = []any{coffeeHeadingBaseClass(2), "text-primary", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<h2 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><span class=\"coffee-doodle-underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `heading_override.templ`, Line: 45, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

91
helpers.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"strings"
"time"
)
// getString extracts a string value from content map.
func getString(content map[string]any, key string) string {
if v, ok := content[key].(string); ok {
return v
}
return ""
}
// getBool extracts a bool value from content map (handles string forms).
func getBool(content map[string]any, key string, defaultVal bool) bool {
if v, ok := content[key].(bool); ok {
return v
}
if v, ok := content[key].(string); ok {
switch strings.ToLower(v) {
case "true", "yes", "1":
return true
case "false", "no", "0":
return false
}
}
return defaultVal
}
// getSlice extracts a slice of maps from content.
func getSlice(content map[string]any, key string) []map[string]any {
if v, ok := content[key].([]any); ok {
result := make([]map[string]any, 0, len(v))
for _, item := range v {
if m, ok := item.(map[string]any); ok {
result = append(result, m)
}
}
return result
}
return nil
}
// shortDayName returns the three-letter weekday abbreviation for "today" in
// the server's local time zone. Used by hours_strip to flag today's row
// server-side (no JS).
func shortDayName(now time.Time) string {
switch now.Weekday() {
case time.Monday:
return "Mon"
case time.Tuesday:
return "Tue"
case time.Wednesday:
return "Wed"
case time.Thursday:
return "Thu"
case time.Friday:
return "Fri"
case time.Saturday:
return "Sat"
case time.Sunday:
return "Sun"
}
return ""
}
// normaliseDay accepts a free-form day string and returns the matching short
// name. This lets editors enter "Monday", "mon", or "Mon" and still get a
// reliable comparison against today.
func normaliseDay(day string) string {
d := strings.ToLower(strings.TrimSpace(day))
switch {
case strings.HasPrefix(d, "mon"):
return "Mon"
case strings.HasPrefix(d, "tue"):
return "Tue"
case strings.HasPrefix(d, "wed"):
return "Wed"
case strings.HasPrefix(d, "thu"):
return "Thu"
case strings.HasPrefix(d, "fri"):
return "Fri"
case strings.HasPrefix(d, "sat"):
return "Sat"
case strings.HasPrefix(d, "sun"):
return "Sun"
}
return strings.TrimSpace(day)
}

73
hours_strip.go Normal file
View File

@ -0,0 +1,73 @@
package main
import (
"bytes"
"context"
"time"
"git.dev.alexdunmow.com/block/core/blocks"
)
// HoursStripBlockMeta defines metadata for the hours_strip block.
var HoursStripBlockMeta = blocks.BlockMeta{
Key: "hours_strip",
Title: "Hours Strip",
Description: "Weekly hours strip with today's row server-side highlighted",
Source: "coffee",
}
// HoursStripBlock renders a weekly hours strip.
// Content shape:
//
// {
// "todayLabel": "Today",
// "hours": [
// {"day": "Mon", "open": "7:00", "close": "15:00"}, ...
// ]
// }
//
// The "today" row is detected server-side by comparing each row's day to the
// current local weekday — no JS required.
func HoursStripBlock(ctx context.Context, content map[string]any) string {
todayLabel := getString(content, "todayLabel")
if todayLabel == "" {
todayLabel = "Today"
}
today := shortDayName(time.Now())
rawHours := getSlice(content, "hours")
var rows []HoursRow
for _, h := range rawHours {
day := normaliseDay(getString(h, "day"))
rows = append(rows, HoursRow{
Day: day,
Open: getString(h, "open"),
Close: getString(h, "close"),
IsToday: day != "" && day == today,
})
}
data := HoursStripData{
TodayLabel: todayLabel,
Rows: rows,
}
var buf bytes.Buffer
_ = hoursStripComponent(data).Render(ctx, &buf)
return buf.String()
}
// HoursStripData contains data for the hours strip component.
type HoursStripData struct {
TodayLabel string
Rows []HoursRow
}
// HoursRow represents one weekday's hours.
type HoursRow struct {
Day string
Open string
Close string
IsToday bool
}

35
hours_strip.templ Normal file
View File

@ -0,0 +1,35 @@
package main
// hoursStripComponent renders the hours strip with the today row highlighted.
templ hoursStripComponent(data HoursStripData) {
<aside data-block="coffee:hours_strip" class="my-4">
<ul class="coffee-card grid grid-cols-1 sm:grid-cols-2 md:grid-cols-7 gap-1 p-2 max-w-5xl mx-auto">
if len(data.Rows) == 0 {
<li class="coffee-body text-sm text-muted-foreground italic px-3 py-2 col-span-full text-center">Add weekday hours to display the strip.</li>
}
for _, row := range data.Rows {
<li class={ "coffee-body px-2 py-1 flex items-baseline gap-2 text-sm rounded-sm", todayClass(row.IsToday) }>
<span class="coffee-display font-semibold text-foreground w-12 shrink-0">{ row.Day }</span>
if row.IsToday {
<span class="coffee-body text-[10px] uppercase tracking-wider text-accent">{ data.TodayLabel }</span>
}
<span class="coffee-mono text-foreground">
if row.Open != "" || row.Close != "" {
{ row.Open } { row.Close }
} else {
Closed
}
</span>
</li>
}
</ul>
</aside>
}
// todayClass returns the highlight class when this row is today's.
func todayClass(isToday bool) string {
if isToday {
return "coffee-hours-today is-today today"
}
return ""
}

152
hours_strip_templ.go Normal file
View File

@ -0,0 +1,152 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// hoursStripComponent renders the hours strip with the today row highlighted.
func hoursStripComponent(data HoursStripData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside data-block=\"coffee:hours_strip\" class=\"my-4\"><ul class=\"coffee-card grid grid-cols-1 sm:grid-cols-2 md:grid-cols-7 gap-1 p-2 max-w-5xl mx-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Rows) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<li class=\"coffee-body text-sm text-muted-foreground italic px-3 py-2 col-span-full text-center\">Add weekday hours to display the strip.</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, row := range data.Rows {
var templ_7745c5c3_Var2 = []any{"coffee-body px-2 py-1 flex items-baseline gap-2 text-sm rounded-sm", todayClass(row.IsToday)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `hours_strip.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><span class=\"coffee-display font-semibold text-foreground w-12 shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(row.Day)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `hours_strip.templ`, Line: 12, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if row.IsToday {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"coffee-body text-[10px] uppercase tracking-wider text-accent\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TodayLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `hours_strip.templ`, Line: 14, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"coffee-mono text-foreground\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if row.Open != "" || row.Close != "" {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(row.Open)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `hours_strip.templ`, Line: 18, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(row.Close)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `hours_strip.templ`, Line: 18, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Closed")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ul></aside>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// todayClass returns the highlight class when this row is today's.
func todayClass(isToday bool) string {
if isToday {
return "coffee-hours-today is-today today"
}
return ""
}
var _ = templruntime.GeneratedTemplate

19
image_override.go Normal file
View File

@ -0,0 +1,19 @@
package main
import (
"bytes"
"context"
)
// CoffeeImageBlock renders an image with the torn-edge frame and optional
// handwritten caption.
// Content shape: {"src": "...", "alt": "...", "caption": "..."}
func CoffeeImageBlock(ctx context.Context, content map[string]any) string {
src := getString(content, "src")
alt := getString(content, "alt")
caption := getString(content, "caption")
var buf bytes.Buffer
_ = coffeeImageComponent(src, alt, caption).Render(ctx, &buf)
return buf.String()
}

20
image_override.templ Normal file
View File

@ -0,0 +1,20 @@
package main
// coffeeImageComponent renders an image with the torn-edge frame and an
// optional caption styled like a handwritten note.
templ coffeeImageComponent(src, alt, caption string) {
<figure class="my-8 flex flex-col items-center coffee-body">
<div class="coffee-frame p-2 bg-card rounded-sm relative coffee-torn-bottom">
if src != "" {
<img src={ src } alt={ alt } class="block max-w-full h-auto" loading="lazy"/>
} else {
<div class="w-full h-48 flex items-center justify-center coffee-body text-sm text-muted-foreground italic px-6">
Add an image to render the torn-edge frame.
</div>
}
</div>
if caption != "" {
<figcaption class="coffee-display italic text-center text-sm text-muted-foreground mt-3">{ caption }</figcaption>
}
</figure>
}

106
image_override_templ.go Normal file
View File

@ -0,0 +1,106 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// coffeeImageComponent renders an image with the torn-edge frame and an
// optional caption styled like a handwritten note.
func coffeeImageComponent(src, alt, caption string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<figure class=\"my-8 flex flex-col items-center coffee-body\"><div class=\"coffee-frame p-2 bg-card rounded-sm relative coffee-torn-bottom\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if src != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(src)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_override.templ`, Line: 9, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(alt)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_override.templ`, Line: 9, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"block max-w-full h-auto\" loading=\"lazy\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"w-full h-48 flex items-center justify-center coffee-body text-sm text-muted-foreground italic px-6\">Add an image to render the torn-edge frame.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if caption != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<figcaption class=\"coffee-display italic text-center text-sm text-muted-foreground mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_override.templ`, Line: 17, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</figcaption>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</figure>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

37
location_card.go Normal file
View File

@ -0,0 +1,37 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// LocationCardBlockMeta defines metadata for the location_card block.
var LocationCardBlockMeta = blocks.BlockMeta{
Key: "location_card",
Title: "Location Card",
Description: "Address card with optional static map image and doodled pin overlay",
Source: "coffee",
}
// LocationCardBlock renders a location card.
// Content shape: {"address": "...", "mapImage": "...", "directionsUrl": "..."}
func LocationCardBlock(ctx context.Context, content map[string]any) string {
data := LocationCardData{
Address: getString(content, "address"),
MapImage: getString(content, "mapImage"),
DirectionsURL: getString(content, "directionsUrl"),
}
var buf bytes.Buffer
_ = locationCardComponent(data).Render(ctx, &buf)
return buf.String()
}
// LocationCardData holds the data the template needs.
type LocationCardData struct {
Address string
MapImage string
DirectionsURL string
}

35
location_card.templ Normal file
View File

@ -0,0 +1,35 @@
package main
// locationCardComponent renders a location card with doodled pin overlay.
templ locationCardComponent(data LocationCardData) {
<section data-block="coffee:location_card" class="my-8">
<div class="coffee-card max-w-3xl mx-auto p-6 grid gap-6 md:grid-cols-2">
<div class="relative aspect-[4/3] overflow-hidden rounded-sm bg-secondary">
if data.MapImage != "" {
<img src={ data.MapImage } alt="Map of our location" class="absolute inset-0 w-full h-full object-cover" loading="lazy"/>
} else {
<div class="absolute inset-0 flex items-center justify-center coffee-body text-sm text-muted-foreground italic">
Map preview
</div>
}
<svg class="coffee-pin absolute top-1/3 left-1/2 -translate-x-1/2 w-10 h-10" viewBox="0 0 40 56" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 4 Q 6 4 6 22 Q 6 36 20 52 Q 34 36 34 22 Q 34 4 20 4 Z"></path>
<circle cx="20" cy="22" r="6"></circle>
</svg>
</div>
<div class="flex flex-col gap-3 justify-center">
<h3 class="coffee-display text-2xl text-primary">
<span class="coffee-doodle-underline">Find us</span>
</h3>
if data.Address != "" {
<address class="coffee-body not-italic text-foreground whitespace-pre-line">{ data.Address }</address>
} else {
<p class="coffee-body text-sm text-muted-foreground italic">Add an address to render the location card.</p>
}
if data.DirectionsURL != "" {
<a href={ templ.SafeURL(data.DirectionsURL) } class="kraft-tag mt-2 self-start" target="_blank" rel="noopener noreferrer">Directions</a>
}
</div>
</div>
</section>
}

116
location_card_templ.go Normal file
View File

@ -0,0 +1,116 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// locationCardComponent renders a location card with doodled pin overlay.
func locationCardComponent(data LocationCardData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"coffee:location_card\" class=\"my-8\"><div class=\"coffee-card max-w-3xl mx-auto p-6 grid gap-6 md:grid-cols-2\"><div class=\"relative aspect-[4/3] overflow-hidden rounded-sm bg-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.MapImage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.MapImage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `location_card.templ`, Line: 9, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" alt=\"Map of our location\" class=\"absolute inset-0 w-full h-full object-cover\" loading=\"lazy\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"absolute inset-0 flex items-center justify-center coffee-body text-sm text-muted-foreground italic\">Map preview</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg class=\"coffee-pin absolute top-1/3 left-1/2 -translate-x-1/2 w-10 h-10\" viewBox=\"0 0 40 56\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M20 4 Q 6 4 6 22 Q 6 36 20 52 Q 34 36 34 22 Q 34 4 20 4 Z\"></path> <circle cx=\"20\" cy=\"22\" r=\"6\"></circle></svg></div><div class=\"flex flex-col gap-3 justify-center\"><h3 class=\"coffee-display text-2xl text-primary\"><span class=\"coffee-doodle-underline\">Find us</span></h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Address != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<address class=\"coffee-body not-italic text-foreground whitespace-pre-line\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `location_card.templ`, Line: 25, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</address>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p class=\"coffee-body text-sm text-muted-foreground italic\">Add an address to render the location card.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.DirectionsURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.DirectionsURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `location_card.templ`, Line: 30, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"kraft-tag mt-2 self-start\" target=\"_blank\" rel=\"noopener noreferrer\">Directions</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,422 +0,0 @@
# Coffee theme — codeless .bnp manifest (Wave A, WO-TF-013).
# Synthesized into manifest.pb by `ninja plugin build` (no plugin.wasm).
theme_presets: presets.json
bundled_fonts: fonts.json
master_pages: master_pages.json
required_icon_packs:
- lucide
- simple-icons
system_templates:
- key: coffee
title: Coffee
description: "Warm, hand-crafted cafe and roastery theme — kraft paper, chalkboard menus, doodles and stamps."
page_templates:
- system: coffee
key: default
title: Default
description: "Header + main + footer over warm paper grain."
slots: [header, main, footer]
- system: coffee
key: landing
title: Landing
description: "Hero pour, featured menu, story and invitation."
slots: [hero, menu, story, cta, footer]
- system: coffee
key: article
title: Article
description: "Narrow narrative column with drop-cap for journal and recipes."
slots: [header, main, footer]
- system: coffee
key: full-width
title: Full Width
description: "Edge-to-edge gallery and interior shots."
slots: [header, main, footer]
- system: coffee
key: blog-index
title: Blog Index
description: "Blog hero, featured posts and a post grid."
slots: [header, featured, main, footer]
- system: coffee
key: contact
title: Contact
description: "Contact form, hours and location."
slots: [header, main, footer]
- system: coffee
key: auth
title: Auth
description: "Centered card for login, register and password reset."
slots: [main, footer]
email_wrappers:
- coffee
template_overrides:
- { template: coffee, block: announcement-bar }
- { template: coffee, block: audio-embed }
- { template: coffee, block: auth-form }
- { template: coffee, block: author-bio }
- { template: coffee, block: author-bio-hero }
- { template: coffee, block: auth-status }
- { template: coffee, block: blog-hero }
- { template: coffee, block: breadcrumbs }
- { template: coffee, block: button }
- { template: coffee, block: card }
- { template: coffee, block: category-list }
- { template: coffee, block: contact-card }
- { template: coffee, block: contact-form }
- { template: coffee, block: countdown }
- { template: coffee, block: cta }
- { template: coffee, block: divider }
- { template: coffee, block: event-list }
- { template: coffee, block: faq }
- { template: coffee, block: featured-posts }
- { template: coffee, block: feature-grid }
- { template: coffee, block: footer }
- { template: coffee, block: gallery }
- { template: coffee, block: heading }
- { template: coffee, block: hero }
- { template: coffee, block: hours-location }
- { template: coffee, block: image }
- { template: coffee, block: logo-strip }
- { template: coffee, block: menu-list }
- { template: coffee, block: navbar }
- { template: coffee, block: newsletter-signup }
- { template: coffee, block: page-suggestions }
- { template: coffee, block: password-reset-form }
- { template: coffee, block: popular-posts }
- { template: coffee, block: post-hero }
- { template: coffee, block: post-metadata }
- { template: coffee, block: pricing }
- { template: coffee, block: quote }
- { template: coffee, block: related-posts }
- { template: coffee, block: rich-section }
- { template: coffee, block: rich-text }
- { template: coffee, block: sidebar-nav }
- { template: coffee, block: social-links }
- { template: coffee, block: stats }
- { template: coffee, block: team }
- { template: coffee, block: testimonial }
- { template: coffee, block: text }
- { template: coffee, block: timeline }
- { template: coffee, block: utility-bar }
- { template: coffee, block: video-embed }
css:
input_css_append: |
/* Coffee theme styles
*
* Dual-mode via the 19 shadcn-style HSL token CSS variables (--background,
* --foreground, --primary, --accent, --border, --muted, --card, ...) consumed
* as hsl(var(--token)). No literal colors. Font families resolve ONLY through
* the BlockNinja font variables (--font-heading, --font-body, --font-mono) with
* the fallback stacks below — never hardcoded, so the admin font picker works.
*/
/* Bundled faces. The host does not yet emit @font-face for bundled_fonts
* (LoadedPlugin.BundledFonts is unconsumed — cms WO-BUNDLED-FONTS-DEAD-CODE),
* so the theme declares its own faces against the served artifact paths
* (/templates/coffee/fonts/...). Families/weights mirror fonts.json exactly.
* Presets set typography.fontHeading/body to template:coffee:<Family> so
* --font-heading/--font-body resolve to Fraunces/Work Sans; Caveat backs the
* hand-accent fallback stack. */
@font-face { font-family: 'Fraunces'; src: url('/templates/coffee/fonts/fraunces-latin-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Fraunces'; src: url('/templates/coffee/fonts/fraunces-latin-600.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Work Sans'; src: url('/templates/coffee/fonts/worksans-latin-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Work Sans'; src: url('/templates/coffee/fonts/worksans-latin-600.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Caveat'; src: url('/templates/coffee/fonts/caveat-latin-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Caveat'; src: url('/templates/coffee/fonts/caveat-latin-700.woff2') format('woff2'); font-weight: 700; font-style: normal; font-display: swap; }
:root {
--coffee-heading-fallback: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif;
--coffee-body-fallback: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--coffee-hand-fallback: "Caveat", "Segoe Print", "Bradley Hand", cursive;
--coffee-mono-fallback: "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
}
/* --- Paper grain background --------------------------------------------- */
body.coffee-paper {
background-color: hsl(var(--background));
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='paper-grain'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.13 0 0 0 0 0.09 0 0 0 0.07 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23paper-grain)'/%3E%3C/svg%3E");
background-repeat: repeat;
}
/* --- Type roles --------------------------------------------------------- */
.coffee-display {
font-family: var(--font-heading, var(--coffee-heading-fallback));
font-feature-settings: "liga" 1, "dlig" 1;
letter-spacing: -0.01em;
}
.coffee-body {
font-family: var(--font-body, var(--coffee-body-fallback));
line-height: 1.65;
}
.coffee-mono {
font-family: var(--font-mono, var(--coffee-mono-fallback));
}
/* Hand-drawn accents (badges, stamps, captions). Routed through --font-heading
* with a Caveat fallback: unassigned it renders Caveat; if the admin sets a
* heading font the accents follow it (the 3-slot model working as intended). */
.coffee-hand {
font-family: var(--font-heading, var(--coffee-hand-fallback));
letter-spacing: 0.01em;
line-height: 1.15;
}
/* --- Doodle underline --------------------------------------------------- */
.coffee-doodle-underline {
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 12' preserveAspectRatio='none'%3E%3Cpath d='M2 8 Q 20 2 40 7 T 80 6 T 118 7' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' opacity='0.55'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 0 100%;
background-size: 100% 0.5em;
padding-bottom: 0.18em;
}
/* --- Drop-cap ----------------------------------------------------------- */
.coffee-dropcap > p:first-of-type::first-letter {
font-family: var(--font-heading, var(--coffee-heading-fallback));
font-size: 4em;
line-height: 0.85;
float: left;
padding: 0.05em 0.12em 0 0;
color: hsl(var(--primary));
}
/* --- Kraft-tag button (stamp press on hover) ---------------------------- */
.kraft-tag {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1.1rem;
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
font-family: var(--font-body, var(--coffee-body-fallback));
font-weight: 500;
letter-spacing: 0.02em;
box-shadow: 0 2px 0 hsl(var(--border));
transition: transform 120ms ease, box-shadow 120ms ease;
cursor: pointer;
}
.kraft-tag:hover {
transform: translateY(1px) rotate(-1.2deg);
box-shadow: 0 1px 0 hsl(var(--border));
}
.kraft-tag:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 3px;
}
.kraft-tag--ghost {
background-color: transparent;
color: hsl(var(--primary));
box-shadow: none;
border-style: dashed;
}
.kraft-tag--ghost:hover {
background-color: hsl(var(--muted));
}
/* --- Torn-paper edges ---------------------------------------------------
* The deckled edge is a FIXED ~13px band at the named edge; the rest of the
* element is masked solid. (The old `mask-size:100% 100%` stretched the 12px
* torn SVG across the whole element, eating ~40% of tall cards as a transparent
* wavy cut-out — the source of the hero/featured-pour/footer clipping.) */
.coffee-torn-top {
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black);
mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black);
-webkit-mask-position: top, bottom;
mask-position: top, bottom;
-webkit-mask-size: 100% 13px, 100% calc(100% - 12px);
mask-size: 100% 13px, 100% calc(100% - 12px);
-webkit-mask-repeat: no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat;
}
.coffee-torn-bottom {
-webkit-mask-image: linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
mask-image: linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: top, bottom;
mask-position: top, bottom;
-webkit-mask-size: 100% calc(100% - 12px), 100% 13px;
mask-size: 100% calc(100% - 12px), 100% 13px;
-webkit-mask-repeat: no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat;
}
.coffee-torn-top.coffee-torn-bottom {
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 12 L0 6 Q 5 2 10 5 T 20 4 T 30 6 T 40 3 T 50 5 T 60 4 T 70 5 T 80 3 T 90 5 T 100 4 L100 12 Z' fill='black'/%3E%3C/svg%3E"), linear-gradient(black, black), url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0 L100 0 L100 6 Q 95 10 90 7 T 80 8 T 70 6 T 60 9 T 50 7 T 40 8 T 30 7 T 20 9 T 10 7 T 0 8 Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: top, center, bottom;
mask-position: top, center, bottom;
-webkit-mask-size: 100% 13px, 100% calc(100% - 24px), 100% 13px;
mask-size: 100% 13px, 100% calc(100% - 24px), 100% 13px;
-webkit-mask-repeat: no-repeat, no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat, no-repeat;
}
/* --- Surfaces ----------------------------------------------------------- */
.coffee-card {
background-color: hsl(var(--card));
color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
}
.coffee-frame {
border: 1px solid hsl(var(--border));
background-color: hsl(var(--card));
}
.coffee-pencil-rule {
border-color: hsl(var(--border));
border-style: solid;
}
/* --- Chalkboard menu panel (inverts foreground/background for a hung board;
* light mode = dark board + cream chalk, dark mode = light menu paper) --- */
.coffee-chalkboard {
position: relative;
background-color: hsl(var(--foreground));
color: hsl(var(--background));
border-radius: 0.5rem;
box-shadow: inset 0 0 0 3px hsl(var(--background) / 0.12), inset 0 0 0 4px hsl(var(--foreground));
}
.coffee-chalk-rule {
border-color: hsl(var(--background) / 0.28);
border-style: solid;
}
.coffee-chalk-muted {
color: hsl(var(--background) / 0.72);
}
/* --- Hours: today highlight ---------------------------------------------
* Applied to the whole <tr> so both cells share one readable band. (Was on the
* <th> only with accent-foreground text over a 15%-accent tint — light-on-light,
* unreadable.) accent text over a 12% tint of the same accent reads cleanly. */
.coffee-hours-today > th,
.coffee-hours-today > td {
background-color: hsl(var(--accent) / 0.12);
}
.coffee-hours-today > th {
box-shadow: inset 3px 0 0 hsl(var(--accent));
padding-left: 0.75rem;
color: hsl(var(--accent));
}
/* --- Layout helpers ------------------------------------------------------
* coffee-pour-grid keeps an explicit md 2fr:3fr split — a non-standard grid
* ratio with no plain Tailwind utility. Single-column base collapses on
* mobile. (The min-height + standard-column grid helpers that used to live
* here are now plain Tailwind utilities in the templates, since the host
* build scans theme .ninjatpl files.) */
.coffee-pour-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
@media (min-width: 768px) {
.coffee-pour-grid { grid-template-columns: 2fr 3fr; align-items: center; }
}
/* --- Price + spec chips ------------------------------------------------- */
.coffee-price {
font-family: var(--font-mono, var(--coffee-mono-fallback));
font-variant-numeric: tabular-nums;
color: hsl(var(--accent));
}
.coffee-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
border: 1px dashed hsl(var(--border));
border-radius: 9999px;
background-color: hsl(var(--muted));
color: hsl(var(--foreground));
font-family: var(--font-body, var(--coffee-body-fallback));
font-size: 0.85rem;
}
/* --- Circular stamp / badge (press on hover) ---------------------------- */
.coffee-stamp {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4.25rem;
height: 4.25rem;
border-radius: 9999px;
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
border: 2px solid hsl(var(--accent-foreground) / 0.35);
box-shadow: inset 0 0 0 3px hsl(var(--accent));
font-family: var(--font-heading, var(--coffee-hand-fallback));
font-size: 0.95rem;
text-align: center;
line-height: 1.05;
transform: rotate(-6deg);
transition: transform 140ms ease;
}
.coffee-stamp:hover {
transform: rotate(-6deg) scale(0.94);
}
.coffee-stamp-sm {
width: 2.25rem;
height: 2.25rem;
font-size: 1rem;
transform: rotate(-4deg);
box-shadow: none;
}
/* --- Loyalty punch stamps ----------------------------------------------- */
.coffee-punch {
display: inline-flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
border-radius: 9999px;
border: 2px dashed hsl(var(--border));
color: hsl(var(--muted-foreground));
}
.coffee-punch-on {
border-style: solid;
border-color: hsl(var(--accent));
background-color: hsl(var(--accent) / 0.15);
color: hsl(var(--accent));
transform: rotate(-5deg);
}
.coffee-punch-reward {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
}
/* --- Location pin ------------------------------------------------------- */
.coffee-pin {
color: hsl(var(--accent));
}
/* --- Doodle divider draw-in --------------------------------------------- */
.coffee-doodle-draw path,
.coffee-doodle-draw ellipse,
.coffee-doodle-draw circle {
stroke-dasharray: 240;
stroke-dashoffset: 240;
animation: coffee-draw 1.4s ease forwards;
}
@keyframes coffee-draw {
to { stroke-dashoffset: 0; }
}
/* --- Motion safety: static fallback ------------------------------------- */
@media (prefers-reduced-motion: reduce) {
.kraft-tag,
.coffee-stamp,
.coffee-punch-on { transition: none; }
.kraft-tag:hover,
.coffee-stamp:hover { transform: none; }
.coffee-doodle-draw path,
.coffee-doodle-draw ellipse,
.coffee-doodle-draw circle {
stroke-dasharray: none;
stroke-dashoffset: 0;
animation: none;
}
}

95
master_pages.go Normal file
View File

@ -0,0 +1,95 @@
package main
import "git.dev.alexdunmow.com/block/core/plugin"
// DefaultMasterPages returns the default master pages the Coffee theme seeds.
// Spec §7. Two masters: default-master (default + article templates) and
// landing-master (landing template only).
func DefaultMasterPages() []plugin.MasterPageDefinition {
return []plugin.MasterPageDefinition{
{
Key: "coffee:default-master",
Title: "Coffee Default Master",
PageTemplates: []string{"default", "article"},
Blocks: []plugin.MasterPageBlock{
{
BlockKey: "navbar",
Title: "Main Navigation",
Content: map[string]any{"menuName": "main"},
Slot: "header",
SortOrder: 0,
},
{
BlockKey: "coffee:hours_strip",
Title: "Hours Strip",
Content: map[string]any{"todayLabel": "Today"},
Slot: "header",
SortOrder: 1,
},
{
BlockKey: "slot",
Title: "Main Content",
Content: map[string]any{"slotName": "main", "placeholder": "Pour something in here"},
Slot: "main",
SortOrder: 0,
},
{
BlockKey: "coffee:footer",
Title: "Site Footer",
Content: map[string]any{"showLocation": true},
Slot: "footer",
SortOrder: 0,
},
},
},
{
Key: "coffee:landing-master",
Title: "Coffee Landing Master",
PageTemplates: []string{"landing"},
Blocks: []plugin.MasterPageBlock{
{
BlockKey: "navbar",
Title: "Main Navigation",
Content: map[string]any{"menuName": "main"},
Slot: "header",
SortOrder: 0,
},
{
BlockKey: "slot",
Title: "Hero Slot",
Content: map[string]any{"slotName": "hero", "placeholder": "Drop a hero block"},
Slot: "hero",
SortOrder: 0,
},
{
BlockKey: "coffee:featured_pour",
Title: "Featured Pour",
Content: map[string]any{},
Slot: "menu",
SortOrder: 0,
},
{
BlockKey: "slot",
Title: "Story Slot",
Content: map[string]any{"slotName": "story", "placeholder": "Tell the story"},
Slot: "story",
SortOrder: 0,
},
{
BlockKey: "coffee:location_card",
Title: "Location Card",
Content: map[string]any{},
Slot: "cta",
SortOrder: 0,
},
{
BlockKey: "coffee:footer",
Title: "Site Footer",
Content: map[string]any{"showLocation": true},
Slot: "footer",
SortOrder: 0,
},
},
},
}
}

View File

@ -1,45 +0,0 @@
[
{
"key": "coffee:default-master",
"title": "Coffee Default Master",
"page_templates": ["default", "article", "full-width", "contact"],
"blocks": [
{ "block_key": "navbar", "title": "Main Navigation", "content": { "menuName": "main", "companyName": "Ritual Coffee" }, "slot": "header", "sort_order": 0 },
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main", "placeholder": "Pour something in here" }, "slot": "main", "sort_order": 0 },
{ "block_key": "footer", "title": "Site Footer", "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily." }, "slot": "footer", "sort_order": 0 }
]
},
{
"key": "coffee:landing-master",
"title": "Coffee Landing Master",
"page_templates": ["landing"],
"blocks": [
{ "block_key": "navbar", "title": "Main Navigation", "content": { "menuName": "main", "companyName": "Ritual Coffee" }, "slot": "hero", "sort_order": 0 },
{ "block_key": "slot", "title": "Hero", "content": { "slotName": "hero", "placeholder": "Drop a hero block" }, "slot": "hero", "sort_order": 1 },
{ "block_key": "slot", "title": "Menu", "content": { "slotName": "menu", "placeholder": "Feature the menu" }, "slot": "menu", "sort_order": 0 },
{ "block_key": "slot", "title": "Story", "content": { "slotName": "story", "placeholder": "Tell the story" }, "slot": "story", "sort_order": 0 },
{ "block_key": "slot", "title": "Call To Action", "content": { "slotName": "cta", "placeholder": "Invite them in" }, "slot": "cta", "sort_order": 0 },
{ "block_key": "footer", "title": "Site Footer", "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily." }, "slot": "footer", "sort_order": 0 }
]
},
{
"key": "coffee:blog-master",
"title": "Coffee Blog Master",
"page_templates": ["blog-index"],
"blocks": [
{ "block_key": "navbar", "title": "Main Navigation", "content": { "menuName": "main", "companyName": "Ritual Coffee" }, "slot": "header", "sort_order": 0 },
{ "block_key": "slot", "title": "Featured", "content": { "slotName": "featured", "placeholder": "Featured posts" }, "slot": "featured", "sort_order": 0 },
{ "block_key": "slot", "title": "Posts", "content": { "slotName": "main", "placeholder": "Post grid" }, "slot": "main", "sort_order": 0 },
{ "block_key": "footer", "title": "Site Footer", "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily." }, "slot": "footer", "sort_order": 0 }
]
},
{
"key": "coffee:auth-master",
"title": "Coffee Auth Master",
"page_templates": ["auth"],
"blocks": [
{ "block_key": "slot", "title": "Auth Form", "content": { "slotName": "main", "placeholder": "Auth form" }, "slot": "main", "sort_order": 0 },
{ "block_key": "slot", "title": "Footer Note", "content": { "slotName": "footer", "placeholder": "Small print" }, "slot": "footer", "sort_order": 0 }
]
}
]

82
menu_board.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// MenuBoardBlockMeta defines metadata for the menu_board block.
var MenuBoardBlockMeta = blocks.BlockMeta{
Key: "menu_board",
Title: "Menu Board",
Description: "Kraft-paper menu with sections of items (espresso, filter, pastry, ...)",
Source: "coffee",
}
// MenuBoardBlock renders a sectioned menu card.
// Content shape:
//
// {
// "title": "Menu",
// "sections": [
// {"name": "Espresso", "items": [
// {"name": "Flat White", "price": "5.50", "note": "...", "allergens": "..."}
// ]}
// ]
// }
func MenuBoardBlock(ctx context.Context, content map[string]any) string {
title := getString(content, "title")
if title == "" {
title = "Menu"
}
rawSections := getSlice(content, "sections")
var sections []MenuSection
for _, s := range rawSections {
rawItems := getSlice(s, "items")
var items []MenuItem
for _, it := range rawItems {
items = append(items, MenuItem{
Name: getString(it, "name"),
Price: getString(it, "price"),
Note: getString(it, "note"),
Allergens: getString(it, "allergens"),
})
}
sections = append(sections, MenuSection{
Name: getString(s, "name"),
Items: items,
})
}
data := MenuBoardData{
Title: title,
Sections: sections,
}
var buf bytes.Buffer
_ = menuBoardComponent(data).Render(ctx, &buf)
return buf.String()
}
// MenuBoardData contains data for the menu board component.
type MenuBoardData struct {
Title string
Sections []MenuSection
}
// MenuSection groups a list of items under a heading.
type MenuSection struct {
Name string
Items []MenuItem
}
// MenuItem represents a single menu line.
type MenuItem struct {
Name string
Price string
Note string
Allergens string
}

54
menu_board.templ Normal file
View File

@ -0,0 +1,54 @@
package main
// menuBoardComponent renders a Coffee-styled menu board.
templ menuBoardComponent(data MenuBoardData) {
<section data-block="coffee:menu_board" class="my-10">
<div class="coffee-card max-w-3xl mx-auto px-6 py-8">
if data.Title != "" {
<h2 class="coffee-display text-3xl mb-6 text-center">
<span class="coffee-doodle-underline">{ data.Title }</span>
</h2>
}
if len(data.Sections) == 0 {
<p class="coffee-body text-sm text-muted-foreground text-center italic">Add a section to get started.</p>
}
for sIdx, section := range data.Sections {
<div class={ menuSectionClass(sIdx) }>
if section.Name != "" {
<h3 class="coffee-display text-xl mb-3 text-primary">{ section.Name }</h3>
}
if len(section.Items) == 0 {
<p class="coffee-body text-sm text-muted-foreground italic">No items yet.</p>
}
<ul class="space-y-2">
for _, item := range section.Items {
<li class="flex items-baseline gap-3 py-1 border-b coffee-pencil-rule border-dashed">
<div class="flex-1 min-w-0">
<div class="coffee-body font-medium text-foreground">{ item.Name }</div>
if item.Note != "" {
<div class="coffee-body text-sm text-muted-foreground">{ item.Note }</div>
}
if item.Allergens != "" {
<div class="coffee-body text-xs text-muted-foreground italic">Allergens: { item.Allergens }</div>
}
</div>
if item.Price != "" {
<div class="coffee-price price text-base whitespace-nowrap">{ item.Price }</div>
}
</li>
}
</ul>
</div>
}
</div>
</section>
}
// menuSectionClass returns the CSS class for a section, leaving the first
// section without a top margin and adding spacing between later sections.
func menuSectionClass(idx int) string {
if idx == 0 {
return "mb-6"
}
return "mt-8 mb-6"
}

220
menu_board_templ.go Normal file
View File

@ -0,0 +1,220 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// menuBoardComponent renders a Coffee-styled menu board.
func menuBoardComponent(data MenuBoardData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"coffee:menu_board\" class=\"my-10\"><div class=\"coffee-card max-w-3xl mx-auto px-6 py-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Title != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"coffee-display text-3xl mb-6 text-center\"><span class=\"coffee-doodle-underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `menu_board.templ`, Line: 9, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span></h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.Sections) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p class=\"coffee-body text-sm text-muted-foreground text-center italic\">Add a section to get started.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for sIdx, section := range data.Sections {
var templ_7745c5c3_Var3 = []any{menuSectionClass(sIdx)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `menu_board.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.Name != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h3 class=\"coffee-display text-xl mb-3 text-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `menu_board.templ`, Line: 18, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(section.Items) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"coffee-body text-sm text-muted-foreground italic\">No items yet.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<ul class=\"space-y-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range section.Items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<li class=\"flex items-baseline gap-3 py-1 border-b coffee-pencil-rule border-dashed\"><div class=\"flex-1 min-w-0\"><div class=\"coffee-body font-medium text-foreground\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `menu_board.templ`, Line: 27, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Note != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"coffee-body text-sm text-muted-foreground\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Note)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `menu_board.templ`, Line: 29, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if item.Allergens != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"coffee-body text-xs text-muted-foreground italic\">Allergens: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Allergens)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `menu_board.templ`, Line: 32, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Price != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"coffee-price price text-base whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Price)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `menu_board.templ`, Line: 36, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</ul></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// menuSectionClass returns the CSS class for a section, leaving the first
// section without a top margin and adding spacing between later sections.
func menuSectionClass(idx int) string {
if idx == 0 {
return "mb-6"
}
return "mt-8 mb-6"
}
var _ = templruntime.GeneratedTemplate

View File

@ -2,8 +2,11 @@
name = "coffee"
display_name = "Coffee"
scope = "@themes"
version = "0.4.0"
version = "0.1.1"
description = "Warm, hand-crafted theme for cafes, bakeries, and slow-craft makers — kraft paper, doodle illustrations, and roasted browns."
kind = "theme"
categories = ["templates"]
tags = ["warm", "hospitality", "cafe", "bakery", "food", "handcraft", "organic", "artisan", "menu"]
[compatibility]
block_core = ">=0.11.0 <0.12.0"

View File

@ -2,13 +2,9 @@
{
"id": "morning-pour",
"name": "Morning Pour",
"description": "Cream paper and espresso ink with a terracotta accent. Daylight cafe.",
"description": "Cream paper, espresso ink, terracotta accent",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:coffee:Fraunces",
"fontBody": "template:coffee:Work Sans"
},
"mode": "light",
"lightColors": {
"background": "36 30% 96%",
"foreground": "25 35% 18%",
@ -56,13 +52,9 @@
{
"id": "dark-roast",
"name": "Dark Roast",
"description": "Espresso-deep base with a copper accent. Evening roastery.",
"description": "Espresso-deep base with copper accent for evening cafes and roasteries",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:coffee:Fraunces",
"fontBody": "template:coffee:Work Sans"
},
"mode": "dark",
"lightColors": {
"background": "36 30% 96%",
"foreground": "25 35% 18%",
@ -110,13 +102,9 @@
{
"id": "kraft-cream",
"name": "Kraft Cream",
"description": "Warmer ivory paper with a deeper terracotta accent. Bakery counter.",
"description": "Warmer ivory paper with deeper terracotta accent",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:coffee:Fraunces",
"fontBody": "template:coffee:Work Sans"
},
"mode": "light",
"lightColors": {
"background": "32 35% 92%",
"foreground": "22 40% 16%",
@ -160,113 +148,5 @@
"ring": "16 50% 55%"
}
}
},
{
"id": "chalkboard",
"name": "Chalkboard",
"description": "Slate-black menu board with chalk-white lettering and a chalk-cream accent.",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:coffee:Fraunces",
"fontBody": "template:coffee:Work Sans"
},
"lightColors": {
"background": "40 20% 94%",
"foreground": "200 12% 16%",
"card": "40 18% 90%",
"cardForeground": "200 12% 16%",
"popover": "40 18% 90%",
"popoverForeground": "200 12% 16%",
"primary": "195 22% 26%",
"primaryForeground": "40 30% 96%",
"secondary": "40 16% 86%",
"secondaryForeground": "200 12% 16%",
"muted": "40 14% 88%",
"mutedForeground": "200 10% 34%",
"accent": "28 55% 48%",
"accentForeground": "40 30% 96%",
"destructive": "0 68% 46%",
"destructiveForeground": "40 30% 96%",
"border": "200 10% 78%",
"input": "200 10% 78%",
"ring": "195 22% 26%"
},
"darkColors": {
"background": "195 18% 12%",
"foreground": "40 34% 92%",
"card": "195 16% 15%",
"cardForeground": "40 34% 92%",
"popover": "195 16% 15%",
"popoverForeground": "40 34% 92%",
"primary": "40 34% 90%",
"primaryForeground": "195 18% 12%",
"secondary": "195 14% 20%",
"secondaryForeground": "40 34% 92%",
"muted": "195 12% 18%",
"mutedForeground": "40 18% 72%",
"accent": "30 70% 62%",
"accentForeground": "195 18% 12%",
"destructive": "0 62% 55%",
"destructiveForeground": "40 34% 92%",
"border": "195 12% 24%",
"input": "195 12% 22%",
"ring": "40 34% 90%"
}
}
},
{
"id": "matcha-oat",
"name": "Matcha and Oat",
"description": "Soft sage green and oat cream for tea rooms and matcha bars.",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:coffee:Fraunces",
"fontBody": "template:coffee:Work Sans"
},
"lightColors": {
"background": "44 32% 94%",
"foreground": "120 15% 18%",
"card": "44 28% 91%",
"cardForeground": "120 15% 18%",
"popover": "44 28% 91%",
"popoverForeground": "120 15% 18%",
"primary": "132 26% 32%",
"primaryForeground": "44 32% 94%",
"secondary": "80 18% 84%",
"secondaryForeground": "120 15% 18%",
"muted": "80 16% 87%",
"mutedForeground": "120 12% 34%",
"accent": "36 60% 48%",
"accentForeground": "44 32% 94%",
"destructive": "0 68% 46%",
"destructiveForeground": "44 32% 94%",
"border": "90 14% 76%",
"input": "90 14% 76%",
"ring": "132 26% 32%"
},
"darkColors": {
"background": "130 16% 11%",
"foreground": "44 30% 91%",
"card": "130 14% 14%",
"cardForeground": "44 30% 91%",
"popover": "130 14% 14%",
"popoverForeground": "44 30% 91%",
"primary": "128 34% 60%",
"primaryForeground": "130 16% 11%",
"secondary": "130 12% 20%",
"secondaryForeground": "44 30% 91%",
"muted": "130 10% 18%",
"mutedForeground": "80 14% 68%",
"accent": "38 72% 60%",
"accentForeground": "130 16% 11%",
"destructive": "0 60% 55%",
"destructiveForeground": "44 30% 91%",
"border": "130 10% 24%",
"input": "130 10% 22%",
"ring": "128 34% 60%"
}
}
}
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 328 KiB

89
register.go Normal file
View File

@ -0,0 +1,89 @@
package main
import (
"context"
"github.com/a-h/templ"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/templates"
)
// wrap adapts a templ-returning render function to templates.TemplateFunc.
func wrap(f func(ctx context.Context, doc map[string]any) templ.Component) templates.TemplateFunc {
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
return f(ctx, doc)
}
}
// Register is the plugin entry point that wires Coffee's system template,
// page templates, blocks, overrides, and email wrapper.
func Register(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
// 1. System template ----------------------------------------------------
tr.RegisterSystemTemplate(templates.SystemTemplateMeta{
Key: "coffee",
Title: "Coffee",
Description: "Warm, hand-crafted theme for cafes, bakeries, and slow-craft makers",
})
// 2. Page templates -----------------------------------------------------
if err := tr.RegisterPageTemplate("coffee", templates.PageTemplateMeta{
Key: "default",
Title: "Default",
Description: "Header + main + footer with warm paper background",
Slots: []string{"header", "main", "footer"},
}, wrap(RenderCoffee)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("coffee", templates.PageTemplateMeta{
Key: "landing",
Title: "Landing",
Description: "Hero pour + featured menu, big imagery",
Slots: []string{"hero", "menu", "story", "cta", "footer"},
}, wrap(RenderCoffeeLanding)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("coffee", templates.PageTemplateMeta{
Key: "article",
Title: "Article",
Description: "Narrow narrative column for journal / recipes",
Slots: []string{"header", "main", "footer"},
}, wrap(RenderCoffeeArticle)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("coffee", templates.PageTemplateMeta{
Key: "full-width",
Title: "Full Width",
Description: "Edge-to-edge gallery / interior shots",
Slots: []string{"header", "main", "footer"},
}, wrap(RenderCoffeeFullWidth)); err != nil {
return err
}
// 3. Schemas — MUST be loaded BEFORE br.Register --------------------------
if err := br.LoadSchemasFromFS(Schemas()); err != nil {
return err
}
// 4. Theme-specific blocks (registered unqualified; addressed as coffee:<key>)
br.Register(MenuBoardBlockMeta, MenuBoardBlock)
br.Register(HoursStripBlockMeta, HoursStripBlock)
br.Register(LocationCardBlockMeta, LocationCardBlock)
br.Register(FeaturedPourBlockMeta, FeaturedPourBlock)
br.Register(FooterBlockMeta, FooterBlock)
br.Register(DoodleDividerBlockMeta, DoodleDividerBlock)
// 5. Built-in block overrides — only active when Coffee is the system template
br.RegisterTemplateOverride("coffee", "heading", CoffeeHeadingBlock)
br.RegisterTemplateOverride("coffee", "text", CoffeeTextBlock)
br.RegisterTemplateOverride("coffee", "button", CoffeeButtonBlock)
br.RegisterTemplateOverride("coffee", "image", CoffeeImageBlock)
// 6. Email wrapper ------------------------------------------------------
tr.RegisterEmailWrapper("coffee", CoffeeEmailWrapper)
return nil
}

25
registration.go Normal file
View File

@ -0,0 +1,25 @@
package main
import (
"io/fs"
"net/http"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/templates"
)
// Registration is the compile-time plugin registration for the Coffee theme.
var Registration = plugin.PluginRegistration{
Name: "coffee",
Version: plugin.ParseModVersion(pluginModBytes),
Register: func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
return Register(tr, br)
},
Assets: func() http.Handler { return AssetsHandler() },
Schemas: func() fs.FS { return Schemas() },
ThemePresets: func() []byte { return ThemePresets() },
BundledFonts: func() []byte { return BundledFonts() },
MasterPages: func() []plugin.MasterPageDefinition { return DefaultMasterPages() },
CSSManifest: func() *plugin.CSSManifest { return ThemeCSSManifest() },
}

View File

@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Doodle Divider",
"description": "Inline SVG divider in one of four hand-drawn motifs",
"type": "object",
"properties": {
"motif": {
"type": "string",
"title": "Motif",
"description": "Which doodle to render",
"default": "beans",
"x-editor": "select",
"enum": ["beans", "croissant", "cup", "leaf"]
}
}
}

View File

@ -4,19 +4,6 @@
"description": "Hero card for a featured coffee, tea, or pastry with image, tasting notes and price",
"type": "object",
"properties": {
"label": {
"type": "string",
"title": "Badge Label",
"description": "Hand-drawn badge text",
"default": "Featured pour",
"x-editor": "text"
},
"origin": {
"type": "string",
"title": "Origin / Kicker",
"description": "Small hand-drawn line above the name (e.g. \"Single origin\")",
"x-editor": "text"
},
"name": {
"type": "string",
"title": "Name",
@ -35,12 +22,6 @@
"description": "Hero image",
"x-editor": "media"
},
"currency": {
"type": "string",
"title": "Currency Symbol",
"default": "$",
"x-editor": "text"
},
"price": {
"type": "string",
"title": "Price",

View File

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Coffee Footer",
"description": "Torn-edge footer with optional location block and newsletter caption",
"type": "object",
"properties": {
"showLocation": {
"type": "string",
"title": "Show Location",
"description": "Whether to render the location/hours summary",
"default": "true",
"x-editor": "select",
"enum": ["true", "false"]
},
"newsletterText": {
"type": "string",
"title": "Newsletter Caption",
"description": "Short caption above the newsletter input",
"x-editor": "textarea"
}
}
}

View File

@ -0,0 +1,47 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Hours Strip",
"description": "Horizontal opening hours strip with today's row server-side highlighted",
"type": "object",
"properties": {
"todayLabel": {
"type": "string",
"title": "Today Label",
"description": "Label rendered next to today's row (e.g. \"Today\")",
"default": "Today",
"x-editor": "text"
},
"hours": {
"type": "array",
"title": "Weekly Hours",
"description": "One row per day. Days not listed render as closed.",
"default": [],
"x-editor": "collection",
"items": {
"type": "object",
"properties": {
"day": {
"type": "string",
"title": "Day",
"description": "Weekday (Mon, Tue, Wed, Thu, Fri, Sat, Sun)",
"x-editor": "select",
"enum": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
},
"open": {
"type": "string",
"title": "Opens",
"description": "Opening time (e.g. \"7:00\")",
"x-editor": "text"
},
"close": {
"type": "string",
"title": "Closes",
"description": "Closing time (e.g. \"15:00\")",
"x-editor": "text"
}
},
"required": ["day"]
}
}
}
}

View File

@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Location Card",
"description": "Address card with optional map image and directions link, doodle-pin overlay",
"type": "object",
"properties": {
"address": {
"type": "string",
"title": "Address",
"description": "Street address as a multi-line block",
"x-editor": "textarea"
},
"mapImage": {
"type": "string",
"title": "Map Image",
"description": "Optional static map image",
"x-editor": "media"
},
"directionsUrl": {
"type": "string",
"title": "Directions URL",
"description": "Link to Google Maps or similar",
"x-editor": "link"
}
}
}

View File

@ -0,0 +1,70 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Menu Board",
"description": "Kraft-paper menu card with sections of items (espresso, filter, pastry, etc.)",
"type": "object",
"properties": {
"title": {
"type": "string",
"title": "Title",
"description": "Heading shown at the top of the menu (e.g. \"Menu\", \"Today's Pour\")",
"default": "Menu",
"x-editor": "text"
},
"sections": {
"type": "array",
"title": "Sections",
"description": "Menu sections such as Espresso, Filter, Pastry",
"default": [],
"x-editor": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Section Name",
"description": "Section heading (e.g. \"Espresso\")",
"x-editor": "text"
},
"items": {
"type": "array",
"title": "Items",
"description": "Items in this section",
"x-editor": "collection",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
"description": "Item name (e.g. \"Flat White\")",
"x-editor": "text"
},
"price": {
"type": "string",
"title": "Price",
"description": "Price (e.g. \"5.50\")",
"x-editor": "text"
},
"note": {
"type": "string",
"title": "Note",
"description": "Optional descriptive line (e.g. tasting notes)",
"x-editor": "text"
},
"allergens": {
"type": "string",
"title": "Allergens",
"description": "Free-text allergen list (e.g. \"contains dairy, gluten\")",
"x-editor": "text"
}
},
"required": ["name"]
}
}
},
"required": ["name"]
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

View File

@ -1,177 +0,0 @@
{
"demo": true,
"settings": {
"merge": {
"site_title": "Ritual Coffee",
"site_description": "A neighbourhood cafe and small-batch roastery."
}
},
"data_tables": [
{
"key": "contact_submissions",
"name": "Contact Submissions",
"description": "Messages sent from the cafe contact form.",
"primary_key": "id",
"columns": [
{ "key": "name", "label": "Name", "type": "text", "required": true },
{ "key": "email", "label": "Email", "type": "email", "required": true },
{ "key": "reason", "label": "Reason", "type": "text", "required": false },
{ "key": "message", "label": "Message", "type": "text", "required": true }
]
}
],
"menu_items": [
{ "menu": "main", "label": "Home", "page_slug": "/", "sort_order": 0 },
{ "menu": "main", "label": "Menu", "url": "/#menu", "sort_order": 1 },
{ "menu": "main", "label": "About", "page_slug": "/about", "sort_order": 2 },
{ "menu": "main", "label": "Journal", "page_slug": "/blog", "sort_order": 3 },
{ "menu": "main", "label": "Visit", "page_slug": "/contact", "sort_order": 4 }
],
"pages": [
{
"slug": "/",
"title": "Ritual Coffee",
"template_key": "default",
"blocks": [
{ "block_key": "navbar", "title": "Navigation", "slot": "header", "sort_order": 0, "content": { "menuName": "main", "companyName": "Ritual Coffee", "ctaLabel": "Order ahead", "ctaUrl": "/contact" } },
{ "block_key": "hero", "title": "Hero", "slot": "main", "sort_order": 0, "content": { "template": "centered", "eyebrow": "Open from 7", "headline": "Coffee worth slowing down for", "subheadline": "We roast in small batches and pull every shot to order.", "ctaPrimary": { "text": "See the menu", "url": "/#menu" }, "ctaSecondary": { "text": "Find us", "url": "/contact" }, "minHeight": "50vh" } },
{ "block_key": "coffee:featured_pour", "title": "Featured Pour", "slot": "main", "sort_order": 1, "content": { "label": "This week", "origin": "Single origin", "name": "Guji Highlands", "tasting": "<p>Bright and floral. Notes of peach, jasmine and brown sugar.</p>", "price": "6.00", "currency": "$" } },
{ "block_key": "menu-list", "title": "Menu", "slot": "main", "sort_order": 2, "content": { "heading": "On the board", "intro": "Prices are for takeaway. Add fifty cents to dine in.", "currency": "$", "columns": 2, "sections": [
{ "title": "Espresso", "items": [
{ "name": "Espresso", "description": "Double shot", "price": "4.00" },
{ "name": "Flat white", "price": "5.00" },
{ "name": "Latte", "price": "5.50" },
{ "name": "Batch filter", "description": "Rotating single origin", "price": "5.00", "badges": [{ "label": "Filter" }] }
] },
{ "title": "Kitchen", "items": [
{ "name": "Butter croissant", "price": "5.50" },
{ "name": "Sourdough toast", "description": "With jam or honey", "price": "6.00" },
{ "name": "Bacon and egg roll", "price": "12.00" },
{ "name": "Granola bowl", "description": "Oats, yoghurt, seasonal fruit", "price": "11.00", "badges": [{ "label": "V" }] }
] }
] } },
{ "block_key": "hours-location", "title": "Hours", "slot": "main", "sort_order": 3, "content": { "heading": "Come and sit", "address": "12 Baker Lane\nNorthbridge", "phone": "08 1234 5678", "hours": [
{ "day": "Monday", "hours": "7:00 - 15:00" },
{ "day": "Tuesday", "hours": "7:00 - 15:00" },
{ "day": "Wednesday", "hours": "7:00 - 15:00" },
{ "day": "Thursday", "hours": "7:00 - 15:00" },
{ "day": "Friday", "hours": "7:00 - 16:00" },
{ "day": "Saturday", "hours": "8:00 - 16:00" },
{ "day": "Sunday", "closed": true }
] } },
{ "block_key": "footer", "title": "Footer", "slot": "footer", "sort_order": 0, "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily.", "address": "12 Baker Lane, Northbridge", "email": "hello@ritual.coffee", "copyright": "Ritual Coffee" } }
]
},
{
"slug": "/about",
"title": "About",
"template_key": "default",
"blocks": [
{ "block_key": "navbar", "title": "Navigation", "slot": "header", "sort_order": 0, "content": { "menuName": "main", "companyName": "Ritual Coffee", "ctaLabel": "Order ahead", "ctaUrl": "/contact" } },
{ "block_key": "hero", "title": "Hero", "slot": "main", "sort_order": 0, "content": { "template": "centered", "eyebrow": "Our story", "headline": "One roaster, one room", "subheadline": "We started on a single machine and never grew out of caring about it.", "minHeight": "auto" } },
{ "block_key": "rich-section", "title": "Story", "slot": "main", "sort_order": 1, "content": { "eyebrow": "Why we roast", "heading": "Fresh, not fast", "body": "<p>We roast twice a week and rest every batch before it hits the hopper. That means the coffee you drink was roasted days ago, not months.</p><p>Everything on the board is made in-house. The croissants are laminated overnight. The jam is cooked in small pots on the back bench.</p>", "mediaPosition": "right" } },
{ "block_key": "coffee:brew_guide", "title": "Brew Guide", "slot": "main", "sort_order": 2, "content": { "kicker": "Make it at home", "method": "V60 pour-over", "intro": "This is how we brew filter on the bar. Scale it up or down by the ratio.", "ratio": "1:16", "grind": "Medium-fine", "water": "300g", "temperature": "94C", "time": "3:00", "steps": [
{ "title": "Rinse and dose", "text": "Rinse the paper. Add 19g of coffee and level the bed." },
{ "title": "Bloom", "text": "Pour 45g of water. Wait 40 seconds." },
{ "title": "Pour in stages", "text": "Pour in slow circles to 300g by two minutes." },
{ "title": "Draw down", "text": "Let it finish by three minutes. Swirl and serve." }
] } },
{ "block_key": "team", "title": "Team", "slot": "main", "sort_order": 3, "content": { "heading": "Behind the bar", "members": [
{ "name": "Mara", "role": "Head roaster", "bio": "Runs the roaster and tastes every batch." },
{ "name": "Theo", "role": "Baker", "bio": "In before the birds. Owns the croissants." },
{ "name": "Priya", "role": "Barista", "bio": "Knows your order before you say it." }
] } },
{ "block_key": "footer", "title": "Footer", "slot": "footer", "sort_order": 0, "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily.", "email": "hello@ritual.coffee", "copyright": "Ritual Coffee" } }
]
},
{
"slug": "/blog",
"title": "Journal",
"template_key": "blog-index",
"blocks": [
{ "block_key": "navbar", "title": "Navigation", "slot": "header", "sort_order": 0, "content": { "menuName": "main", "companyName": "Ritual Coffee", "ctaLabel": "Order ahead", "ctaUrl": "/contact" } },
{ "block_key": "blog-hero", "title": "Journal Hero", "slot": "featured", "sort_order": 0, "content": { "title": "The Journal", "subtitle": "Notes from the roaster and the bar." } },
{ "block_key": "card", "title": "Post: Guji", "slot": "main", "sort_order": 0, "content": { "title": "Meet the Guji Highlands", "text": "Our new single origin is bright, floral and easy to love.", "link": "/blog/meet-the-guji-highlands", "linkLabel": "Read on" } },
{ "block_key": "card", "title": "Post: Roasting week", "slot": "main", "sort_order": 1, "content": { "title": "A week on the roaster", "text": "How we plan a roasting week and why we rest every batch.", "link": "/blog/a-week-on-the-roaster", "linkLabel": "Read on" } },
{ "block_key": "card", "title": "Post: Filter", "slot": "main", "sort_order": 2, "content": { "title": "How we brew filter", "text": "The recipe we use on the bar, step by step.", "link": "/blog/how-we-brew-filter", "linkLabel": "Read on" } },
{ "block_key": "footer", "title": "Footer", "slot": "footer", "sort_order": 0, "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily.", "email": "hello@ritual.coffee", "copyright": "Ritual Coffee" } }
]
},
{
"slug": "/blog/meet-the-guji-highlands",
"parent_slug": "/blog",
"title": "Meet the Guji Highlands",
"template_key": "article",
"blocks": [
{ "block_key": "navbar", "title": "Navigation", "slot": "header", "sort_order": 0, "content": { "menuName": "main", "companyName": "Ritual Coffee", "ctaLabel": "Order ahead", "ctaUrl": "/contact" } },
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 0, "content": { "level": 1, "text": "Meet the Guji Highlands" } },
{ "block_key": "text", "title": "Body", "slot": "main", "sort_order": 1, "content": { "text": "<p>Our new single origin comes from the Guji zone in southern Ethiopia. It is washed, which keeps it clean and bright.</p><p>We taste peach and jasmine up front, then a soft brown sugar finish. It works as espresso and shines as filter.</p><p>Come in this week and try it on the bar.</p>" } },
{ "block_key": "coffee:roast_profile", "title": "Roast Profile", "slot": "main", "sort_order": 2, "content": { "origin": "Single origin", "name": "Guji Highlands", "producer": "Smallholders, Guji zone", "roastLevel": "Medium", "notes": "<p>Peach, jasmine, brown sugar.</p>", "process": "Washed", "varietal": "Heirloom", "altitude": "1900-2100 masl" } },
{ "block_key": "footer", "title": "Footer", "slot": "footer", "sort_order": 0, "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily.", "email": "hello@ritual.coffee", "copyright": "Ritual Coffee" } }
]
},
{
"slug": "/blog/a-week-on-the-roaster",
"parent_slug": "/blog",
"title": "A week on the roaster",
"template_key": "article",
"blocks": [
{ "block_key": "navbar", "title": "Navigation", "slot": "header", "sort_order": 0, "content": { "menuName": "main", "companyName": "Ritual Coffee", "ctaLabel": "Order ahead", "ctaUrl": "/contact" } },
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 0, "content": { "level": 1, "text": "A week on the roaster" } },
{ "block_key": "text", "title": "Body", "slot": "main", "sort_order": 1, "content": { "text": "<p>We roast on Tuesdays and Fridays. Each batch is small, and we rest it before it goes on the bar.</p><p>Resting lets the coffee settle after roasting. Pulled too soon, it tastes sharp and gassy. Given a few days, it opens up.</p><p>That is why the date on the bag matters more than the date on the door.</p>" } },
{ "block_key": "footer", "title": "Footer", "slot": "footer", "sort_order": 0, "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily.", "email": "hello@ritual.coffee", "copyright": "Ritual Coffee" } }
]
},
{
"slug": "/blog/how-we-brew-filter",
"parent_slug": "/blog",
"title": "How we brew filter",
"template_key": "article",
"blocks": [
{ "block_key": "navbar", "title": "Navigation", "slot": "header", "sort_order": 0, "content": { "menuName": "main", "companyName": "Ritual Coffee", "ctaLabel": "Order ahead", "ctaUrl": "/contact" } },
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 0, "content": { "level": 1, "text": "How we brew filter" } },
{ "block_key": "text", "title": "Body", "slot": "main", "sort_order": 1, "content": { "text": "<p>Filter is the clearest way to taste a coffee. We keep the recipe simple so you can repeat it at home.</p><p>Weigh the coffee and the water. Keep the grind even. Pour in slow circles and give it room to draw down.</p>" } },
{ "block_key": "coffee:brew_guide", "title": "Brew Guide", "slot": "main", "sort_order": 2, "content": { "kicker": "The recipe", "method": "V60 pour-over", "ratio": "1:16", "grind": "Medium-fine", "water": "300g", "temperature": "94C", "time": "3:00", "steps": [
{ "title": "Rinse and dose", "text": "Rinse the paper. Add 19g and level the bed." },
{ "title": "Bloom", "text": "Pour 45g. Wait 40 seconds." },
{ "title": "Pour", "text": "Slow circles to 300g by two minutes." },
{ "title": "Finish", "text": "Draw down by three minutes. Serve." }
] } },
{ "block_key": "footer", "title": "Footer", "slot": "footer", "sort_order": 0, "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily.", "email": "hello@ritual.coffee", "copyright": "Ritual Coffee" } }
]
},
{
"slug": "/contact",
"title": "Visit",
"template_key": "contact",
"blocks": [
{ "block_key": "navbar", "title": "Navigation", "slot": "header", "sort_order": 0, "content": { "menuName": "main", "companyName": "Ritual Coffee", "ctaLabel": "Order ahead", "ctaUrl": "/contact" } },
{ "block_key": "hours-location", "title": "Hours", "slot": "main", "sort_order": 0, "content": { "heading": "Find us", "address": "12 Baker Lane\nNorthbridge", "phone": "08 1234 5678", "hours": [
{ "day": "Monday", "hours": "7:00 - 15:00" },
{ "day": "Tuesday", "hours": "7:00 - 15:00" },
{ "day": "Wednesday", "hours": "7:00 - 15:00" },
{ "day": "Thursday", "hours": "7:00 - 15:00" },
{ "day": "Friday", "hours": "7:00 - 16:00" },
{ "day": "Saturday", "hours": "8:00 - 16:00" },
{ "day": "Sunday", "closed": true }
] } },
{ "block_key": "contact-form", "title": "Contact Form", "slot": "main", "sort_order": 1, "content": { "heading": "Say hello", "description": "Booking a table for six or more? Send us a note and we will sort it.", "submitLabel": "Send message", "successMessage": "Thanks. We will get back to you soon.", "formConfig": { "targetTable": "contact_submissions" }, "fields": [
{ "name": "name", "label": "Name", "type": "text", "required": true, "width": "half" },
{ "name": "email", "label": "Email", "type": "email", "required": true, "width": "half" },
{ "name": "reason", "label": "Reason", "type": "text", "required": false, "width": "full" },
{ "name": "message", "label": "Message", "type": "textarea", "required": true, "width": "full" }
] } },
{ "block_key": "footer", "title": "Footer", "slot": "footer", "sort_order": 0, "content": { "companyName": "Ritual Coffee", "tagline": "Small-batch coffee, baked fresh daily.", "email": "hello@ritual.coffee", "copyright": "Ritual Coffee" } }
]
},
{
"slug": "/login",
"title": "Login",
"template_key": "auth",
"blocks": [
{ "block_key": "auth-form", "title": "Auth Form", "slot": "main", "sort_order": 0, "content": { "default_tab": "login", "show_social": false } },
{ "block_key": "text", "title": "Footer Note", "slot": "footer", "sort_order": 0, "content": { "text": "<p>Members get first pick of new roasts.</p>" } }
]
}
]
}

View File

@ -1,25 +0,0 @@
{
"workflows": [
{
"key": "notify_admins_on_contact",
"name": "Notify admins on contact",
"description": "Email site admins when someone submits the cafe contact form.",
"enabled": true,
"trigger": {
"type": "row_inserted",
"table": "contact_submissions"
},
"steps": [
{
"type": "email",
"name": "Email admins",
"template": "Form Submission Notification",
"recipients": {
"mode": "admins",
"scope": "all"
}
}
]
}
]
}

257
template.templ Normal file
View File

@ -0,0 +1,257 @@
package main
import (
"context"
"git.dev.alexdunmow.com/block/core/templates/bn"
)
// PageData carries everything the Coffee page templates need to render.
type PageData struct {
Title string
Slots map[string]string
ThemeMode string
ThemeCSS string
SiteSettings bn.SiteSettingsData
PageMeta bn.PageMeta
StructuredData string
CSSHash string
PageviewNonce string
EngagementConfig bn.EngagementConfig
}
func parseCoffeePageData(doc map[string]any) PageData {
title := "Untitled"
if t, ok := doc["title"].(string); ok {
title = t
}
slots := make(map[string]string)
if s, ok := doc["slots"].(map[string]string); ok {
slots = s
}
themeCSS := ""
if tc, ok := doc["theme_css"].(string); ok {
themeCSS = tc
}
structuredData := ""
if sd, ok := doc["structured_data"].(string); ok {
structuredData = sd
}
cssHash := ""
if ch, ok := doc["css_hash"].(string); ok {
cssHash = ch
}
pageviewNonce := ""
if pn, ok := doc["pageview_nonce"].(string); ok {
pageviewNonce = pn
}
themeMode := "light"
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
themeMode = tm
}
siteSettings := bn.ParseSiteSettings(doc)
pageMeta := bn.ParsePageMeta(doc)
engagementConfig := bn.ParseEngagementConfig(doc)
return PageData{
Title: title,
Slots: slots,
ThemeMode: themeMode,
ThemeCSS: themeCSS,
SiteSettings: siteSettings,
PageMeta: pageMeta,
StructuredData: structuredData,
CSSHash: cssHash,
PageviewNonce: pageviewNonce,
EngagementConfig: engagementConfig,
}
}
// Default page template (header / main / footer)
templ Coffee(data PageData) {
<!DOCTYPE html>
<html lang="en">
@bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
})
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
@bn.AdminBypassBanner(data.SiteSettings)
<header class="w-full">
@templ.Raw(data.Slots["header"])
</header>
<main class="flex-grow max-w-5xl mx-auto w-full px-4 py-8">
if main, ok := data.Slots["main"]; ok && main != "" {
@templ.Raw(main)
} else {
<div class="py-20 text-center coffee-body text-muted-foreground italic">
<p>Pour something in here.</p>
</div>
}
</main>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
// Landing page template with hero / menu / story / cta / footer slots.
templ CoffeeLanding(data PageData) {
<!DOCTYPE html>
<html lang="en">
@bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
})
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
@bn.AdminBypassBanner(data.SiteSettings)
<section class="w-full">
@templ.Raw(data.Slots["hero"])
</section>
<section class="w-full">
<div class="max-w-5xl mx-auto px-4">
@templ.Raw(data.Slots["menu"])
</div>
</section>
<section class="w-full">
<div class="max-w-3xl mx-auto px-4 py-12">
@templ.Raw(data.Slots["story"])
</div>
</section>
<section class="w-full">
<div class="max-w-5xl mx-auto px-4">
@templ.Raw(data.Slots["cta"])
</div>
</section>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
// Article page template — narrow narrative column for journal / recipes.
templ CoffeeArticle(data PageData) {
<!DOCTYPE html>
<html lang="en">
@bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
})
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
@bn.AdminBypassBanner(data.SiteSettings)
<header class="w-full">
<div class="max-w-3xl mx-auto px-4">
@templ.Raw(data.Slots["header"])
</div>
</header>
<main class="flex-grow max-w-2xl mx-auto w-full px-4 py-12 coffee-dropcap">
if main, ok := data.Slots["main"]; ok && main != "" {
<article class="prose prose-lg max-w-none">
@templ.Raw(main)
</article>
} else {
<div class="py-20 text-center coffee-body text-muted-foreground italic">
<p>Write something honest here.</p>
</div>
}
</main>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
// Full-width page template — edge-to-edge gallery / interior shots.
templ CoffeeFullWidth(data PageData) {
<!DOCTYPE html>
<html lang="en">
@bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
})
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
@bn.AdminBypassBanner(data.SiteSettings)
<header class="w-full">
@templ.Raw(data.Slots["header"])
</header>
<main class="flex-grow w-full">
if main, ok := data.Slots["main"]; ok && main != "" {
@templ.Raw(main)
} else {
<div class="max-w-3xl mx-auto py-20 px-4 text-center coffee-body text-muted-foreground italic">
<p>Add a gallery, a wide photo, a confession.</p>
</div>
}
</main>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
// RenderCoffee is the default page renderer.
func RenderCoffee(ctx context.Context, doc map[string]any) templ.Component {
return Coffee(parseCoffeePageData(doc))
}
// RenderCoffeeLanding renders the landing page template.
func RenderCoffeeLanding(ctx context.Context, doc map[string]any) templ.Component {
return CoffeeLanding(parseCoffeePageData(doc))
}
// RenderCoffeeArticle renders the article page template.
func RenderCoffeeArticle(ctx context.Context, doc map[string]any) templ.Component {
return CoffeeArticle(parseCoffeePageData(doc))
}
// RenderCoffeeFullWidth renders the full-width page template.
func RenderCoffeeFullWidth(ctx context.Context, doc map[string]any) templ.Component {
return CoffeeFullWidth(parseCoffeePageData(doc))
}

506
template_templ.go Normal file
View File

@ -0,0 +1,506 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"git.dev.alexdunmow.com/block/core/templates/bn"
)
// PageData carries everything the Coffee page templates need to render.
type PageData struct {
Title string
Slots map[string]string
ThemeMode string
ThemeCSS string
SiteSettings bn.SiteSettingsData
PageMeta bn.PageMeta
StructuredData string
CSSHash string
PageviewNonce string
EngagementConfig bn.EngagementConfig
}
func parseCoffeePageData(doc map[string]any) PageData {
title := "Untitled"
if t, ok := doc["title"].(string); ok {
title = t
}
slots := make(map[string]string)
if s, ok := doc["slots"].(map[string]string); ok {
slots = s
}
themeCSS := ""
if tc, ok := doc["theme_css"].(string); ok {
themeCSS = tc
}
structuredData := ""
if sd, ok := doc["structured_data"].(string); ok {
structuredData = sd
}
cssHash := ""
if ch, ok := doc["css_hash"].(string); ok {
cssHash = ch
}
pageviewNonce := ""
if pn, ok := doc["pageview_nonce"].(string); ok {
pageviewNonce = pn
}
themeMode := "light"
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
themeMode = tm
}
siteSettings := bn.ParseSiteSettings(doc)
pageMeta := bn.ParsePageMeta(doc)
engagementConfig := bn.ParseEngagementConfig(doc)
return PageData{
Title: title,
Slots: slots,
ThemeMode: themeMode,
ThemeCSS: themeCSS,
SiteSettings: siteSettings,
PageMeta: pageMeta,
StructuredData: structuredData,
CSSHash: cssHash,
PageviewNonce: pageviewNonce,
EngagementConfig: engagementConfig,
}
}
// Default page template (header / main / footer)
func Coffee(data PageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<header class=\"w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</header><main class=\"flex-grow max-w-5xl mx-auto w-full px-4 py-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if main, ok := data.Slots["main"]; ok && main != "" {
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"py-20 text-center coffee-body text-muted-foreground italic\"><p>Pour something in here.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</main><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Landing page template with hero / menu / story / cta / footer slots.
func CoffeeLanding(data PageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<!doctype html><html lang=\"en\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<body class=\"coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<section class=\"w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["hero"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</section><section class=\"w-full\"><div class=\"max-w-5xl mx-auto px-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["menu"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></section><section class=\"w-full\"><div class=\"max-w-3xl mx-auto px-4 py-12\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["story"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></section><section class=\"w-full\"><div class=\"max-w-5xl mx-auto px-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["cta"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></section><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Article page template — narrow narrative column for journal / recipes.
func CoffeeArticle(data PageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<!doctype html><html lang=\"en\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<body class=\"coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<header class=\"w-full\"><div class=\"max-w-3xl mx-auto px-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></header><main class=\"flex-grow max-w-2xl mx-auto w-full px-4 py-12 coffee-dropcap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if main, ok := data.Slots["main"]; ok && main != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<article class=\"prose prose-lg max-w-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"py-20 text-center coffee-body text-muted-foreground italic\"><p>Write something honest here.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</main><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Full-width page template — edge-to-edge gallery / interior shots.
func CoffeeFullWidth(data PageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<!doctype html><html lang=\"en\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/coffee/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<body class=\"coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<header class=\"w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</header><main class=\"flex-grow w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if main, ok := data.Slots["main"]; ok && main != "" {
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"max-w-3xl mx-auto py-20 px-4 text-center coffee-body text-muted-foreground italic\"><p>Add a gallery, a wide photo, a confession.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</main><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RenderCoffee is the default page renderer.
func RenderCoffee(ctx context.Context, doc map[string]any) templ.Component {
return Coffee(parseCoffeePageData(doc))
}
// RenderCoffeeLanding renders the landing page template.
func RenderCoffeeLanding(ctx context.Context, doc map[string]any) templ.Component {
return CoffeeLanding(parseCoffeePageData(doc))
}
// RenderCoffeeArticle renders the article page template.
func RenderCoffeeArticle(ctx context.Context, doc map[string]any) templ.Component {
return CoffeeArticle(parseCoffeePageData(doc))
}
// RenderCoffeeFullWidth renders the full-width page template.
func RenderCoffeeFullWidth(ctx context.Context, doc map[string]any) templ.Component {
return CoffeeFullWidth(parseCoffeePageData(doc))
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }}
<header class="w-full">
<div class="max-w-3xl mx-auto px-4">{{ slots.header|safe }}</div>
</header>
<main class="flex-grow max-w-2xl mx-auto w-full px-4 py-12 coffee-dropcap">
{% if slots.main %}<article class="prose prose-lg max-w-none">{{ slots.main|safe }}</article>{% else %}<div class="py-20 text-center coffee-body text-muted-foreground italic"><p>Write something honest here.</p></div>{% endif %}
</main>
<footer class="w-full mt-auto">{{ slots.footer|safe }}</footer>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,19 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }}
<main class="flex flex-grow w-full flex-col items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<a href="/" class="mb-6 flex items-center justify-center" aria-label="Home">
{% if site_settings.Logo %}<img src="{{ site_settings.Logo }}" alt="{{ site_settings.LogoAlt|default:site_settings.Title }}" class="h-10 w-auto">{% else %}<span class="coffee-display text-3xl text-primary">{{ site_settings.Title|default:"Coffee" }}</span>{% endif %}
</a>
<div>
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="coffee-card p-6 text-center coffee-body text-muted-foreground"><p>No form assigned to this page.</p></div>{% endif %}
</div>
{% if slots.footer %}<div class="coffee-body mt-6 text-center text-sm text-muted-foreground">{{ slots.footer|safe }}</div>{% endif %}
</div>
</main>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }}
<header class="w-full">{{ slots.header|safe }}</header>
<main class="flex-grow w-full">
{% if title %}<div class="mx-auto max-w-6xl px-4 pt-12"><h1 class="coffee-display text-4xl text-primary md:text-5xl"><span class="coffee-doodle-underline">{{ title }}</span></h1>{% if page_meta.MetaDescription %}<p class="coffee-body mt-3 max-w-2xl text-lg text-muted-foreground">{{ page_meta.MetaDescription }}</p>{% endif %}</div>{% endif %}
{% if slots.featured %}<section class="w-full">{{ slots.featured|safe }}</section>{% endif %}
{% if slots.main %}<section class="w-full">{{ slots.main|safe }}</section>{% else %}<div class="py-20 text-center coffee-hand text-2xl text-muted-foreground"><p>No stories brewed yet.</p></div>{% endif %}
</main>
<footer class="w-full mt-auto">{{ slots.footer|safe }}</footer>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }}
<header class="w-full">{{ slots.header|safe }}</header>
<main class="flex-grow w-full">
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center coffee-hand text-2xl text-muted-foreground"><p>Add a contact form and your hours.</p></div>{% endif %}
</main>
<footer class="w-full mt-auto">{{ slots.footer|safe }}</footer>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }}
<header class="w-full">{{ slots.header|safe }}</header>
<main class="flex-grow w-full">
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center coffee-hand text-2xl text-muted-foreground"><p>Pour something in here.</p></div>{% endif %}
</main>
<footer class="w-full mt-auto">{{ slots.footer|safe }}</footer>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }}
<header class="w-full">{{ slots.header|safe }}</header>
<main class="flex-grow w-full">
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="max-w-3xl mx-auto py-20 px-4 text-center coffee-body text-muted-foreground italic"><p>Add a gallery, a wide photo, a confession.</p></div>{% endif %}
</main>
<footer class="w-full mt-auto">{{ slots.footer|safe }}</footer>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,19 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }}
<section class="w-full">{{ slots.hero|safe }}</section>
<section class="w-full">
<div class="max-w-5xl mx-auto px-4">{{ slots.menu|safe }}</div>
</section>
<section class="w-full">
<div class="max-w-3xl mx-auto px-4 py-12">{{ slots.story|safe }}</div>
</section>
<section class="w-full">
<div class="max-w-5xl mx-auto px-4">{{ slots.cta|safe }}</div>
</section>
<footer class="w-full mt-auto">{{ slots.footer|safe }}</footer>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,99 +0,0 @@
<!doctype html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ site_name }}</title>
<style type="text/css">
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
}
h1, h2, h3, h4, h5, h6 {
font-family: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif;
font-weight: 600;
}
@media only screen and (max-width: 620px) {
.coffee-email-container {
width: 100% !important;
max-width: 100% !important;
}
.coffee-email-padding {
padding-left: 24px !important;
padding-right: 24px !important;
}
}
</style>
</head>
<body style="background-color: {{ colors.background|default:"#f4ece1" }}; margin: 0; padding: 0; font-family: &amp;#39;Inter&amp;#39;, -apple-system, BlinkMacSystemFont, &amp;#39;Segoe UI&amp;#39;, Roboto, sans-serif;">
{% if preview_text %}<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">{{ preview_text }}</div>{% endif %}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" style="padding: 40px 10px; background-color: {{ colors.background|default:"#f4ece1" }};">
<table role="presentation" class="coffee-email-container" width="560" cellspacing="0" cellpadding="0" border="0" style="max-width: 560px; background-color: {{ colors.card|default:"#ece1d0" }}; border: 1px solid {{ colors.border|default:"#c9b69e" }}; border-radius: 4px;">
<tr>
<td align="center" style="padding: 32px 40px 16px; border-bottom: 1px dashed {{ colors.border|default:"#c9b69e" }};">
{% if logo_url %}<img src="{{ logo_url }}" alt="{{ site_name }}" style="max-height: 48px; width: auto; display: block;">{% elif site_name %}<h1 style="margin: 0; font-size: 24px; letter-spacing: -0.01em; color: {{ colors.primary|default:"#8a4a23" }};">{{ site_name }}</h1>{% endif %}
</td>
</tr>
<tr>
<td class="coffee-email-padding" style="padding: 32px 40px; color: {{ colors.foreground|default:"#3d2a1a" }}; font-size: 16px; line-height: 1.65;">{{ body|safe }}</td>
</tr>
<tr>
<td align="center" style="padding: 16px 40px 8px;">
<!-- inline SVG doodle divider, survives Outlook -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0"><tr><td>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="32" viewBox="0 0 200 32" fill="none" stroke="{{ colors.primary|default:"#c95b2f" }}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 18 L80 18"></path>
<path d="M120 18 L196 18"></path>
<ellipse cx="95" cy="18" rx="6" ry="9"></ellipse>
<path d="M95 9 L95 27"></path>
<ellipse cx="108" cy="18" rx="6" ry="9"></ellipse>
<path d="M108 9 L108 27"></path>
</svg>
</td></tr></table>
</td>
</tr>
<tr>
<td style="padding: 16px 40px 32px; color: {{ colors.mutedForeground|default:"#6b5440" }}; font-size: 12px; line-height: 1.6;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center">
{% if site_name %}<p style="margin: 0 0 6px; font-size: 13px; color: {{ colors.foreground|default:"#3d2a1a" }};">{{ site_name }}</p>{% endif %}
<p style="margin: 0 0 6px;">Pull up a seat. Pastries from 7, coffee until late.</p>
{% if site_url %}<p style="margin: 0 0 8px;"><a href="{{ site_url }}" style="color: {{ colors.primary|default:"#c95b2f" }}; text-decoration: none; border-bottom: 1px dashed {{ colors.primary|default:"#c95b2f" }};">{{ site_url }}</a></p>{% endif %}
{% if unsubscribe_url %}<p style="margin: 0; font-size: 11px;"><a href="{{ unsubscribe_url }}" style="color: {{ colors.mutedForeground|default:"#6b5440" }}; text-decoration: none;">Unsubscribe</a></p>{% endif %}
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -1,21 +0,0 @@
{# coffee announcement-bar — dismissable strip; dismissal persists in localStorage keyed by variant+message hash. #}
{% set anncls = "bg-accent/15 text-foreground" %}
{% if variant == "promo" %}{% set anncls = "bg-accent text-accent-foreground" %}{% elif variant == "alert" %}{% set anncls = "bg-destructive text-destructive-foreground" %}{% endif %}
<div data-bn-announcement data-bn-annkey="{{ variant|default:'info' }}:{{ message|slugify }}" class="bn-announcement w-full {{ anncls }} {{ class }}">
<div class="relative mx-auto flex max-w-7xl items-center justify-center gap-2 px-10 py-2 text-center text-sm">
<p class="coffee-body font-medium">{{ message }}{% if linkText and linkUrl %} <a href="{{ linkUrl }}" class="underline decoration-dashed underline-offset-2 hover:opacity-80">{{ linkText }}</a>{% endif %}</p>
{% if dismissable %}<button type="button" data-bn-ann-dismiss aria-label="Dismiss announcement" class="absolute right-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center rounded p-1 transition-colors hover:bg-black/10"><svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>{% endif %}
</div>
</div>
{% if dismissable %}<script>
(function(){
var bars=document.querySelectorAll('[data-bn-announcement]:not([data-bn-ann-wired])');
bars.forEach(function(bar){
bar.setAttribute('data-bn-ann-wired','1');
var key='bnann:'+bar.getAttribute('data-bn-annkey');
try{if(localStorage.getItem(key)==='1'){bar.style.display='none';return;}}catch(e){}
var btn=bar.querySelector('[data-bn-ann-dismiss]');
if(btn)btn.addEventListener('click',function(){bar.style.display='none';try{localStorage.setItem(key,'1');}catch(e){}});
});
})();
</script>{% endif %}

View File

@ -1,15 +0,0 @@
<figure class="bn-audio coffee-card my-8 overflow-hidden{% if class %} {{ class }}{% endif %}" data-bn-audio data-audio-url="{{ url|default:"" }}" data-audio-media="{% if media %}{{ media|mediaURL }}{% endif %}"><div class="flex items-center gap-4 p-4">{% if artwork %}<div class="h-16 w-16 shrink-0 overflow-hidden rounded-sm bg-secondary">{% img artwork alt=title|default:"" class="h-full w-full object-cover" %}</div>{% endif %}<div class="min-w-0 flex-1">{% if title %}<p class="coffee-display truncate text-lg text-primary">{{ title }}</p>{% endif %}{% if artist %}<p class="coffee-hand truncate text-lg text-accent">{{ artist }}</p>{% endif %}<div class="mt-2 flex items-center gap-3" data-bn-audio-slot><button type="button" data-bn-audio-play class="kraft-tag text-xs" aria-label="Play{% if title %}: {{ title }}{% endif %}"><svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#play"></use></svg> Play</button>{% if download %}<a href="{% if media %}{{ media|mediaURL }}{% else %}{{ url }}{% endif %}" download class="coffee-body inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"><svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#download"></use></svg> Download</a>{% endif %}</div></div></div></figure><script>
(function(){
if(window.__bnAudioFacade)return;window.__bnAudioFacade=true;
document.addEventListener('click',function(e){
var btn=e.target.closest('[data-bn-audio-play]');if(!btn)return;
var fig=btn.closest('[data-bn-audio]');if(!fig)return;
e.preventDefault();
var src=fig.getAttribute('data-audio-media')||fig.getAttribute('data-audio-url')||'';
if(!src)return;
var slot=btn.closest('[data-bn-audio-slot]');
var a=document.createElement('audio');a.src=src;a.controls=true;a.autoplay=true;a.preload='none';a.style.cssText='width:100%;min-width:12rem;height:2.25rem;';
btn.remove();slot.insertBefore(a,slot.firstChild);
});
})();
</script>

View File

@ -1,32 +0,0 @@
{# coffee auth-form — combined login/register in kraft chrome. Endpoints and panel ids match the platform auth flow (/api/auth/login, /api/auth/register, #auth-message, #login-panel/#register-panel). Auth state via the auth_state provider (auth.logged_in / auth.display_name). Captcha, when enabled, is emitted by the platform auth path. #}
{% if auth.logged_in %}
<div class="auth-form-logged-in coffee-card mx-auto max-w-md p-6 text-center">
<p class="coffee-body text-muted-foreground">You are logged in as <strong class="text-primary">{{ auth.display_name }}</strong></p>
<a href="/account" class="kraft-tag mt-4">My account</a>
</div>
{% else %}
{% set defaultTab = default_tab|default:"login" %}
<div class="auth-form coffee-card mx-auto max-w-md p-6">
<div id="auth-message"></div>
<div class="mb-5 flex border-b border-dashed border-border">
<button type="button" onclick="document.getElementById('login-panel').classList.remove('hidden');document.getElementById('register-panel').classList.add('hidden');this.className='coffee-display flex-1 py-2 text-center border-b-2 border-primary text-primary';this.nextElementSibling.className='coffee-display flex-1 py-2 text-center border-b-2 border-transparent text-muted-foreground'" class="coffee-display flex-1 py-2 text-center border-b-2 {% if defaultTab == "register" %}border-transparent text-muted-foreground{% else %}border-primary text-primary{% endif %}">Login</button>
<button type="button" onclick="document.getElementById('register-panel').classList.remove('hidden');document.getElementById('login-panel').classList.add('hidden');this.className='coffee-display flex-1 py-2 text-center border-b-2 border-primary text-primary';this.previousElementSibling.className='coffee-display flex-1 py-2 text-center border-b-2 border-transparent text-muted-foreground'" class="coffee-display flex-1 py-2 text-center border-b-2 {% if defaultTab == "register" %}border-primary text-primary{% else %}border-transparent text-muted-foreground{% endif %}">Register</button>
</div>
<div id="login-panel" class="{% if defaultTab == "register" %}hidden{% endif %}">
<form hx-post="/api/auth/login" hx-target="#auth-message" hx-swap="innerHTML">
<div class="mb-3"><label class="coffee-hand mb-1 block text-lg text-accent">Email</label><input type="email" name="email" required class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"></div>
<div class="mb-4"><label class="coffee-hand mb-1 block text-lg text-accent">Password</label><input type="password" name="password" required class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"></div>
<button type="submit" class="kraft-tag w-full justify-center" hx-disabled-elt="this">Login</button>
<p class="coffee-body mt-3 text-center text-sm"><a href="/forgot-password" class="text-accent underline decoration-dashed">Forgot password?</a></p>
</form>
</div>
<div id="register-panel" class="{% if defaultTab == "register" %}{% else %}hidden{% endif %}">
<form hx-post="/api/auth/register" hx-target="#auth-message" hx-swap="innerHTML">
<div class="mb-3"><label class="coffee-hand mb-1 block text-lg text-accent">Username</label><input type="text" name="username" required pattern="[a-zA-Z0-9_-]{3,30}" class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"></div>
<div class="mb-3"><label class="coffee-hand mb-1 block text-lg text-accent">Email</label><input type="email" name="email" required class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"></div>
<div class="mb-4"><label class="coffee-hand mb-1 block text-lg text-accent">Password</label><input type="password" name="password" required minlength="8" class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"></div>
<button type="submit" class="kraft-tag w-full justify-center" hx-disabled-elt="this">Create account</button>
</form>
</div>
</div>
{% endif %}

View File

@ -1,25 +0,0 @@
{# coffee auth-status — header login state. Auth state via the auth_state provider; endpoints match the platform (/login, /login?tab=register, /api/auth/logout, /api/auth/resend-verification). #}
{% if not auth.logged_in %}
<div class="auth-status flex items-center gap-2">
<a href="/login" class="coffee-body rounded-lg px-3 py-1.5 text-sm font-medium opacity-80 transition-colors hover:bg-muted hover:opacity-100">Login</a>
<a href="/login?tab=register" class="kraft-tag text-sm">Sign up</a>
</div>
{% else %}
{% if not auth.email_verified %}<div class="fixed inset-x-0 top-16 z-40 bg-accent px-4 py-2 text-center text-sm text-accent-foreground">Please verify your email address. <button hx-post="/api/auth/resend-verification" hx-swap="outerHTML" class="font-medium underline">Resend verification email</button></div>{% endif %}
<div class="auth-status relative">
<button type="button" onclick="this.nextElementSibling.classList.toggle('hidden')" class="coffee-body flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium opacity-90 transition-colors hover:bg-muted hover:opacity-100">
<span class="coffee-hand flex h-7 w-7 items-center justify-center rounded-full bg-accent text-sm text-accent-foreground">{{ auth.display_name|first|upper }}</span>
<span>{{ auth.display_name }}</span>
<svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#chevron-down"></use></svg>
</button>
<div id="auth-dropdown-menu" class="coffee-card absolute right-0 top-full z-50 mt-1 hidden w-48 py-1 shadow-lg">
<a href="/account" class="coffee-body block px-4 py-2 text-sm text-foreground hover:bg-muted">My profile</a>
<a href="/account/reviews" class="coffee-body block px-4 py-2 text-sm text-foreground hover:bg-muted">My reviews</a>
<hr class="my-1 border-dashed border-border">
<button hx-post="/api/auth/logout" hx-swap="none" class="coffee-body block w-full px-4 py-2 text-left text-sm text-destructive hover:bg-muted">Logout</button>
</div>
</div>
<script>
(function(){document.addEventListener('click',function(e){var menu=document.getElementById('auth-dropdown-menu');var btn=document.querySelector('.auth-status > button');if(!menu||!btn)return;if(!btn.contains(e.target)&&!menu.contains(e.target)){menu.classList.add('hidden');}});})();
</script>
{% endif %}

View File

@ -1,20 +0,0 @@
{% if author %}
<section class="border-b border-dashed border-border bg-background py-12 text-foreground md:py-16{% if class %} {{ class }}{% endif %}">
<div class="mx-auto flex max-w-3xl flex-col items-center px-4 text-center">
{% if showAvatar and author.avatar_url %}<div class="mb-5 h-24 w-24 overflow-hidden rounded-full bg-secondary ring-2 ring-border">{% img author.avatar_url alt=author.display_name class="h-full w-full object-cover" %}</div>{% endif %}
<h1 class="coffee-display text-3xl text-primary md:text-4xl">{{ author.display_name }}</h1>
{% if author.job_title or author.company %}<p class="coffee-hand mt-1 text-xl text-accent">{{ author.job_title }}{% if author.job_title and author.company %} · {% endif %}{{ author.company }}</p>{% endif %}
{% if author.bio %}<p class="coffee-body mt-4 max-w-2xl leading-relaxed text-muted-foreground">{{ author.bio }}</p>{% endif %}
<div class="coffee-body mt-5 flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground">
{% if showPostCount %}<span>{{ author.post_count|default:0 }} post{% if author.post_count != 1 %}s{% endif %}</span>{% endif %}
{% if author.location %}<span>{{ author.location }}</span>{% endif %}
</div>
{% if showSocial %}<div class="mt-5 flex items-center justify-center gap-3">
{% if author.website_url %}<a href="{{ author.website_url }}" target="_blank" rel="noopener" class="text-muted-foreground transition-colors hover:text-accent" title="Website"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#globe"></use></svg></a>{% endif %}
{% if author.twitter_handle %}<a href="https://twitter.com/{{ author.twitter_handle|cut:"@" }}" target="_blank" rel="noopener" class="text-muted-foreground transition-colors hover:text-accent" title="Twitter"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#x"></use></svg></a>{% endif %}
{% if author.linkedin_url %}<a href="{{ author.linkedin_url }}" target="_blank" rel="noopener" class="text-muted-foreground transition-colors hover:text-accent" title="LinkedIn"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#linkedin"></use></svg></a>{% endif %}
{% if author.github_url %}<a href="{{ author.github_url }}" target="_blank" rel="noopener" class="text-muted-foreground transition-colors hover:text-accent" title="GitHub"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#github"></use></svg></a>{% endif %}
</div>{% endif %}
</div>
</section>
{% endif %}

View File

@ -1,12 +0,0 @@
{% if post.author_name %}
<aside class="coffee-card p-6{% if class %} {{ class }}{% endif %}">
{% if heading %}<p class="coffee-hand mb-4 text-xl text-accent">{{ heading }}</p>{% endif %}
<div class="flex items-start gap-4">
{% if showAvatar and post.author_avatar %}<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-full bg-secondary ring-2 ring-border">{% img post.author_avatar alt=post.author_name class="h-full w-full object-cover" %}</div>{% endif %}
<div class="min-w-0">
<h3 class="coffee-display text-base text-primary">{% if post.author_slug %}<a href="/author/{{ post.author_slug }}" class="transition-colors hover:text-accent">{{ post.author_name }}</a>{% else %}{{ post.author_name }}{% endif %}</h3>
{% if post.author_bio %}<p class="coffee-body mt-1 text-sm text-muted-foreground">{{ post.author_bio }}</p>{% endif %}
</div>
</div>
</aside>
{% endif %}

View File

@ -1,22 +0,0 @@
<section class="bg-background py-12 text-foreground md:py-16{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
<div class="max-w-2xl">
{% if title %}<h1 class="coffee-display text-4xl text-primary md:text-5xl"><span class="coffee-doodle-underline">{{ title }}</span></h1>{% endif %}
{% if subtitle %}<p class="coffee-body mt-3 text-lg text-muted-foreground">{{ subtitle }}</p>{% endif %}
</div>
{% if posts %}
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-3">
{% for post in posts %}
<article class="group flex flex-col{% if forloop.First %} md:col-span-3 md:flex-row md:items-stretch md:gap-6{% endif %}">
{% if post.featured_image_url %}<a href="{{ post.url }}" class="coffee-frame mb-3 block overflow-hidden rounded-sm p-1.5{% if forloop.First %} md:mb-0 md:w-1/2{% endif %}">{% img post.featured_image_url alt=post.title class="aspect-video h-full w-full rounded-sm object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
<div class="flex flex-col justify-center{% if forloop.First %} md:w-1/2{% endif %}">
<h2 class="coffee-display {% if forloop.First %}text-2xl{% else %}text-lg{% endif %} text-primary"><a href="{{ post.url }}" class="transition-colors hover:text-accent">{{ post.title }}</a></h2>
{% if showExcerpt and post.excerpt %}<p class="coffee-body mt-2 text-sm text-muted-foreground">{{ post.excerpt }}</p>{% endif %}
<div class="coffee-hand mt-3 flex flex-wrap items-center gap-x-3 text-lg text-accent">{% if post.author_name %}<span>{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span class="text-muted-foreground">{{ post.published_at|date:"M j, Y" }}</span>{% endif %}</div>
</div>
</article>
{% endfor %}
</div>
{% endif %}
</div>
</section>

View File

@ -1,12 +0,0 @@
{% set arrows = template == "arrows" %}
<nav aria-label="Breadcrumb" class="py-3{% if class %} {{ class }}{% endif %}">
<ol class="coffee-body flex flex-wrap items-center gap-1 text-sm">
{% if showHome %}<li class="flex items-center"><a href="/" class="text-muted-foreground transition-colors hover:text-accent">{{ homeLabel|default:"Home" }}</a></li>{% endif %}
{% for item in breadcrumbs %}
<li class="flex items-center">
{% if showHome or not forloop.First %}{% if arrows %}<svg width="16" height="16" class="mx-2 h-4 w-4 text-accent" aria-hidden="true"><use href="/icons/lucide.svg#chevron-right"></use></svg>{% else %}<span class="mx-2 text-accent">{{ separator|default:"/" }}</span>{% endif %}{% endif %}
{% if item.is_current %}{% if showCurrent %}<span class="coffee-display text-foreground" aria-current="page">{{ item.label }}</span>{% endif %}{% else %}<a href="{{ item.url }}" class="text-muted-foreground transition-colors hover:text-accent">{{ item.label }}</a>{% endif %}
</li>
{% endfor %}
</ol>
</nav>

View File

@ -1,5 +0,0 @@
{% with align=align|default:"left" style=style|default:"primary" %}
<div class="bn-button flex {% if align == "center" %}justify-center{% elif align == "right" %}justify-end{% else %}justify-start{% endif %}{% if class %} {{ class }}{% endif %}">
{% if link %}<a href="{{ link }}"{% if newTab %} target="_blank" rel="noopener"{% endif %} class="kraft-tag {% if style == "secondary" %}bg-secondary text-secondary-foreground{% elif style == "outline" or style == "ghost" %}kraft-tag--ghost{% elif style == "destructive" %}bg-destructive text-destructive-foreground{% endif %} {% if size == "lg" %}text-base px-6 py-3{% elif size == "sm" %}text-xs px-3 py-1.5{% endif %}">{% if icon and iconPosition != "right" %}<svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}<span>{{ label|default:"Learn more" }}</span>{% if icon and iconPosition == "right" %}<svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</a>{% else %}<button type="button" class="kraft-tag {% if style == "secondary" %}bg-secondary text-secondary-foreground{% elif style == "outline" or style == "ghost" %}kraft-tag--ghost{% elif style == "destructive" %}bg-destructive text-destructive-foreground{% endif %} {% if size == "lg" %}text-base px-6 py-3{% elif size == "sm" %}text-xs px-3 py-1.5{% endif %}">{% if icon and iconPosition != "right" %}<svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}<span>{{ label|default:"Learn more" }}</span>{% if icon and iconPosition == "right" %}<svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</button>{% endif %}
</div>
{% endwith %}

View File

@ -1,12 +0,0 @@
{% with layout=layout|default:"vertical" %}
<article class="bn-card coffee-card overflow-hidden transition-shadow hover:shadow-md {% if layout == "horizontal" %}flex flex-col sm:flex-row{% endif %}{% if class %} {{ class }}{% endif %}">
{% if link %}<a href="{{ link }}" class="contents">{% endif %}
{% if media %}<div class="overflow-hidden bg-secondary {% if layout == "horizontal" %}sm:w-2/5 sm:shrink-0{% endif %}">{% if layout == "horizontal" %}{% img media alt=title|default:"" class="h-full w-full object-cover" %}{% else %}{% img media alt=title|default:"" class="aspect-video h-full w-full object-cover" %}{% endif %}</div>{% endif %}
<div class="flex flex-col gap-2 p-5 md:p-6 {% if layout == "horizontal" %}justify-center{% endif %}">
{% if title %}<h3 class="coffee-display text-xl text-primary">{{ title }}</h3>{% endif %}
{% if text %}<div class="coffee-body text-sm leading-relaxed text-muted-foreground">{{ text|safe }}</div>{% endif %}
{% if linkLabel and link %}<span class="coffee-hand mt-2 inline-flex items-center gap-1 text-lg text-accent">{{ linkLabel }} <svg width="16" height="16" class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></span>{% endif %}
</div>
{% if link %}</a>{% endif %}
</article>
{% endwith %}

View File

@ -1,17 +0,0 @@
{% set pills = style == "pills" %}
<section class="bg-background text-foreground{% if class %} {{ class }}{% endif %}">
{% if heading %}<h2 class="coffee-hand mb-4 text-2xl text-accent">{{ heading }}</h2>{% endif %}
{% if pills %}
<div class="flex flex-wrap gap-2">
{% for cat in categories %}
<a href="{{ cat.url }}" class="coffee-body inline-flex items-center gap-1.5 rounded-full border border-dashed border-border bg-card px-3 py-1 text-sm text-card-foreground transition-colors hover:border-accent hover:text-accent">{{ cat.name }}{% if showCount %}<span class="text-xs text-muted-foreground">{{ cat.post_count }}</span>{% endif %}</a>
{% endfor %}
</div>
{% else %}
<ul class="space-y-1">
{% for cat in categories %}
<li><a href="{{ cat.url }}" class="coffee-body flex items-center justify-between rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"><span>{{ cat.name }}</span>{% if showCount %}<span class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{{ cat.post_count }}</span>{% endif %}</a></li>
{% endfor %}
</ul>
{% endif %}
</section>

View File

@ -1,17 +0,0 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-4xl px-4">
{% if heading %}<h2 class="coffee-display text-3xl text-primary md:text-4xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
{% if description %}<p class="coffee-body mt-3 max-w-2xl text-lg text-muted-foreground">{{ description }}</p>{% endif %}
<div class="mt-10 grid grid-cols-1 gap-4 {% if columns == 3 %}sm:grid-cols-2 lg:grid-cols-3{% elif columns == 1 %}{% else %}sm:grid-cols-2{% endif %}">
{% for channel in channels %}
<div class="coffee-card flex items-start gap-4 p-5">
{% if channel.icon %}<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/{{ channel.icon|split:":"|first }}.svg#{{ channel.icon|split:":"|last }}"></use></svg></span>{% endif %}
<div class="min-w-0">
{% if channel.label %}<div class="coffee-hand text-lg text-accent">{{ channel.label }}</div>{% endif %}
{% if channel.value %}<div class="coffee-body mt-0.5 break-words font-medium">{% if channel.link %}<a href="{{ channel.link }}" class="transition-colors hover:text-accent">{{ channel.value }}</a>{% else %}{{ channel.value }}{% endif %}</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</section>

View File

@ -1,39 +0,0 @@
{% with bid=_block_id|default:context.blockId %}
<section id="contact-form-{{ bid }}" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-2xl px-4">
{% if form.submitted %}
<div class="coffee-card coffee-torn-top coffee-torn-bottom p-8 text-center">
<div class="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-accent/15 text-accent"><svg width="24" height="24" class="h-6 w-6" aria-hidden="true"><use href="/icons/lucide.svg#check"></use></svg></div>
<p class="coffee-display text-xl text-primary">{{ form.message|default:successMessage|default:"Thanks. Your message is on its way." }}</p>
</div>
{% else %}
{% if heading %}<h2 class="coffee-display text-3xl text-primary md:text-4xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
{% if description %}<p class="coffee-body mt-3 text-lg text-muted-foreground">{{ description }}</p>{% endif %}
{% if form.errors._form %}<div class="coffee-body mt-6 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">{{ form.errors._form }}</div>{% endif %}
{% with formAction="/api/blocks/"|add:bid|add:"/submit" formTarget="#contact-form-"|add:bid %}
{% form action=formAction hx_target=formTarget hx_swap="outerHTML" class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2" %}
{% for field in fields %}
<div class="{% if field.width == "half" %}sm:col-span-1{% else %}sm:col-span-2{% endif %}">
{% if field.label %}<label for="cf-{{ bid }}-{{ field.name }}" class="coffee-hand mb-1.5 block text-lg text-accent">{{ field.label }}{% if field.required %} <span class="text-destructive">*</span>{% endif %}</label>{% endif %}
{% if field.type == "textarea" %}
<textarea id="cf-{{ bid }}-{{ field.name }}" name="{{ field.name }}" rows="5"{% if field.required %} required{% endif %}{% if field.placeholder %} placeholder="{{ field.placeholder }}"{% endif %} class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"></textarea>
{% elif field.type == "select" %}
<select id="cf-{{ bid }}-{{ field.name }}" name="{{ field.name }}"{% if field.required %} required{% endif %} class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
{% if field.placeholder %}<option value="">{{ field.placeholder }}</option>{% endif %}
{% for opt in field.options %}<option value="{{ opt.value }}">{{ opt.label|default:opt.value }}</option>{% endfor %}
</select>
{% else %}
<input id="cf-{{ bid }}-{{ field.name }}" name="{{ field.name }}" type="{{ field.type|default:"text" }}"{% if field.required %} required{% endif %}{% if field.placeholder %} placeholder="{{ field.placeholder }}"{% endif %} class="coffee-body w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring">
{% endif %}
</div>
{% endfor %}
<div class="hidden" aria-hidden="true"><label>Leave this field empty<input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>
<div class="sm:col-span-2">
<button type="submit" class="kraft-tag">{{ submitLabel|default:"Send message" }}</button>
</div>
{% endform %}
{% endwith %}
{% endif %}
</div>
</section>
{% endwith %}

Some files were not shown because too many files have changed in this diff Show More