Compare commits

..

6 Commits

Author SHA1 Message Date
Alex Dunmow
c2dd751f6b fix(coffee): remediate torn-mask clipping, empty-media & JIT-dropped layout
Layout/whitespace bugs:
- .coffee-torn-top/bottom used mask-size:100% 100%, stretching the 12px torn
  SVG over the whole element and masking away ~40% of tall cards as a wavy
  transparent cut-out — the cause of the clipped hero region, featured_pour
  and footer. Reworked to a fixed ~13px deckled band per edge over a solid
  gradient (plus a combined top+bottom selector).
- featured_pour showed an editor-style "Add a hero image" placeholder to
  visitors when the demo seed ships no image; now a tasteful token-based coffee
  glyph on the secondary surface. Same for the image block ("Add an image").
- hours today-highlight was accent-foreground text over a 15% accent tint
  (light-on-light, unreadable "Sunday"); now a readable full-row band with an
  accent left edge (class moved from <th> to <tr>).

Mobile responsiveness / JIT robustness (host Tailwind does not reliably compile
arbitrary/responsive utilities out of theme templates):
- Replaced min-h-[50vh|75vh|400px|320px], md:grid-cols-[2fr_3fr] and
  md:grid-cols-2 in hero/featured_pour/menu-list/hours with explicit CSS helper
  classes carrying real media queries; base is single-column so every grid
  collapses to 1 col on mobile regardless of the JIT.

Semantic tokens only; no version bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:25:36 +08:00
Alex Dunmow
4b5a37b7d3 fix(coffee): collapse multi-line ninjatpl comments to single-line (load-fatal lexer error) 2026-07-05 17:06:50 +08:00
Alex Dunmow
1ee4a32cdf bump to 0.2.2 2026-07-05 16:51:02 +08:00
Alex Dunmow
f657f15ad7 bump to 0.2.1 2026-07-05 16:44:47 +08:00
Alex Dunmow
537fe0ffc3 docs: publishing README 2026-07-05 13:42:54 +08:00
Alex Dunmow
023bd23d96 feat(coffee): Wave A override contract — 49 builtin overrides, 7 page templates, persona blocks, seed
WO-TF-013. Bring the coffee theme up to the frozen OVERRIDE-CONTRACT:

- 49 mandatory builtin overrides in templates/overrides/coffee/ (chrome,
  marketing, primitives, commerce/local, blog family, auth surface,
  page-suggestions), each re-skinned in cafe/roastery chrome over the builtin
  data model. Legacy button/heading/image/text field reads updated to the new
  builtin contract (link/label/style, media/text).
- Signature moves: chalkboard menu panel (menu-list), hand-drawn doodle
  dividers, torn-paper edges, kraft-tag stamped buttons, circular roast stamps,
  hours "today" highlight. Semantic tokens only; CSS-first motion with
  prefers-reduced-motion fallbacks.
- 7 page templates: default, landing, article, full-width, blog-index, contact,
  auth.
- Blocks rework: deleted the five builtin-duplicating theme blocks (menu_board,
  hours_strip, location_card, footer, doodle_divider) — now overrides. Kept/added
  4 persona blocks: featured_pour, roast_profile, loyalty_card, brew_guide.
- 5 dual-mode presets (mode "both"), all 19 tokens, light and dark tuned.
- Bundled Fraunces / Work Sans / Caveat woff2 (OFL, latin subset, license
  recorded); fonts.json populated; all font-family via var(--font-*). Updated
  RECOMMENDED_FONTS.md, added RECOMMENDED_ICONS.md; required_icon_packs declared.
- Themed email-safe wrapper retained (tables, inline styles).
- Master pages rebuilt on builtin keys. Demo seed (demo:true): home, about,
  blog + 3 posts, contact (form + contact_submissions data table + row_inserted
  email-admins workflow), login. Cafe voice.

make (codeless build + verify) and check-safety both exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:57:54 +08:00
93 changed files with 2409 additions and 534 deletions

24
README.md Normal file
View File

@ -0,0 +1,24 @@
# 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,49 +1,50 @@
# Coffee — Recommended fonts # Coffee — Recommended fonts
The Coffee theme ships `fonts.json = []` per the wave-1 The Coffee theme **bundles** its type as woff2 in `assets/fonts/` (latin
[FONTS.md](../docs/FONTS.md) policy. No woff2s are bundled in this pass. subsets, SIL Open Font License 1.1 — see `assets/fonts/OFL.txt`). The three
Templates resolve font families through the BlockNinja CSS variables families become `source=template` rows in the fonts table and are selectable
`--font-heading`, `--font-body`, and `--font-mono` with the fallback stacks in the admin typography picker. Templates always resolve font families through
defined in `assets/style.css`. The site admin assigns fonts in the the BlockNinja CSS variables `--font-heading`, `--font-body`, `--font-mono`
typography panel; the picks below are the spec-aligned defaults that match with the fallback stacks defined in the theme CSS — no family is hardcoded.
the visual identity.
## How to apply ## Bundled families
1. Open the admin → Theme → Typography panel. | Slot | Family | Weights | Role |
2. Switch to the **Google Fonts** tab. |-----------|------------|-----------|------|
3. Search and pick each family below. | Heading | Fraunces | 400, 600 | Ligature-rich display serif for headings and prices. Warm, hand-cut character. |
4. Assign to the matching slot (Heading / Body / Mono). | Body | Work Sans | 400, 600 | Relaxed grotesque for long copy at 17px+. Reads well on cream and espresso backgrounds. |
5. Save. The picks are persisted as `google:<Family>` and rendered as | Accent | Caveat | 400, 700 | Hand-drawn script for badges, stamps, and doodle captions. |
`@import` URLs by `theme.GenerateCSS()`.
## Picks 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.
| Slot | Source | Family | Why | ## Default behaviour (no admin picks)
|----------|------------------|------------------|-----|
| 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`. |
All three are already in the curated Google Fonts list in the picker and On a fresh install the admin has not assigned any slot, so `--font-heading` /
have permissive SIL OFL 1.1 licences. `--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:
## Fallback stacks (used before admin picks fonts) - Headings render in **Fraunces**.
- Body renders in **Work Sans**.
- Hand-drawn accents (`.coffee-hand`) render in **Caveat**.
Defined in `assets/style.css`: ## 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)
- `--coffee-heading-fallback`: `"Fraunces", "Playfair Display", Georgia, "Times New Roman", serif` - `--coffee-heading-fallback`: `"Fraunces", "Playfair Display", Georgia, "Times New Roman", serif`
- `--coffee-body-fallback`: `"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-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` - `--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.

26
RECOMMENDED_ICONS.md Normal file
View File

@ -0,0 +1,26 @@
# 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.

111
assets/fonts/OFL.txt Normal file
View File

@ -0,0 +1,111 @@
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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,51 +1,49 @@
/* Coffee theme styles /* Coffee theme styles
* *
* Uses the 19 shadcn-style HSL token CSS variables (--background, * Dual-mode via the 19 shadcn-style HSL token CSS variables (--background,
* --foreground, --primary, --accent, --border, --muted, --card, ...) via * --foreground, --primary, --accent, --border, --muted, --card, ...) consumed
* `hsl(var(--token))`. Font families are resolved through the BlockNinja font * as hsl(var(--token)). No literal colors. Font families resolve ONLY through
* variables (--font-heading, --font-body, --font-mono) with fallback stacks * the BlockNinja font variables (--font-heading, --font-body, --font-mono) with
* derived from the spec §3 typography list. * the fallback stacks below never hardcoded, so the admin font picker works.
*/ */
/* --- 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 { :root {
--coffee-heading-fallback: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif; --coffee-heading-fallback: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif;
--coffee-body-fallback: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-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; --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 { body.coffee-paper {
background-color: hsl(var(--background)); 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-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; background-repeat: repeat;
} }
/* --- Headings ----------------------------------------------------------- */ /* --- Type roles --------------------------------------------------------- */
.coffee-display { .coffee-display {
font-family: var(--font-heading, var(--coffee-heading-fallback)); font-family: var(--font-heading, var(--coffee-heading-fallback));
font-feature-settings: "liga" 1, "dlig" 1; font-feature-settings: "liga" 1, "dlig" 1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.coffee-body { .coffee-body {
font-family: var(--font-body, var(--coffee-body-fallback)); font-family: var(--font-body, var(--coffee-body-fallback));
line-height: 1.65; line-height: 1.65;
} }
.coffee-mono { .coffee-mono {
font-family: var(--font-mono, var(--coffee-mono-fallback)); 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 (heading override) -------------------------------- */ /* --- Doodle underline --------------------------------------------------- */
.coffee-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-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-repeat: no-repeat;
@ -54,7 +52,7 @@ body.coffee-paper {
padding-bottom: 0.18em; padding-bottom: 0.18em;
} }
/* --- Drop-cap (text override / article body) ---------------------------- */ /* --- Drop-cap ----------------------------------------------------------- */
.coffee-dropcap > p:first-of-type::first-letter { .coffee-dropcap > p:first-of-type::first-letter {
font-family: var(--font-heading, var(--coffee-heading-fallback)); font-family: var(--font-heading, var(--coffee-heading-fallback));
font-size: 4em; font-size: 4em;
@ -64,7 +62,7 @@ body.coffee-paper {
color: hsl(var(--primary)); color: hsl(var(--primary));
} }
/* --- Kraft-tag button --------------------------------------------------- */ /* --- Kraft-tag button (stamp press on hover) ---------------------------- */
.kraft-tag { .kraft-tag {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@ -78,74 +76,239 @@ body.coffee-paper {
font-family: var(--font-body, var(--coffee-body-fallback)); font-family: var(--font-body, var(--coffee-body-fallback));
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
box-shadow: 0 1px 0 hsl(var(--border)); box-shadow: 0 2px 0 hsl(var(--border));
transition: transform 120ms ease, box-shadow 120ms ease; transition: transform 120ms ease, box-shadow 120ms ease;
cursor: pointer; cursor: pointer;
} }
.kraft-tag:hover { .kraft-tag:hover {
transform: rotate(-1.2deg); transform: translateY(1px) rotate(-1.2deg);
box-shadow: 0 2px 0 hsl(var(--border)); box-shadow: 0 1px 0 hsl(var(--border));
} }
.kraft-tag:focus-visible { .kraft-tag:focus-visible {
outline: 2px solid hsl(var(--ring)); outline: 2px solid hsl(var(--ring));
outline-offset: 3px; 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-edge utility -------------------------------------------------- */ /* --- 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 { .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"); -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"); 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-repeat: no-repeat; -webkit-mask-position: top, bottom;
mask-repeat: no-repeat; mask-position: top, bottom;
-webkit-mask-size: 100% 100%; -webkit-mask-size: 100% 13px, 100% calc(100% - 12px);
mask-size: 100% 100%; mask-size: 100% 13px, 100% calc(100% - 12px);
-webkit-mask-repeat: no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat;
} }
.coffee-torn-bottom { .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"); -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: 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-repeat: no-repeat; -webkit-mask-position: top, bottom;
mask-repeat: no-repeat; mask-position: top, bottom;
-webkit-mask-size: 100% 100%; -webkit-mask-size: 100% calc(100% - 12px), 100% 13px;
mask-size: 100% 100%; 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;
} }
/* --- Coffee surfaces ---------------------------------------------------- */ /* --- Surfaces ----------------------------------------------------------- */
.coffee-card { .coffee-card {
background-color: hsl(var(--card)); background-color: hsl(var(--card));
color: hsl(var(--card-foreground)); color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.coffee-frame { .coffee-frame {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
background-color: hsl(var(--card)); background-color: hsl(var(--card));
} }
.coffee-pencil-rule { .coffee-pencil-rule {
border-color: hsl(var(--border)); border-color: hsl(var(--border));
border-style: solid; border-style: solid;
} }
/* --- Today highlight ---------------------------------------------------- */ /* --- Chalkboard menu panel (inverts foreground/background for a hung board;
.coffee-hours-today { * light mode = dark board + cream chalk, dark mode = light menu paper) --- */
background-color: hsl(var(--accent) / 0.15); .coffee-chalkboard {
color: hsl(var(--accent-foreground)); position: relative;
border-left: 3px solid hsl(var(--accent)); background-color: hsl(var(--foreground));
padding-left: 0.75rem; 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);
} }
/* --- Price typography --------------------------------------------------- */ /* --- 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 ------------------------------------------------------
* Explicit CSS so these layouts never depend on the host Tailwind JIT
* compiling arbitrary/responsive utilities out of THEME templates it does
* not scan them reliably, so `min-h-[50vh]` / `md:grid-cols-[2fr_3fr]` /
* `md:grid-cols-2` in a theme file can silently vanish. Base is always a
* single column, so grids collapse on mobile regardless of the JIT. */
.coffee-minh-50 { min-height: 50vh; }
.coffee-minh-75 { min-height: 75vh; }
.coffee-minh-screen { min-height: 100vh; }
.coffee-minh-400 { min-height: 400px; }
.coffee-minh-320 { min-height: 320px; }
.coffee-split-2 { display: grid; grid-template-columns: 1fr; }
.coffee-pour-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
.coffee-menu-grid { display: grid; grid-template-columns: 1fr; gap: 2.5rem 3rem; }
.coffee-info-grid { display: grid; grid-template-columns: 1fr; gap: 2.5rem; }
@media (min-width: 768px) {
.coffee-pour-grid { grid-template-columns: 2fr 3fr; align-items: center; }
.coffee-menu-grid.coffee-menu-grid--2 { grid-template-columns: 1fr 1fr; }
.coffee-info-grid.coffee-info-grid--2 { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 1024px) {
.coffee-split-2 { grid-template-columns: 1fr 1fr; }
}
/* --- Price + spec chips ------------------------------------------------- */
.coffee-price { .coffee-price {
font-family: var(--font-mono, var(--coffee-mono-fallback)); font-family: var(--font-mono, var(--coffee-mono-fallback));
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: hsl(var(--accent)); 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;
}
/* --- Doodle pin overlay for location card ------------------------------- */ /* --- 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 { .coffee-pin {
color: hsl(var(--accent)); 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,42 +1,32 @@
# Coffee theme block definitions (codeless .bnp, WO-WZ-021). # Coffee theme block definitions (codeless .bnp).
# Keys are fully qualified (coffee:<key>) because the codeless loader registers # Only true persona furniture stays a theme block. Everything that duplicated a
# definitions verbatim — there is no PluginBlockRegistry prefixing in the # builtin (menu, hours, location, footer, divider) is now a template override of
# codeless path (unlike the wasm/templ path). The qualified keys match the # the builtin key under templates/overrides/coffee/ — not a block here.
# hardcoded data-block attributes and the master_pages.json references. #
# Keys are fully qualified (coffee:<key>): the codeless loader registers
# definitions verbatim (no PluginBlockRegistry prefixing in the codeless path).
blocks: blocks:
- key: coffee:menu_board
title: Menu Board
description: Kraft-paper menu with sections of items (espresso, filter, pastry, ...)
category: content
schema: menu_board.schema.json
template: menu_board.ninjatpl
- key: coffee:hours_strip
title: Hours Strip
description: Weekly hours strip listing each day's opening hours.
category: content
schema: hours_strip.schema.json
template: hours_strip.ninjatpl
- key: coffee:location_card
title: Location Card
description: Address card with optional static map image and doodled pin overlay.
category: content
schema: location_card.schema.json
template: location_card.ninjatpl
- key: coffee:featured_pour - key: coffee:featured_pour
title: Featured Pour title: Featured Pour
description: Hero card for a featured coffee, tea or pastry with tasting notes and price. description: Single-origin spotlight card with hand-drawn badge, tasting notes and price.
category: content category: content
schema: featured_pour.schema.json schema: featured_pour.schema.json
template: featured_pour.ninjatpl template: featured_pour.ninjatpl
- key: coffee:footer - key: coffee:roast_profile
title: Footer title: Roast Profile
description: Torn-edge footer with optional location summary and newsletter caption. description: Roastery spec card — origin, process, roast level and tasting notes with a circular roast stamp.
category: layout category: content
schema: footer.schema.json schema: roast_profile.schema.json
template: footer.ninjatpl template: roast_profile.ninjatpl
- key: coffee:doodle_divider - key: coffee:loyalty_card
title: Doodle Divider title: Loyalty Card
description: Hand-drawn divider in one of four motifs (beans, croissant, cup, leaf). description: Ten-stamp coffee punch card with a reward on the tenth stamp.
category: layout category: content
schema: doodle_divider.schema.json schema: loyalty_card.schema.json
template: doodle_divider.ninjatpl 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

@ -0,0 +1,29 @@
<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

@ -0,0 +1,67 @@
{
"$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,39 +0,0 @@
<div data-block="coffee:doodle_divider" data-motif="{{ motif|default:"beans" }}" class="my-8 flex items-center justify-center text-accent">
{% if motif == "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>
{% elif motif == "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>
{% elif motif == "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>
{% else %}
<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>
{% endif %}
</div>

View File

@ -1,16 +0,0 @@
{
"$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

@ -1,15 +1,14 @@
<section data-block="coffee:featured_pour" class="my-10"> <section data-block="coffee:featured_pour" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="coffee-card max-w-4xl mx-auto p-6 grid gap-6 md:grid-cols-[2fr_3fr] items-center relative"> <div class="coffee-card coffee-torn-bottom coffee-pour-grid relative mx-auto max-w-4xl p-6">
<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"> <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>
Featured
</span>
<div class="aspect-square overflow-hidden rounded-sm bg-secondary"> <div class="aspect-square overflow-hidden rounded-sm bg-secondary">
{% if image %}<img src="{{ image }}" alt="{{ 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>{% endif %} {% 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 class="h-24 w-24" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg></div>{% endif %}
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
{% if name %}<h3 class="coffee-display text-3xl text-primary">{{ name }}</h3>{% else %}<h3 class="coffee-display text-3xl text-muted-foreground italic">Featured pour</h3>{% endif %} {% if origin %}<p class="coffee-hand text-xl text-accent">{{ origin }}</p>{% endif %}
{% if tasting %}<div class="coffee-body text-foreground prose-sm max-w-none">{{ tasting|safe }}</div>{% 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 price %}<div class="coffee-price price text-xl">{{ price }}</div>{% 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>
</div> </div>
</section> </section>

View File

@ -4,6 +4,19 @@
"description": "Hero card for a featured coffee, tea, or pastry with image, tasting notes and price", "description": "Hero card for a featured coffee, tea, or pastry with image, tasting notes and price",
"type": "object", "type": "object",
"properties": { "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": { "name": {
"type": "string", "type": "string",
"title": "Name", "title": "Name",
@ -22,6 +35,12 @@
"description": "Hero image", "description": "Hero image",
"x-editor": "media" "x-editor": "media"
}, },
"currency": {
"type": "string",
"title": "Currency Symbol",
"default": "$",
"x-editor": "text"
},
"price": { "price": {
"type": "string", "type": "string",
"title": "Price", "title": "Price",

View File

@ -1,28 +0,0 @@
<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 showLocation != "false" and showLocation != false %}
<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>
{% endif %}
<div>
<h4 class="coffee-display text-xl text-primary mb-2">Stay in touch</h4>
{% if newsletterText %}<p class="text-sm text-muted-foreground mb-3">{{ newsletterText }}</p>{% else %}<p class="text-sm text-muted-foreground mb-3">Slow notes from the bar. Brew tips, seasonal pours, no spam.</p>{% endif %}
<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>

View File

@ -1,22 +0,0 @@
{
"$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

@ -1,13 +0,0 @@
{% if context.now %}{% set today = context.now|date:"D"|lower %}{% endif %}
<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 not hours %}<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>{% endif %}
{% for row in hours %}
<li class="coffee-body px-2 py-1 flex items-baseline gap-2 text-sm rounded-sm {% if row.day and row.day|lower|slice:":3" == today %}coffee-hours-today is-today today{% endif %}">
<span class="coffee-display font-semibold text-foreground w-12 shrink-0">{{ row.day }}</span>
{% if row.day and row.day|lower|slice:":3" == today %}<span class="coffee-body text-[10px] uppercase tracking-wider text-accent">{{ todayLabel|default:"Today" }}</span>{% endif %}
<span class="coffee-mono text-foreground">{% if row.open or row.close %}{{ row.open }} {{ row.close }}{% else %}Closed{% endif %}</span>
</li>
{% endfor %}
</ul>
</aside>

View File

@ -1,47 +0,0 @@
{
"$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

@ -1,18 +0,0 @@
<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 mapImage %}<img src="{{ 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>{% endif %}
<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 address %}<address class="coffee-body not-italic text-foreground whitespace-pre-line">{{ address }}</address>{% else %}<p class="coffee-body text-sm text-muted-foreground italic">Add an address to render the location card.</p>{% endif %}
{% if directionsUrl %}<a href="{{ directionsUrl }}" class="kraft-tag mt-2 self-start" target="_blank" rel="noopener noreferrer">Directions</a>{% endif %}
</div>
</div>
</section>

View File

@ -1,26 +0,0 @@
{
"$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,24 @@
{% 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 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 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 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 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 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 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 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 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 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 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

@ -0,0 +1,38 @@
{
"$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:menu_board" class="my-10">
<div class="coffee-card max-w-3xl mx-auto px-6 py-8">
<h2 class="coffee-display text-3xl mb-6 text-center">
<span class="coffee-doodle-underline">{{ title|default:"Menu" }}</span>
</h2>
{% if not sections %}<p class="coffee-body text-sm text-muted-foreground text-center italic">Add a section to get started.</p>{% endif %}
{% for section in sections %}
<div class="{% if forloop.First %}mb-6{% else %}mt-8 mb-6{% endif %}">
{% if section.name %}<h3 class="coffee-display text-xl mb-3 text-primary">{{ section.name }}</h3>{% endif %}
{% if not section.items %}<p class="coffee-body text-sm text-muted-foreground italic">No items yet.</p>{% endif %}
<ul class="space-y-2">
{% for item in 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>{% endif %}
{% if item.allergens %}<div class="coffee-body text-xs text-muted-foreground italic">Allergens: {{ item.allergens }}</div>{% endif %}
</div>
{% if item.price %}<div class="coffee-price price text-base whitespace-nowrap">{{ item.price }}</div>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</section>

View File

@ -1,70 +0,0 @@
{
"$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"]
}
}
}
}

View File

@ -0,0 +1,26 @@
<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

@ -0,0 +1,64 @@
{
"$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"
}
}
}

View File

@ -1 +1,26 @@
[] [
{
"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" }
]
}
]

View File

@ -1,95 +1,157 @@
# Coffee theme — codeless .bnp manifest (WO-WZ-021 Phase B1). # Coffee theme — codeless .bnp manifest (Wave A, WO-TF-013).
# Synthesized into manifest.pb by `ninja plugin build` (no plugin.wasm). # Synthesized into manifest.pb by `ninja plugin build` (no plugin.wasm).
theme_presets: presets.json theme_presets: presets.json
bundled_fonts: fonts.json bundled_fonts: fonts.json
master_pages: master_pages.json master_pages: master_pages.json
required_icon_packs:
- lucide
- simple-icons
system_templates: system_templates:
- key: coffee - key: coffee
title: Coffee title: Coffee
description: "Warm, hand-crafted theme for cafes, bakeries, and slow-craft makers" description: "Warm, hand-crafted cafe and roastery theme — kraft paper, chalkboard menus, doodles and stamps."
page_templates: page_templates:
- system: coffee - system: coffee
key: default key: default
title: Default title: Default
description: "Header + main + footer with warm paper background" description: "Header + main + footer over warm paper grain."
slots: [header, main, footer] slots: [header, main, footer]
- system: coffee - system: coffee
key: landing key: landing
title: Landing title: Landing
description: "Hero pour + featured menu, big imagery" description: "Hero pour, featured menu, story and invitation."
slots: [hero, menu, story, cta, footer] slots: [hero, menu, story, cta, footer]
- system: coffee - system: coffee
key: article key: article
title: Article title: Article
description: "Narrow narrative column for journal / recipes" description: "Narrow narrative column with drop-cap for journal and recipes."
slots: [header, main, footer] slots: [header, main, footer]
- system: coffee - system: coffee
key: full-width key: full-width
title: Full Width title: Full Width
description: "Edge-to-edge gallery / interior shots" description: "Edge-to-edge gallery and interior shots."
slots: [header, main, footer] slots: [header, main, footer]
- system: coffee
template_overrides: key: blog-index
- { template: coffee, block: heading } title: Blog Index
- { template: coffee, block: text } description: "Blog hero, featured posts and a post grid."
- { template: coffee, block: button } slots: [header, featured, main, footer]
- { template: coffee, block: image } - 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: email_wrappers:
- coffee - 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: css:
input_css_append: | input_css_append: |
/* Coffee theme styles /* Coffee theme styles
* *
* Uses the 19 shadcn-style HSL token CSS variables (--background, * Dual-mode via the 19 shadcn-style HSL token CSS variables (--background,
* --foreground, --primary, --accent, --border, --muted, --card, ...) via * --foreground, --primary, --accent, --border, --muted, --card, ...) consumed
* `hsl(var(--token))`. Font families are resolved through the BlockNinja font * as hsl(var(--token)). No literal colors. Font families resolve ONLY through
* variables (--font-heading, --font-body, --font-mono) with fallback stacks * the BlockNinja font variables (--font-heading, --font-body, --font-mono) with
* derived from the spec §3 typography list. * the fallback stacks below — never hardcoded, so the admin font picker works.
*/ */
/* --- 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 { :root {
--coffee-heading-fallback: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif; --coffee-heading-fallback: "Fraunces", "Playfair Display", Georgia, "Times New Roman", serif;
--coffee-body-fallback: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-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; --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 { body.coffee-paper {
background-color: hsl(var(--background)); 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-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; background-repeat: repeat;
} }
/* --- Headings ----------------------------------------------------------- */ /* --- Type roles --------------------------------------------------------- */
.coffee-display { .coffee-display {
font-family: var(--font-heading, var(--coffee-heading-fallback)); font-family: var(--font-heading, var(--coffee-heading-fallback));
font-feature-settings: "liga" 1, "dlig" 1; font-feature-settings: "liga" 1, "dlig" 1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.coffee-body { .coffee-body {
font-family: var(--font-body, var(--coffee-body-fallback)); font-family: var(--font-body, var(--coffee-body-fallback));
line-height: 1.65; line-height: 1.65;
} }
.coffee-mono { .coffee-mono {
font-family: var(--font-mono, var(--coffee-mono-fallback)); 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 (heading override) -------------------------------- */ /* --- Doodle underline --------------------------------------------------- */
.coffee-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-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-repeat: no-repeat;
@ -98,7 +160,7 @@ css:
padding-bottom: 0.18em; padding-bottom: 0.18em;
} }
/* --- Drop-cap (text override / article body) ---------------------------- */ /* --- Drop-cap ----------------------------------------------------------- */
.coffee-dropcap > p:first-of-type::first-letter { .coffee-dropcap > p:first-of-type::first-letter {
font-family: var(--font-heading, var(--coffee-heading-fallback)); font-family: var(--font-heading, var(--coffee-heading-fallback));
font-size: 4em; font-size: 4em;
@ -108,7 +170,7 @@ css:
color: hsl(var(--primary)); color: hsl(var(--primary));
} }
/* --- Kraft-tag button --------------------------------------------------- */ /* --- Kraft-tag button (stamp press on hover) ---------------------------- */
.kraft-tag { .kraft-tag {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@ -122,74 +184,239 @@ css:
font-family: var(--font-body, var(--coffee-body-fallback)); font-family: var(--font-body, var(--coffee-body-fallback));
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
box-shadow: 0 1px 0 hsl(var(--border)); box-shadow: 0 2px 0 hsl(var(--border));
transition: transform 120ms ease, box-shadow 120ms ease; transition: transform 120ms ease, box-shadow 120ms ease;
cursor: pointer; cursor: pointer;
} }
.kraft-tag:hover { .kraft-tag:hover {
transform: rotate(-1.2deg); transform: translateY(1px) rotate(-1.2deg);
box-shadow: 0 2px 0 hsl(var(--border)); box-shadow: 0 1px 0 hsl(var(--border));
} }
.kraft-tag:focus-visible { .kraft-tag:focus-visible {
outline: 2px solid hsl(var(--ring)); outline: 2px solid hsl(var(--ring));
outline-offset: 3px; 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-edge utility -------------------------------------------------- */ /* --- 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 { .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"); -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"); 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-repeat: no-repeat; -webkit-mask-position: top, bottom;
mask-repeat: no-repeat; mask-position: top, bottom;
-webkit-mask-size: 100% 100%; -webkit-mask-size: 100% 13px, 100% calc(100% - 12px);
mask-size: 100% 100%; mask-size: 100% 13px, 100% calc(100% - 12px);
-webkit-mask-repeat: no-repeat, no-repeat;
mask-repeat: no-repeat, no-repeat;
} }
.coffee-torn-bottom { .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"); -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: 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-repeat: no-repeat; -webkit-mask-position: top, bottom;
mask-repeat: no-repeat; mask-position: top, bottom;
-webkit-mask-size: 100% 100%; -webkit-mask-size: 100% calc(100% - 12px), 100% 13px;
mask-size: 100% 100%; 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;
} }
/* --- Coffee surfaces ---------------------------------------------------- */ /* --- Surfaces ----------------------------------------------------------- */
.coffee-card { .coffee-card {
background-color: hsl(var(--card)); background-color: hsl(var(--card));
color: hsl(var(--card-foreground)); color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.coffee-frame { .coffee-frame {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
background-color: hsl(var(--card)); background-color: hsl(var(--card));
} }
.coffee-pencil-rule { .coffee-pencil-rule {
border-color: hsl(var(--border)); border-color: hsl(var(--border));
border-style: solid; border-style: solid;
} }
/* --- Today highlight ---------------------------------------------------- */ /* --- Chalkboard menu panel (inverts foreground/background for a hung board;
.coffee-hours-today { * light mode = dark board + cream chalk, dark mode = light menu paper) --- */
background-color: hsl(var(--accent) / 0.15); .coffee-chalkboard {
color: hsl(var(--accent-foreground)); position: relative;
border-left: 3px solid hsl(var(--accent)); background-color: hsl(var(--foreground));
padding-left: 0.75rem; 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);
} }
/* --- Price typography --------------------------------------------------- */ /* --- 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 ------------------------------------------------------
* Explicit CSS so these layouts never depend on the host Tailwind JIT
* compiling arbitrary/responsive utilities out of THEME templates — it does
* not scan them reliably, so `min-h-[50vh]` / `md:grid-cols-[2fr_3fr]` /
* `md:grid-cols-2` in a theme file can silently vanish. Base is always a
* single column, so grids collapse on mobile regardless of the JIT. */
.coffee-minh-50 { min-height: 50vh; }
.coffee-minh-75 { min-height: 75vh; }
.coffee-minh-screen { min-height: 100vh; }
.coffee-minh-400 { min-height: 400px; }
.coffee-minh-320 { min-height: 320px; }
.coffee-split-2 { display: grid; grid-template-columns: 1fr; }
.coffee-pour-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
.coffee-menu-grid { display: grid; grid-template-columns: 1fr; gap: 2.5rem 3rem; }
.coffee-info-grid { display: grid; grid-template-columns: 1fr; gap: 2.5rem; }
@media (min-width: 768px) {
.coffee-pour-grid { grid-template-columns: 2fr 3fr; align-items: center; }
.coffee-menu-grid.coffee-menu-grid--2 { grid-template-columns: 1fr 1fr; }
.coffee-info-grid.coffee-info-grid--2 { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 1024px) {
.coffee-split-2 { grid-template-columns: 1fr 1fr; }
}
/* --- Price + spec chips ------------------------------------------------- */
.coffee-price { .coffee-price {
font-family: var(--font-mono, var(--coffee-mono-fallback)); font-family: var(--font-mono, var(--coffee-mono-fallback));
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: hsl(var(--accent)); 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;
}
/* --- Doodle pin overlay for location card ------------------------------- */ /* --- 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 { .coffee-pin {
color: hsl(var(--accent)); 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

@ -2,12 +2,11 @@
{ {
"key": "coffee:default-master", "key": "coffee:default-master",
"title": "Coffee Default Master", "title": "Coffee Default Master",
"page_templates": ["default", "article"], "page_templates": ["default", "article", "full-width", "contact"],
"blocks": [ "blocks": [
{ "block_key": "navbar", "title": "Main Navigation", "content": { "menuName": "main" }, "slot": "header", "sort_order": 0 }, { "block_key": "navbar", "title": "Main Navigation", "content": { "menuName": "main", "companyName": "Ritual Coffee" }, "slot": "header", "sort_order": 0 },
{ "block_key": "coffee:hours_strip", "title": "Hours Strip", "content": { "todayLabel": "Today" }, "slot": "header", "sort_order": 1 },
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main", "placeholder": "Pour something in here" }, "slot": "main", "sort_order": 0 }, { "block_key": "slot", "title": "Main Content", "content": { "slotName": "main", "placeholder": "Pour something in here" }, "slot": "main", "sort_order": 0 },
{ "block_key": "coffee:footer", "title": "Site Footer", "content": { "showLocation": true }, "slot": "footer", "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 }
] ]
}, },
{ {
@ -15,12 +14,32 @@
"title": "Coffee Landing Master", "title": "Coffee Landing Master",
"page_templates": ["landing"], "page_templates": ["landing"],
"blocks": [ "blocks": [
{ "block_key": "navbar", "title": "Main Navigation", "content": { "menuName": "main" }, "slot": "header", "sort_order": 0 }, { "block_key": "navbar", "title": "Main Navigation", "content": { "menuName": "main", "companyName": "Ritual Coffee" }, "slot": "hero", "sort_order": 0 },
{ "block_key": "slot", "title": "Hero Slot", "content": { "slotName": "hero", "placeholder": "Drop a hero block" }, "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": "coffee:featured_pour", "title": "Featured Pour", "content": {}, "slot": "menu", "sort_order": 0 }, { "block_key": "slot", "title": "Menu", "content": { "slotName": "menu", "placeholder": "Feature the menu" }, "slot": "menu", "sort_order": 0 },
{ "block_key": "slot", "title": "Story Slot", "content": { "slotName": "story", "placeholder": "Tell the story" }, "slot": "story", "sort_order": 0 }, { "block_key": "slot", "title": "Story", "content": { "slotName": "story", "placeholder": "Tell the story" }, "slot": "story", "sort_order": 0 },
{ "block_key": "coffee:location_card", "title": "Location Card", "content": {}, "slot": "cta", "sort_order": 0 }, { "block_key": "slot", "title": "Call To Action", "content": { "slotName": "cta", "placeholder": "Invite them in" }, "slot": "cta", "sort_order": 0 },
{ "block_key": "coffee:footer", "title": "Site Footer", "content": { "showLocation": true }, "slot": "footer", "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 }
] ]
} }
] ]

View File

@ -2,7 +2,7 @@
name = "coffee" name = "coffee"
display_name = "Coffee" display_name = "Coffee"
scope = "@themes" scope = "@themes"
version = "0.2.0" version = "0.2.2"
description = "Warm, hand-crafted theme for cafes, bakeries, and slow-craft makers — kraft paper, doodle illustrations, and roasted browns." description = "Warm, hand-crafted theme for cafes, bakeries, and slow-craft makers — kraft paper, doodle illustrations, and roasted browns."
kind = "theme" kind = "theme"
categories = ["templates"] categories = ["templates"]

View File

@ -2,9 +2,9 @@
{ {
"id": "morning-pour", "id": "morning-pour",
"name": "Morning Pour", "name": "Morning Pour",
"description": "Cream paper, espresso ink, terracotta accent", "description": "Cream paper and espresso ink with a terracotta accent. Daylight cafe.",
"theme": { "theme": {
"mode": "light", "mode": "both",
"lightColors": { "lightColors": {
"background": "36 30% 96%", "background": "36 30% 96%",
"foreground": "25 35% 18%", "foreground": "25 35% 18%",
@ -52,9 +52,9 @@
{ {
"id": "dark-roast", "id": "dark-roast",
"name": "Dark Roast", "name": "Dark Roast",
"description": "Espresso-deep base with copper accent for evening cafes and roasteries", "description": "Espresso-deep base with a copper accent. Evening roastery.",
"theme": { "theme": {
"mode": "dark", "mode": "both",
"lightColors": { "lightColors": {
"background": "36 30% 96%", "background": "36 30% 96%",
"foreground": "25 35% 18%", "foreground": "25 35% 18%",
@ -102,9 +102,9 @@
{ {
"id": "kraft-cream", "id": "kraft-cream",
"name": "Kraft Cream", "name": "Kraft Cream",
"description": "Warmer ivory paper with deeper terracotta accent", "description": "Warmer ivory paper with a deeper terracotta accent. Bakery counter.",
"theme": { "theme": {
"mode": "light", "mode": "both",
"lightColors": { "lightColors": {
"background": "32 35% 92%", "background": "32 35% 92%",
"foreground": "22 40% 16%", "foreground": "22 40% 16%",
@ -148,5 +148,105 @@
"ring": "16 50% 55%" "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",
"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",
"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%"
}
}
} }
] ]

177
seed/seed.json Normal file
View File

@ -0,0 +1,177 @@
{
"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>" } }
]
}
]
}

25
seed/workflows.json Normal file
View File

@ -0,0 +1,25 @@
{
"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"
}
}
]
}
]
}

View File

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }} {{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col"> <body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }} {{ admin_banner_html|safe }}

View File

@ -0,0 +1,19 @@
<!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

@ -0,0 +1,15 @@
<!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

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

View File

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }} {{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col"> <body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }} {{ admin_banner_html|safe }}

View File

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }} {{ head_html|safe }}
<body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col"> <body class="coffee-paper coffee-body bg-background text-foreground antialiased min-h-screen flex flex-col">
{{ admin_banner_html|safe }} {{ admin_banner_html|safe }}

View File

@ -0,0 +1,21 @@
{# 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

@ -0,0 +1,15 @@
<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 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 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

@ -0,0 +1,32 @@
{# 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

@ -0,0 +1,25 @@
{# 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 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

@ -0,0 +1,20 @@
{% 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 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 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 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 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

@ -0,0 +1,12 @@
{% 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

@ -0,0 +1,22 @@
<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

@ -0,0 +1,12 @@
{% 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 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 +1,5 @@
{% if url %}<a href="{{ url }}" class="kraft-tag {% if variant == "secondary" %}bg-secondary text-secondary-foreground{% elif variant == "destructive" %}bg-destructive text-destructive-foreground{% endif %}">{{ text }}</a>{% else %}<button type="button" class="kraft-tag {% if variant == "secondary" %}bg-secondary text-secondary-foreground{% elif variant == "destructive" %}bg-destructive text-destructive-foreground{% endif %}">{{ text }}</button>{% endif %} {% 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 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 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 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 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

@ -0,0 +1,12 @@
{% 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 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

@ -0,0 +1,17 @@
{% 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

@ -0,0 +1,17 @@
<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 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

@ -0,0 +1,39 @@
{% 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 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 %}

View File

@ -0,0 +1,42 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-4xl px-4 text-center">
{% if heading %}<h2 class="coffee-display text-3xl text-primary md:text-4xl">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="coffee-body mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<div class="mt-10" data-countdown>
<div class="flex items-start justify-center gap-3 sm:gap-6" data-countdown-timer data-target="{{ target }}" role="timer" aria-label="Time remaining">
<div class="flex flex-col items-center {% if variant == "boxes" %}coffee-card min-w-[4.5rem] px-3 py-4{% endif %}"><span class="coffee-mono text-4xl font-bold tabular-nums text-accent sm:text-5xl" data-unit="days">--</span><span class="coffee-hand mt-1 text-lg text-muted-foreground">{{ labelDays|default:"Days" }}</span></div>
<div class="flex flex-col items-center {% if variant == "boxes" %}coffee-card min-w-[4.5rem] px-3 py-4{% endif %}"><span class="coffee-mono text-4xl font-bold tabular-nums text-accent sm:text-5xl" data-unit="hours">--</span><span class="coffee-hand mt-1 text-lg text-muted-foreground">{{ labelHours|default:"Hours" }}</span></div>
<div class="flex flex-col items-center {% if variant == "boxes" %}coffee-card min-w-[4.5rem] px-3 py-4{% endif %}"><span class="coffee-mono text-4xl font-bold tabular-nums text-accent sm:text-5xl" data-unit="minutes">--</span><span class="coffee-hand mt-1 text-lg text-muted-foreground">{{ labelMinutes|default:"Minutes" }}</span></div>
<div class="flex flex-col items-center {% if variant == "boxes" %}coffee-card min-w-[4.5rem] px-3 py-4{% endif %}"><span class="coffee-mono text-4xl font-bold tabular-nums text-accent sm:text-5xl" data-unit="seconds">--</span><span class="coffee-hand mt-1 text-lg text-muted-foreground">{{ labelSeconds|default:"Seconds" }}</span></div>
</div>
<div class="mt-8 hidden" data-countdown-expired>
<p class="coffee-display text-xl text-primary">{{ expiredMessage|default:"This has started." }}</p>
{% if expiredUrl and expiredLabel %}<a href="{{ expiredUrl }}" class="kraft-tag mt-4 text-base px-5 py-3">{{ expiredLabel }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
</div>
</div>
<noscript><p class="coffee-body mt-6 text-muted-foreground">Counting down to <time datetime="{{ target }}">{{ target }}</time>.</p></noscript>
</div>
</section>
<script>
(function(){
document.querySelectorAll('[data-countdown-timer]').forEach(function(el){
var target = new Date(el.getAttribute('data-target')).getTime();
if (isNaN(target)) return;
var wrap = el.closest('[data-countdown]');
var expired = wrap ? wrap.querySelector('[data-countdown-expired]') : null;
function pad(n){ return (n < 10 ? '0' : '') + n; }
function set(unit, value){ var node = el.querySelector('[data-unit="' + unit + '"]'); if (node) node.textContent = value; }
var interval;
function tick(){
var diff = target - Date.now();
if (diff <= 0){ el.style.display = 'none'; if (expired) expired.classList.remove('hidden'); if (interval) clearInterval(interval); return; }
set('days', Math.floor(diff / 86400000));
set('hours', pad(Math.floor(diff % 86400000 / 3600000)));
set('minutes', pad(Math.floor(diff % 3600000 / 60000)));
set('seconds', pad(Math.floor(diff % 60000 / 1000)));
}
tick();
interval = setInterval(tick, 1000);
});
})();
</script>

View File

@ -0,0 +1,14 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
<div class="{% if background == "band" %}coffee-torn-top coffee-torn-bottom rounded-2xl bg-primary px-6 py-14 text-center text-primary-foreground md:px-12{% elif background == "card" %}coffee-card px-6 py-14 text-center md:px-12{% else %}flex flex-col items-center gap-8 rounded-2xl bg-muted px-6 py-12 text-foreground md:flex-row md:justify-between md:px-12 md:text-left{% endif %}">
<div class="{% if background != "split" %}mx-auto max-w-2xl{% endif %}">
{% if heading %}<h2 class="coffee-display text-3xl md:text-4xl {% if background == "band" %}text-primary-foreground{% else %}text-primary{% endif %}">{{ heading }}</h2>{% endif %}
{% if text %}<p class="coffee-body mt-3 text-lg {% if background == "band" %}text-primary-foreground/80{% else %}text-muted-foreground{% endif %}">{{ text }}</p>{% endif %}
</div>
<div class="flex flex-wrap items-center justify-center gap-3 {% if background != "split" %}mt-8{% else %}shrink-0{% endif %}">
{% if primaryUrl and primaryLabel %}<a href="{{ primaryUrl }}" class="kraft-tag text-base px-6 py-3 {% if background == "band" %}bg-background text-foreground{% endif %}">{{ primaryLabel }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
{% if secondaryUrl and secondaryLabel %}<a href="{{ secondaryUrl }}" class="kraft-tag kraft-tag--ghost text-base px-6 py-3 {% if background == "band" %}border-primary-foreground/40 text-primary-foreground{% endif %}">{{ secondaryLabel }}</a>{% endif %}
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,17 @@
{% with variant=variant|default:"line" width=width|default:"full" %}
<div class="bn-divider coffee-doodle-draw mx-auto my-8 flex items-center justify-center {% if width == "narrow" %}max-w-[200px]{% elif width == "wide" %}max-w-3xl{% else %}w-full{% endif %}{% if class %} {{ class }}{% endif %}" role="separator">
{% if variant == "ornament" or variant == "spacer-rule" %}
<span class="h-px flex-1 bg-border"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="26" viewBox="0 0 72 26" fill="none" stroke="hsl(var(--accent))" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="mx-4 shrink-0" aria-hidden="true">
<ellipse cx="30" cy="13" rx="6" ry="9"></ellipse><path d="M30 4 L30 22"></path>
<ellipse cx="42" cy="13" rx="6" ry="9"></ellipse><path d="M42 4 L42 22"></path>
<path d="M4 13 L18 13"></path><path d="M54 13 L68 13"></path>
</svg>
<span class="h-px flex-1 bg-border"></span>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 12" preserveAspectRatio="none" class="h-3 w-full" fill="none" stroke="hsl(var(--border))" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M2 7 Q 40 2 80 7 T 160 6 T 240 7 T 298 6"></path>
</svg>
{% endif %}
</div>
{% endwith %}

View File

@ -0,0 +1,40 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl 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 intro %}<p class="coffee-body mt-3 max-w-2xl text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
{% if layout == "cards" %}
<div class="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{% for event in events %}{% if not (hidePast and event.past) %}
<article class="coffee-card flex flex-col overflow-hidden{% if event.past %} opacity-60{% endif %}">
{% if event.media %}<div class="aspect-video overflow-hidden bg-secondary">{% img event.media alt=event.title class="h-full w-full object-cover" %}</div>{% endif %}
<div class="flex flex-1 flex-col gap-2 p-5">
<div class="coffee-hand text-lg text-accent"><time{% if event.datetime %} datetime="{{ event.datetime }}"{% endif %}>{{ event.start }}{% if event.end %} &ndash; {{ event.end }}{% endif %}</time>{% if event.timezone %} <span class="text-muted-foreground">{{ event.timezone }}</span>{% endif %}</div>
<h3 class="coffee-display text-lg text-primary">{{ event.title }}</h3>
{% if event.venue %}<p class="coffee-body text-sm text-muted-foreground">{{ event.venue }}</p>{% endif %}
{% if event.description %}<p class="coffee-body text-sm text-muted-foreground">{{ event.description }}</p>{% endif %}
{% if event.link %}<a href="{{ event.link }}" class="coffee-hand mt-auto inline-flex items-center gap-1 pt-2 text-lg text-accent hover:underline">{{ event.linkLabel|default:"Details" }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
</div>
</article>
{% endif %}{% endfor %}
</div>
{% else %}
<ul class="mt-10 divide-y divide-dashed divide-border border-y border-dashed border-border">
{% for event in events %}{% if not (hidePast and event.past) %}
<li class="flex flex-col gap-3 py-5 sm:flex-row sm:items-center sm:gap-6{% if event.past %} opacity-60{% endif %}">
<div class="shrink-0 sm:w-52">
<div class="coffee-display text-sm text-primary"><time{% if event.datetime %} datetime="{{ event.datetime }}"{% endif %}>{{ event.start }}</time></div>
{% if event.end %}<div class="coffee-body text-xs text-muted-foreground">to {{ event.end }}</div>{% endif %}
{% if event.timezone %}<div class="coffee-body text-xs text-muted-foreground">{{ event.timezone }}</div>{% endif %}
</div>
<div class="min-w-0 flex-1">
<h3 class="coffee-display text-base text-primary">{{ event.title }}</h3>
{% if event.venue %}<p class="coffee-body text-sm text-muted-foreground">{{ event.venue }}</p>{% endif %}
{% if event.description %}<p class="coffee-body mt-1 text-sm text-muted-foreground">{{ event.description }}</p>{% endif %}
</div>
{% if event.link %}<a href="{{ event.link }}" class="kraft-tag kraft-tag--ghost shrink-0 text-xs">{{ event.linkLabel|default:"Details" }}</a>{% endif %}
</li>
{% endif %}{% endfor %}
</ul>
{% endif %}
</div>
</section>

View File

@ -0,0 +1,17 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-3xl px-4">
{% if heading %}<h2 class="coffee-display text-center text-3xl text-primary md:text-4xl">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="coffee-body mx-auto mt-4 max-w-2xl text-center text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<div class="mt-10 divide-y divide-dashed divide-border border-y border-dashed border-border">
{% for item in items %}
<details class="group py-2"{% if variant == "open-list" %} open{% endif %}>
<summary class="coffee-display flex cursor-pointer list-none items-center justify-between gap-4 py-3 text-lg text-primary marker:content-none [&::-webkit-details-marker]:hidden">
<span>{{ item.question }}</span>
<svg class="h-5 w-5 shrink-0 text-accent transition-transform duration-200 group-open:rotate-180" aria-hidden="true"><use href="/icons/lucide.svg#chevron-down"></use></svg>
</summary>
<div class="coffee-body pb-4 text-muted-foreground [&_a]:text-accent [&_a]:underline [&_a]:decoration-dashed">{{ item.answer|safe }}</div>
</details>
{% endfor %}
</div>
</div>
</section>

View File

@ -0,0 +1,17 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if eyebrow %}<p class="coffee-hand text-center text-2xl text-accent">{{ eyebrow }}</p>{% endif %}
{% if heading %}<h2 class="coffee-display mt-1 text-center text-3xl text-primary md:text-4xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
{% if intro %}<p class="coffee-body mx-auto mt-4 max-w-2xl text-center text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<div class="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% elif columns == 4 %}lg:grid-cols-4{% else %}lg:grid-cols-3{% endif %}">
{% for item in items %}
<div class="{% if layout == "card" %}coffee-card p-6 transition-shadow hover:shadow-md{% elif layout == "centered" %}text-center{% endif %}">
{% if item.icon %}<div class="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-full bg-accent/15 text-accent{% if layout == "centered" %} mx-auto{% endif %}"><svg class="h-6 w-6" aria-hidden="true"><use href="/icons/{{ item.icon|split:":"|first }}.svg#{{ item.icon|split:":"|last }}"></use></svg></div>{% endif %}
<h3 class="coffee-display text-xl text-primary">{% if item.linkUrl %}<a href="{{ item.linkUrl }}" class="transition-colors hover:text-accent">{{ item.title }}</a>{% else %}{{ item.title }}{% endif %}</h3>
{% if item.text %}<p class="coffee-body mt-2 text-muted-foreground">{{ item.text }}</p>{% endif %}
{% if item.linkUrl and item.linkLabel %}<a href="{{ item.linkUrl }}" class="coffee-hand mt-3 inline-flex items-center gap-1 text-lg text-accent hover:underline">{{ item.linkLabel }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>

View File

@ -0,0 +1,21 @@
<section class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="coffee-display mb-8 text-2xl text-primary md:text-3xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% elif columns == 4 %}lg:grid-cols-4{% else %}lg:grid-cols-3{% endif %}">
{% for post in posts %}
<article class="coffee-card group flex flex-col overflow-hidden transition-shadow hover:shadow-md">
{% if showFeaturedImage and post.featured_image_url %}<a href="{{ post.url }}" class="block aspect-video overflow-hidden bg-secondary">{% img post.featured_image_url alt=post.title class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
<div class="flex flex-1 flex-col p-5">
<h3 class="coffee-display text-lg text-primary"><a href="{{ post.url }}" class="transition-colors hover:text-accent">{{ post.title }}</a></h3>
{% if showExcerpt and post.excerpt %}<p class="coffee-body mt-2 flex-1 text-sm text-muted-foreground">{{ post.excerpt }}</p>{% endif %}
<div class="coffee-hand mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-base text-accent">
{% if showAuthor and post.author_name %}<span>{{ post.author_name }}</span>{% endif %}
{% if showDate and post.published_at %}<span class="text-muted-foreground">{{ post.published_at|date:"M j, Y" }}</span>{% endif %}
{% if post.reading_time %}<span class="text-muted-foreground">{{ post.reading_time }} min</span>{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</section>

View File

@ -0,0 +1,38 @@
{# coffee footer — torn-paper top edge, kraft chrome, builtin footer_menus provider. #}
{% set fl = layout %}
{% if not fl %}{% if template == "centered" %}{% set fl = "centered" %}{% else %}{% set fl = "columns" %}{% endif %}{% endif %}
{% macro sociallinks(cls) %}{% if social_items %}<div class="flex items-center gap-3 {{ cls }}">{% for s in social_items %}<a href="{{ s.url }}" target="_blank" rel="noopener" title="{{ s.label }}" class="text-muted-foreground transition-colors hover:text-accent"><span class="sr-only">{{ s.label }}</span>{% if s.icon %}<svg class="h-5 w-5" aria-hidden="true"><use href="/icons/{{ s.icon|split:":"|first }}.svg#{{ s.icon|split:":"|last }}"></use></svg>{% else %}{{ s.label }}{% endif %}</a>{% endfor %}</div>{% endif %}{% endmacro %}
{% macro newsletter() %}{% if showNewsletter %}<div class="max-w-sm"><h3 class="coffee-hand text-2xl text-accent">{{ newsletterTitle|default:"Stay in the loop" }}</h3>{% if newsletterDescription %}<p class="coffee-body mt-1 text-sm text-muted-foreground">{{ newsletterDescription }}</p>{% endif %}<div class="mt-3">{% signup_form list=newsletterListSlug|default:"main" source="footer" submit=newsletterButtonText|default:"Subscribe" %}</div></div>{% endif %}{% endmacro %}
{% macro legalbar() %}<div class="mt-10 flex flex-col items-center justify-between gap-3 border-t border-dashed border-border pt-6 text-sm text-muted-foreground sm:flex-row"><p class="coffee-body">{% if copyright %}&copy; {{ copyright }}{% else %}&copy; {{ companyName }}{% endif %}</p>{% if legal_items %}<nav class="flex flex-wrap items-center gap-x-4 gap-y-1">{% for l in legal_items %}<a href="{{ l.url|default:'#' }}"{% if l.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="coffee-body transition-colors hover:text-foreground">{{ l.label }}</a>{% endfor %}</nav>{% endif %}</div>{% endmacro %}
<footer class="coffee-torn-top w-full bg-muted/50 text-foreground {{ class }}">
<div class="{% if fullWidth %}w-full px-4 sm:px-6 lg:px-8{% else %}mx-auto max-w-7xl px-4 sm:px-6 lg:px-8{% endif %} py-16">
{% if fl == "minimal" %}
<div class="flex flex-col items-center justify-between gap-6 sm:flex-row">
<div class="flex items-center gap-3">{% if logo %}<img src="{{ logo }}" alt="{{ companyName }}" class="h-8 w-auto"/>{% elif companyName %}<span class="coffee-display text-xl text-primary">{{ companyName }}</span>{% endif %}{% if tagline %}<span class="coffee-hand hidden text-lg text-accent sm:inline">{{ tagline }}</span>{% endif %}</div>
{{ sociallinks("") }}
</div>
{{ legalbar() }}
{% elif fl == "centered" %}
<div class="flex flex-col items-center gap-5 text-center">
{% if logo %}<img src="{{ logo }}" alt="{{ companyName }}" class="h-10 w-auto"/>{% elif companyName %}<span class="coffee-display text-2xl text-primary">{{ companyName }}</span>{% endif %}
{% if tagline %}<p class="coffee-hand max-w-md text-xl text-accent">{{ tagline }}</p>{% endif %}
{% if columns %}<nav class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">{% for col in columns %}{% for item in col.items %}<a href="{{ item.url|default:'#' }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="coffee-body text-sm text-muted-foreground transition-colors hover:text-foreground">{{ item.label }}</a>{% endfor %}{% endfor %}</nav>{% endif %}
{{ newsletter() }}
{{ sociallinks("justify-center") }}
</div>
{{ legalbar() }}
{% else %}
<div class="grid grid-cols-2 gap-8 md:grid-cols-4 lg:grid-cols-5">
<div class="col-span-2 lg:col-span-2">
{% if logo %}<img src="{{ logo }}" alt="{{ companyName }}" class="h-9 w-auto"/>{% elif companyName %}<span class="coffee-display text-2xl text-primary">{{ companyName }}</span>{% endif %}
{% if tagline %}<p class="coffee-hand mt-2 max-w-xs text-xl text-accent">{{ tagline }}</p>{% endif %}
{% if address or email or phone %}<address class="coffee-body mt-4 space-y-1 text-sm not-italic text-muted-foreground">{% if address %}<div>{{ address }}</div>{% endif %}{% if email %}<div><a href="mailto:{{ email }}" class="transition-colors hover:text-foreground">{{ email }}</a></div>{% endif %}{% if phone %}<div><a href="tel:{{ phone }}" class="transition-colors hover:text-foreground">{{ phone }}</a></div>{% endif %}</address>{% endif %}
{{ sociallinks("mt-5") }}
</div>
{% for col in columns %}<div><h3 class="coffee-hand text-xl text-accent">{{ col.header }}</h3><ul class="mt-3 space-y-2">{% for item in col.items %}<li><a href="{{ item.url|default:'#' }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="coffee-body text-sm text-muted-foreground transition-colors hover:text-foreground">{{ item.label }}</a></li>{% endfor %}</ul></div>{% endfor %}
{% if showNewsletter %}<div class="col-span-2 lg:col-span-1">{{ newsletter() }}</div>{% endif %}
</div>
{{ legalbar() }}
{% endif %}
</div>
</footer>

View File

@ -0,0 +1,9 @@
{% with layout=layout|default:"grid" gap=gap|default:"md" cols=columns|default:3 %}
<div class="bn-gallery my-8{% if class %} {{ class }}{% endif %}" data-bn-gallery data-lightbox="{% if lightbox %}true{% else %}false{% endif %}">
<div class="{% if layout == "carousel" %}flex snap-x snap-mandatory overflow-x-auto pb-2 {% if gap == "sm" %}gap-2{% elif gap == "lg" %}gap-6{% else %}gap-4{% endif %}{% elif layout == "masonry" %}columns-2 {% if cols == 4 %}md:columns-4{% elif cols == 2 %}md:columns-2{% else %}md:columns-3{% endif %} {% if gap == "sm" %}gap-2{% elif gap == "lg" %}gap-6{% else %}gap-4{% endif %}{% else %}grid grid-cols-2 {% if cols == 4 %}md:grid-cols-4{% elif cols == 2 %}md:grid-cols-2{% else %}md:grid-cols-3{% endif %} {% if gap == "sm" %}gap-2{% elif gap == "lg" %}gap-6{% else %}gap-4{% endif %}{% endif %}">
{% for image in images %}
<a href="{{ image.src|mediaURL }}" data-bn-gallery-item data-caption="{{ image.caption|default:"" }}" class="group relative block overflow-hidden rounded-sm coffee-frame p-1.5 bg-card focus:outline-none focus-visible:ring-2 focus-visible:ring-ring {% if layout == "carousel" %}w-64 shrink-0 snap-start sm:w-80{% elif layout == "masonry" %}mb-4 w-full break-inside-avoid{% endif %}">{% if layout == "grid" %}{% img image.src alt=image.alt|default:"" class="aspect-square w-full rounded-sm object-cover" %}{% else %}{% img image.src alt=image.alt|default:"" class="h-auto w-full rounded-sm" %}{% endif %}{% if image.caption %}<span class="sr-only">{{ image.caption }}</span>{% endif %}</a>
{% endfor %}
</div>
</div>
{% endwith %}

View File

@ -1 +1,6 @@
{% if level|integer == 1 %}<h1 class="coffee-display text-5xl leading-tight text-primary {{ textClass }}">{{ text }}</h1>{% elif level|integer == 3 %}<h3 class="coffee-display text-2xl text-primary {{ textClass }}"><span class="coffee-doodle-underline">{{ text }}</span></h3>{% elif level|integer == 4 %}<h4 class="coffee-display text-xl text-primary {{ textClass }}">{{ text }}</h4>{% elif level|integer == 5 %}<h5 class="coffee-display text-lg text-primary {{ textClass }}">{{ text }}</h5>{% elif level|integer == 6 %}<h6 class="coffee-display text-base text-primary {{ textClass }}">{{ text }}</h6>{% else %}<h2 class="coffee-display text-3xl italic text-primary {{ textClass }}"><span class="coffee-doodle-underline">{{ text }}</span></h2>{% endif %} {% if level == 1 %}<h1 class="coffee-display text-4xl md:text-5xl leading-tight text-primary flex-1 {{ textClass }}">{{ text }}</h1>
{% elif level == 3 %}<h3 class="coffee-display text-2xl text-primary flex-1 {{ textClass }}"><span class="coffee-doodle-underline">{{ text }}</span></h3>
{% elif level == 4 %}<h4 class="coffee-display text-xl text-primary flex-1 {{ textClass }}">{{ text }}</h4>
{% elif level == 5 %}<h5 class="coffee-display text-lg font-medium text-primary flex-1 {{ textClass }}">{{ text }}</h5>
{% elif level == 6 %}<h6 class="coffee-hand text-xl text-accent flex-1 {{ textClass }}">{{ text }}</h6>
{% else %}<h2 class="coffee-display text-3xl md:text-4xl text-primary flex-1 {{ textClass }}"><span class="coffee-doodle-underline">{{ text }}</span></h2>{% endif %}

View File

@ -0,0 +1,36 @@
{# coffee hero — paper chrome; styled-text fields accept {text} object OR bare string. #}
{% set eyebrowText = eyebrow.text|default:eyebrow %}
{% set headlineText = headline.text|default:headline %}
{% set subText = subheadline.text|default:subheadline %}
{% set descText = description.text|default:description %}
{% set pText = ctaPrimary.text %}
{% set pUrl = ctaPrimary.url %}
{% set sText = ctaSecondary.text %}
{% set sUrl = ctaSecondary.url %}
{% set variant = template|default:"centered" %}
{% set hasImage = backgroundImage %}
{% set showOverlay = hasImage and overlayColor != "none" and overlayColor != "" %}
{% macro herocontent(align) %}
{% if eyebrowText %}<span class="coffee-hand mb-2 inline-block text-2xl text-accent">{{ eyebrowText }}</span>{% endif %}
{% if headlineText %}<h1 class="coffee-display text-4xl leading-tight text-primary sm:text-5xl lg:text-6xl">{{ headlineText }}</h1>{% endif %}
{% if subText %}<h2 class="coffee-body mt-4 max-w-2xl text-xl text-muted-foreground sm:text-2xl{% if align == "center" %} mx-auto{% endif %}">{{ subText }}</h2>{% endif %}
{% if descText %}<p class="coffee-body mt-4 max-w-xl leading-relaxed text-muted-foreground{% if align == "center" %} mx-auto{% endif %}">{{ descText }}</p>{% endif %}
{% if pText or sText %}<div class="mt-8 flex flex-col gap-4 sm:flex-row{% if align == "center" %} justify-center{% endif %}">
{% if pText and pUrl %}<a href="{{ pUrl }}" class="kraft-tag text-base px-6 py-3">{{ pText }}</a>{% endif %}
{% if sText and sUrl %}<a href="{{ sUrl }}" class="kraft-tag kraft-tag--ghost text-base px-6 py-3">{{ sText }}</a>{% endif %}
</div>{% endif %}
{% if trustLogos %}<div class="mt-10"><p class="coffee-hand mb-3 text-lg text-accent">Regulars love</p><div class="flex flex-wrap items-center gap-6{% if align == "center" %} justify-center{% endif %}">{% for logo in trustLogos %}{% if logo.url %}<a href="{{ logo.url }}" target="_blank" rel="noopener" class="opacity-60 transition-opacity hover:opacity-100">{% img logo.image alt=logo.alt class="h-8 w-auto" %}</a>{% else %}{% img logo.image alt=logo.alt class="h-8 w-auto opacity-60" %}{% endif %}{% endfor %}</div></div>{% endif %}
{% endmacro %}
{% if variant == "split" %}
<section class="hero coffee-split-2 overflow-hidden bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="coffee-minh-320 relative bg-secondary {% if mediaPosition == "left" %}order-1 lg:order-1{% else %}order-1 lg:order-2{% endif %}">{% if hasImage %}{% img backgroundImage alt=headlineText class="absolute inset-0 h-full w-full object-cover" layout="hero" %}{% endif %}</div>
<div class="flex flex-col justify-center px-6 py-16 lg:px-12 {% if mediaPosition == "left" %}order-2 lg:order-2{% else %}order-2 lg:order-1{% endif %}"><div class="max-w-lg text-left">{{ herocontent("left") }}</div></div>
</section>
{% else %}
{% set align = "center" %}
{% if variant == "left-aligned" %}{% set align = "left" %}{% endif %}
<section class="hero relative flex flex-col overflow-hidden bg-background text-foreground {% if minHeight == "screen" %}coffee-minh-screen{% elif minHeight == "50vh" %}coffee-minh-50{% elif minHeight == "auto" %}coffee-minh-400{% else %}coffee-minh-75{% endif %} {% if verticalAlign == "top" %}justify-start pt-20{% elif verticalAlign == "bottom" %}justify-end pb-20{% else %}justify-center{% endif %}{% if class %} {{ class }}{% endif %}">
{% if hasImage %}<div class="absolute inset-0 z-0">{% img backgroundImage alt=headlineText class="h-full w-full object-cover" layout="hero" %}{% if showOverlay %}<div class="absolute inset-0 {% if overlayColor == "light" %}bg-background/60{% elif overlayColor == "primary" %}bg-primary/50{% elif overlayOpacity <= 30 %}bg-black/30{% elif overlayOpacity <= 50 %}bg-black/50{% elif overlayOpacity <= 70 %}bg-black/70{% else %}bg-black/80{% endif %}"></div>{% endif %}</div>{% endif %}
<div class="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 lg:px-8 {% if variant == "left-aligned" %}text-left{% else %}text-center{% endif %}">{{ herocontent(align) }}</div>
</section>
{% endif %}

View File

@ -0,0 +1,40 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-5xl px-4">
{% if heading %}<h2 class="coffee-display text-3xl text-primary md:text-4xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
<div class="coffee-info-grid{% if mapEmbedUrl %} coffee-info-grid--2{% endif %} mt-10">
<div>
{% if hours %}
<table class="coffee-body w-full text-sm" data-coffee-hours>
<tbody class="divide-y divide-dashed divide-border">
{% for row in hours %}
<tr data-day="{{ row.day }}">
<th scope="row" class="coffee-display py-2 pr-4 text-left text-base text-primary">{{ row.day }}</th>
<td class="py-2 text-right {% if row.closed %}text-muted-foreground{% else %}text-foreground{% endif %} tabular-nums">{% if row.closed %}Closed{% else %}{{ row.hours }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if specialHoursNote %}<p class="coffee-body mt-4 rounded-md border border-dashed border-border bg-muted px-3 py-2 text-sm text-muted-foreground">{{ specialHoursNote }}</p>{% endif %}
{% if address %}<div class="mt-6"><div class="coffee-hand text-lg text-accent">Find us</div><address class="coffee-body mt-1 whitespace-pre-line not-italic">{{ address }}</address></div>{% endif %}
{% if phone %}<div class="mt-4"><div class="coffee-hand text-lg text-accent">Call ahead</div><a href="tel:{{ phone }}" class="coffee-body mt-1 inline-block font-medium transition-colors hover:text-accent">{{ phone }}</a></div>{% endif %}
{% if directionsUrl %}<a href="{{ directionsUrl }}" target="_blank" rel="noopener" class="kraft-tag mt-6 text-sm"><svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#navigation"></use></svg>Get directions</a>{% endif %}
</div>
{% if mapEmbedUrl %}
<div class="coffee-frame aspect-square w-full overflow-hidden rounded-sm bg-secondary md:aspect-auto">
<button type="button" data-map-src="{{ mapEmbedUrl }}" onclick="var f=document.createElement('iframe');f.src=this.getAttribute('data-map-src');f.setAttribute('class','h-full w-full border-0');f.setAttribute('loading','lazy');f.setAttribute('referrerpolicy','no-referrer-when-downgrade');f.setAttribute('title','Map');this.parentNode.replaceChild(f,this);" class="coffee-body flex h-full min-h-[16rem] w-full items-center justify-center gap-2 text-sm font-medium transition-colors hover:bg-muted"><svg class="h-5 w-5 coffee-pin" aria-hidden="true"><use href="/icons/lucide.svg#map-pin"></use></svg>{{ mapButtonLabel|default:"Load map" }}</button>
</div>
{% endif %}
</div>
</div>
</section>
<script>
(function(){
var days=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
var today=days[new Date().getDay()];
document.querySelectorAll('[data-coffee-hours] tr[data-day]').forEach(function(tr){
var d=(tr.getAttribute('data-day')||'').trim();
if(d.toLowerCase().indexOf(today.toLowerCase())===0){tr.classList.add('coffee-hours-today');}
});
})();
</script>

View File

@ -1,6 +1,8 @@
<figure class="my-8 flex flex-col items-center coffee-body"> {% with size=size|default:"full" align=align|default:"center" %}
<div class="coffee-frame p-2 bg-card rounded-sm relative coffee-torn-bottom"> <figure class="my-8 flex flex-1 flex-col {% if align == "left" %}items-start{% elif align == "right" %}items-end{% else %}items-center{% endif %}{% if class %} {{ class }}{% endif %}">
{% 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>{% endif %} <div class="coffee-frame coffee-torn-bottom relative overflow-hidden p-2 {% if size == "xs" %}max-w-[200px] w-full{% elif size == "sm" %}max-w-[400px] w-full{% elif size == "md" %}max-w-[600px] w-full{% elif size == "lg" %}max-w-[800px] w-full{% else %}w-full{% endif %}">
{% if src %}{% img src alt=alt class="block h-auto w-full rounded-sm object-cover" layout=size %}{% else %}<div class="flex h-48 w-full items-center justify-center bg-secondary text-primary/20"><svg class="h-12 w-12" aria-hidden="true"><use href="/icons/lucide.svg#image"></use></svg></div>{% endif %}
</div> </div>
{% if caption %}<figcaption class="coffee-display italic text-center text-sm text-muted-foreground mt-3">{{ caption }}</figcaption>{% endif %} {% if caption %}<figcaption class="coffee-hand mt-3 text-center text-xl text-accent">{{ caption }}</figcaption>{% endif %}
</figure> </figure>
{% endwith %}

View File

@ -0,0 +1,15 @@
<section class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<p class="coffee-hand text-center text-2xl text-accent">{{ heading }}</p>{% endif %}
<div class="mt-8 {% if marquee %}group relative overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]{% endif %}">
<div class="flex flex-wrap items-center justify-center gap-x-10 gap-y-6 {% if marquee %}flex-nowrap [animation:logo-marquee_30s_linear_infinite] motion-reduce:[animation:none] group-hover:[animation-play-state:paused]{% endif %}">
{% for logo in logos %}
{% if logo.url %}<a href="{{ logo.url }}" class="block shrink-0 opacity-80 transition hover:opacity-100">{% endif %}
<span class="inline-flex h-8 items-center{% if grayscale %} grayscale transition-[filter] hover:grayscale-0{% endif %}">{% img logo.image alt=logo.alt class="h-8 w-auto object-contain" %}</span>
{% if logo.url %}</a>{% endif %}
{% endfor %}
</div>
</div>
</div>
</section>
{% if marquee %}<style>@keyframes logo-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}</style>{% endif %}

View File

@ -0,0 +1,31 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-5xl 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 intro %}<p class="coffee-body mt-3 max-w-2xl text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<div class="coffee-chalkboard mt-10 p-6 md:p-10">
<div class="coffee-menu-grid{% if columns == 2 %} coffee-menu-grid--2{% endif %}">
{% for section in sections %}
<div class="menu-section">
{% if section.title %}<h3 class="coffee-hand border-b coffee-chalk-rule pb-2 text-3xl">{{ section.title }}</h3>{% endif %}
{% if section.note %}<p class="coffee-body coffee-chalk-muted mt-2 text-sm">{{ section.note }}</p>{% endif %}
<ul class="mt-4 space-y-5">
{% for item in section.items %}
<li class="flex items-baseline gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="coffee-display text-lg">{{ item.name }}</span>
{% for badge in item.badges %}{% if badge.label %}<span class="coffee-hand rounded-full border coffee-chalk-rule px-2 py-0.5 text-sm coffee-chalk-muted">{{ badge.label }}</span>{% endif %}{% endfor %}
</div>
{% if item.description %}<p class="coffee-body coffee-chalk-muted mt-0.5 text-sm">{{ item.description }}</p>{% endif %}
</div>
{% if item.price %}<span class="coffee-mono shrink-0 tabular-nums">{% if currency %}{{ currency }}{% endif %}{{ item.price }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% if footnote %}<p class="coffee-body coffee-chalk-muted mt-10 text-xs">{{ footnote }}</p>{% endif %}
</div>
</div>
</section>

View File

@ -0,0 +1,74 @@
{# coffee navbar — paper-strip chrome over the builtin menu provider. One navbar per page (fixed drawer ids). #}
{% set nv = variant %}
{% if not nv %}{% if template == "centered" %}{% set nv = "centered-logo" %}{% else %}{% set nv = "standard" %}{% endif %}{% endif %}
{% set sb = stickyBehavior %}
{% if not sb %}{% if sticky %}{% set sb = "sticky" %}{% else %}{% set sb = "none" %}{% endif %}{% endif %}
{% set cta_text = cta.text|default:ctaLabel %}
{% set cta_url = cta.url|default:ctaUrl %}
{% macro navlink(item) %}<a href="{{ item.url|default:'#' }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %}{% if item.title %} title="{{ item.title }}"{% endif %} class="coffee-body px-3 py-2 text-sm font-medium text-foreground/80 transition-colors hover:text-accent {{ item.css_class }}">{{ item.label }}</a>{% endmacro %}
{% macro dropdown(item) %}<div class="relative group"><button type="button" class="coffee-body inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-foreground/80 transition-colors hover:text-accent">{{ item.label }}<svg class="h-4 w-4 transition-transform group-hover:rotate-180" aria-hidden="true"><use href="/icons/lucide.svg#chevron-down"></use></svg></button><div class="absolute left-0 z-50 mt-1 w-56 invisible opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"><div class="coffee-card py-2 shadow-lg">{% for child in item.children %}{% if child.item_type == "header" %}<div class="coffee-hand px-4 py-2 text-base text-accent">{{ child.label }}</div>{% else %}<a href="{{ child.url|default:'#' }}"{% if child.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="coffee-body block px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted {{ child.css_class }}">{{ child.label }}</a>{% endif %}{% endfor %}</div></div></div>{% endmacro %}
{% macro brand(size) %}<a href="/" class="flex flex-shrink-0 items-center gap-2">{% if logoType == "text" and logoText.text %}<span class="coffee-display text-2xl text-primary {{ logoText.color }}">{{ logoText.text }}</span>{% elif logo %}<img src="{{ logo }}" alt="{{ alt|default:companyName }}" class="{{ size }} w-auto"{% if logoMaxWidth or logoMaxHeight %} style="{% if logoMaxWidth %}max-width:{{ logoMaxWidth }};{% endif %}{% if logoMaxHeight %}max-height:{{ logoMaxHeight }};{% endif %}"{% endif %}/>{% elif companyName %}<span class="coffee-display text-2xl text-primary">{{ companyName }}</span>{% endif %}</a>{% endmacro %}
{% macro ctabtn(extra) %}{% if cta_text %}<a href="{{ cta_url|default:'#' }}" class="kraft-tag text-sm {{ extra }}">{{ cta_text }}</a>{% endif %}{% endmacro %}
<nav data-bn-navbar data-bn-sticky="{{ sb }}" class="bn-navbar w-full {% if sb == "sticky" or sb == "shrink-on-scroll" %}sticky top-0 z-40 {% endif %}bg-background/95 backdrop-blur border-b border-dashed border-border {{ class }}">
{% if showUtilityBar %}<div class="w-full border-b border-border/60 bg-muted/60 text-xs text-muted-foreground {% if not showOnMobile %}hidden sm:block{% endif %}"><div class="mx-auto flex h-8 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8"><div class="coffee-body">{{ utilityBarText|safe }}</div><div class="flex items-center gap-4">{% if utilityBarPhone %}<a href="tel:{{ utilityBarPhone }}" class="hover:text-foreground">{{ utilityBarPhone }}</a>{% endif %}{% if utilityBarEmail %}<a href="mailto:{{ utilityBarEmail }}" class="hover:text-foreground">{{ utilityBarEmail }}</a>{% endif %}</div></div></div>{% endif %}
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="bn-navbar-row flex h-16 items-center justify-between">
{% if nv == "centered-logo" %}
<div class="hidden flex-1 items-center gap-1 md:flex">{% for item in menu.items %}{% if item.children %}{{ dropdown(item) }}{% else %}{{ navlink(item) }}{% endif %}{% endfor %}</div>
<div class="flex items-center justify-center">{{ brand("h-9") }}</div>
<div class="hidden flex-1 items-center justify-end gap-3 md:flex">{{ ctabtn("") }}</div>
{% else %}
<div class="flex items-center">{{ brand("h-9") }}</div>
<div class="hidden items-center gap-1 md:flex">{% for item in menu.items %}{% if item.children %}{{ dropdown(item) }}{% else %}{{ navlink(item) }}{% endif %}{% endfor %}{{ ctabtn("ml-3") }}</div>
{% endif %}
<div class="flex items-center md:hidden">
<label for="bn-navbar-toggle" data-bn-nav-open aria-controls="bn-navbar-drawer" aria-expanded="false" class="inline-flex cursor-pointer items-center justify-center rounded-md p-2 transition-colors hover:bg-muted"><span class="sr-only">Open menu</span><svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></label>
</div>
</div>
</div>
<input type="checkbox" id="bn-navbar-toggle" class="bn-navbar-toggle" aria-hidden="true"/>
<label for="bn-navbar-toggle" class="bn-navbar-overlay fixed inset-0 z-40 bg-black/50 md:hidden" aria-hidden="true"></label>
<div id="bn-navbar-drawer" class="bn-navbar-drawer fixed right-0 top-0 z-50 flex h-full w-80 max-w-[85vw] flex-col bg-background text-foreground shadow-xl md:hidden" role="dialog" aria-modal="true" aria-label="Site menu">
<div class="flex items-center justify-between border-b border-dashed border-border p-4">{{ brand("h-8") }}<label for="bn-navbar-toggle" data-bn-nav-close class="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground"><span class="sr-only">Close menu</span><svg class="h-6 w-6" 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></label></div>
<nav class="flex-1 overflow-y-auto py-4" aria-label="Mobile">{% for item in menu.items %}{% if item.children %}<details class="group/acc"><summary class="coffee-body flex cursor-pointer list-none items-center justify-between px-6 py-3 text-base font-medium transition-colors hover:bg-muted">{{ item.label }}<svg class="h-4 w-4 transition-transform group-open/acc:rotate-180" aria-hidden="true"><use href="/icons/lucide.svg#chevron-down"></use></svg></summary><div class="pb-2 pl-8">{% for child in item.children %}{% if child.item_type != "header" %}<a href="{{ child.url|default:'#' }}"{% if child.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="coffee-body block px-2 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground">{{ child.label }}</a>{% endif %}{% endfor %}</div></details>{% else %}<a href="{{ item.url|default:'#' }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="coffee-body block px-6 py-3 text-base font-medium transition-colors hover:bg-muted">{{ item.label }}</a>{% endif %}{% endfor %}</nav>
{% if cta_text %}<div class="border-t border-dashed border-border p-4"><a href="{{ cta_url|default:'#' }}" class="kraft-tag w-full justify-center">{{ cta_text }}</a></div>{% endif %}
</div>
<style>
.bn-navbar-toggle{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;}
.bn-navbar-drawer{transform:translateX(100%);transition:transform .3s ease;}
.bn-navbar-overlay{opacity:0;pointer-events:none;transition:opacity .3s ease;}
.bn-navbar-toggle:checked ~ .bn-navbar-drawer{transform:translateX(0);}
.bn-navbar-toggle:checked ~ .bn-navbar-overlay{opacity:1;pointer-events:auto;}
.bn-navbar-drawer details > summary::-webkit-details-marker{display:none;}
</style>
<script>
(function(){
if(window.__bnCoffeeNavInit)return;window.__bnCoffeeNavInit=1;
function ready(fn){if(document.readyState!='loading')fn();else document.addEventListener('DOMContentLoaded',fn);}
ready(function(){
document.querySelectorAll('[data-bn-navbar]').forEach(function(nav){
var toggle=nav.querySelector('.bn-navbar-toggle');
var drawer=nav.querySelector('.bn-navbar-drawer');
var opener=nav.querySelector('[data-bn-nav-open]');
if(!toggle||!drawer)return;
function focusables(){return drawer.querySelectorAll('a[href],button,label[for],input,summary,[tabindex]:not([tabindex="-1"])');}
toggle.addEventListener('change',function(){
var open=toggle.checked;
document.documentElement.style.overflow=open?'hidden':'';
document.body.style.overflow=open?'hidden':'';
if(opener)opener.setAttribute('aria-expanded',open?'true':'false');
if(open){var f=focusables();if(f.length)f[0].focus();}else if(opener&&opener.focus)opener.focus();
});
drawer.addEventListener('keydown',function(e){
if(e.key==='Escape'){toggle.checked=false;toggle.dispatchEvent(new Event('change'));return;}
if(e.key!=='Tab')return;
var f=focusables();if(!f.length)return;
var first=f[0],last=f[f.length-1];
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
});
drawer.querySelectorAll('a[href]').forEach(function(a){a.addEventListener('click',function(){toggle.checked=false;toggle.dispatchEvent(new Event('change'));});});
});
});
})();
</script>

View File

@ -0,0 +1,9 @@
<section class="py-12 bg-background text-foreground md:py-16{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-3xl px-4">
<div class="coffee-torn-top coffee-torn-bottom rounded-2xl {% if layout == "inline" %}bg-primary px-6 py-10 text-primary-foreground md:px-12{% else %}coffee-card px-6 py-10 md:px-10{% endif %} text-center">
{% if title %}<h2 class="coffee-display text-2xl md:text-3xl {% if layout == "inline" %}text-primary-foreground{% else %}text-primary{% endif %}">{{ title }}</h2>{% endif %}
{% if description %}<p class="coffee-body mx-auto mt-3 max-w-xl {% if layout == "inline" %}text-primary-foreground/80{% else %}text-muted-foreground{% endif %}">{{ description }}</p>{% endif %}
<div class="mx-auto mt-6 max-w-md">{% signup_form list=listSlug source=source submit=submitText success_message=successMessage placeholder=placeholder %}</div>
</div>
</div>
</section>

View File

@ -0,0 +1,20 @@
{# coffee page-suggestions — themed 404 helper. Suggestions (when the platform provides them) list {url|slug, title, excerpt}; otherwise a friendly hand-drawn "lost your way" panel with a route home. #}
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-3xl px-4 text-center">
<p class="coffee-hand text-3xl text-accent">Lost your way?</p>
<h2 class="coffee-display mt-1 text-3xl text-primary md:text-4xl">{{ title|default:"Were you looking for..." }}</h2>
{% if suggestions %}
<ul class="mt-8 space-y-3 text-left">
{% for s in suggestions %}
<li class="coffee-card p-4">
<a href="{{ s.url|default:s.slug }}" class="coffee-display text-lg text-primary transition-colors hover:text-accent">{{ s.title }}</a>
{% if showDescription and s.excerpt %}<p class="coffee-body mt-1 text-sm text-muted-foreground">{{ s.excerpt }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="coffee-body mx-auto mt-4 max-w-xl text-muted-foreground">{{ emptyMessage|default:"That page has wandered off. Head back to the counter and start again." }}</p>
{% endif %}
<a href="/" class="kraft-tag mt-8"><svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#home"></use></svg>Back home</a>
</div>
</section>

View File

@ -0,0 +1,13 @@
{# coffee password-reset-form — request panel in kraft chrome. Endpoint matches the platform (/api/auth/request-password-reset, #reset-message). #}
<div class="password-reset-form coffee-card mx-auto max-w-md p-6">
<div id="reset-message"></div>
<div id="request-reset-panel">
<h2 class="coffee-display text-2xl text-primary"><span class="coffee-doodle-underline">Reset password</span></h2>
<p class="coffee-body mt-2 text-muted-foreground">Enter your email address and we will send you a link to reset your password.</p>
<form hx-post="/api/auth/request-password-reset" hx-target="#reset-message" hx-swap="innerHTML" class="mt-4">
<div class="mb-4"><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>
<button type="submit" class="kraft-tag w-full justify-center" hx-disabled-elt="this">Send reset link</button>
</form>
<p class="coffee-body mt-4 text-center text-sm"><a href="/login" class="text-accent underline decoration-dashed">Back to login</a></p>
</div>
</div>

View File

@ -0,0 +1,15 @@
<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 %}
<ol class="space-y-4">
{% for post in posts %}
<li class="flex items-start gap-3">
{% if showRank %}<span class="coffee-display mt-0.5 text-2xl text-accent/60">{{ forloop.Counter }}</span>{% endif %}
{% if showThumbnail and post.featured_image_url %}<a href="{{ post.url }}" class="coffee-frame h-12 w-12 flex-shrink-0 overflow-hidden rounded-sm p-0.5">{% img post.featured_image_url alt=post.title class="h-full w-full rounded-sm object-cover" %}</a>{% endif %}
<div class="min-w-0">
<h3 class="coffee-display text-sm text-primary"><a href="{{ post.url }}" class="transition-colors hover:text-accent">{{ post.title }}</a></h3>
{% if post.published_at %}<p class="coffee-body mt-0.5 text-xs text-muted-foreground">{{ post.published_at|date:"M j, Y" }}</p>{% endif %}
</div>
</li>
{% endfor %}
</ol>
</section>

View File

@ -0,0 +1,19 @@
{% set center = align != "left" %}
{% if post.featured_image_url %}
<section class="hero relative flex min-h-[50vh] flex-col justify-end overflow-hidden bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="absolute inset-0 z-0">{% img post.featured_image_url alt=post.featured_image_alt class="h-full w-full object-cover" layout="hero" %}<div class="absolute inset-0 bg-black/60"></div></div>
<div class="relative z-10 mx-auto w-full max-w-4xl px-4 py-12 sm:px-6 lg:px-8 {% if center %}text-center{% endif %}">
<h1 class="coffee-display text-3xl leading-tight text-white sm:text-4xl lg:text-5xl">{{ post.title }}</h1>
{% if showExcerpt and post.excerpt %}<p class="coffee-body mt-4 max-w-2xl text-lg text-white/80{% if center %} mx-auto{% endif %}">{{ post.excerpt }}</p>{% endif %}
{% if showMeta %}<div class="coffee-hand mt-6 flex flex-wrap items-center gap-x-3 gap-y-1 text-lg text-white/90{% if center %} justify-center{% endif %}">{% if post.author_name %}<span>{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span>{{ post.published_at|date:"F j, Y" }}</span>{% endif %}{% if post.reading_time %}<span>{{ post.reading_time }} min</span>{% endif %}</div>{% endif %}
</div>
</section>
{% else %}
<section class="bg-background py-14 text-foreground md:py-20{% if class %} {{ class }}{% endif %}">
<div class="mx-auto w-full max-w-3xl px-4 {% if center %}text-center{% endif %}">
<h1 class="coffee-display text-3xl leading-tight text-primary sm:text-4xl lg:text-5xl">{{ post.title }}</h1>
{% if showExcerpt and post.excerpt %}<p class="coffee-body mt-4 text-lg text-muted-foreground{% if center %} mx-auto max-w-2xl{% endif %}">{{ post.excerpt }}</p>{% endif %}
{% if showMeta %}<div class="coffee-hand mt-6 flex flex-wrap items-center gap-x-3 gap-y-1 text-lg text-accent{% if center %} justify-center{% endif %}">{% if post.author_name %}<span>{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span class="text-muted-foreground">{{ post.published_at|date:"F j, Y" }}</span>{% endif %}{% if post.reading_time %}<span class="text-muted-foreground">{{ post.reading_time }} min</span>{% endif %}</div>{% endif %}
</div>
</section>
{% endif %}

View File

@ -0,0 +1,8 @@
{% if post %}
<div class="coffee-body flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground{% if class %} {{ class }}{% endif %}">
{% if showAuthor and post.author_name %}<span class="flex items-center gap-2">{% if showAvatar and post.author_avatar %}{% img post.author_avatar alt=post.author_name class="h-7 w-7 rounded-full object-cover" %}{% endif %}<span class="coffee-display text-foreground">{{ post.author_name }}</span></span>{% endif %}
{% if showDate and post.published_at %}<time datetime="{{ post.published_at|date:"Y-m-d" }}">{{ post.published_at|date:"F j, Y" }}</time>{% endif %}
{% if showReadingTime and post.reading_time %}<span>{{ post.reading_time }} min read</span>{% endif %}
{% if post.categories %}<span class="flex flex-wrap gap-1.5">{% for cat in post.categories %}<a href="{{ cat.url|default:'#' }}" class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:text-accent">{{ cat.name }}</a>{% endfor %}</span>{% endif %}
</div>
{% endif %}

View File

@ -0,0 +1,24 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="coffee-display text-center text-3xl text-primary md:text-4xl">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="coffee-body mx-auto mt-4 max-w-2xl text-center text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for tier in tiers %}
<div class="coffee-card relative flex flex-col p-6 {% if tier.highlighted %}ring-2 ring-accent{% endif %}">
{% if tier.badge %}<span class="coffee-hand absolute -top-4 left-1/2 -translate-x-1/2 rotate-[-3deg] rounded-full bg-accent px-4 py-1 text-lg text-accent-foreground shadow-sm">{{ tier.badge }}</span>{% endif %}
<h3 class="coffee-display text-xl text-primary">{{ tier.name }}</h3>
<p class="mt-4 flex items-baseline gap-1">
<span class="coffee-display text-4xl text-accent">{% if currency %}{{ currency }}{% else %}${% endif %}{{ tier.price }}</span>
{% if tier.period %}<span class="coffee-body text-sm text-muted-foreground">{{ tier.period }}</span>{% endif %}
</p>
{% if tier.description %}<p class="coffee-body mt-3 text-sm text-muted-foreground">{{ tier.description }}</p>{% endif %}
{% if tier.features %}<ul class="mt-6 flex-1 space-y-3 text-sm">
{% for feature in tier.features %}<li class="coffee-body flex items-start gap-2"><svg class="mt-0.5 h-4 w-4 shrink-0 text-accent" aria-hidden="true"><use href="/icons/lucide.svg#check"></use></svg><span>{{ feature }}</span></li>{% endfor %}
</ul>{% endif %}
{% if tier.ctaUrl and tier.ctaLabel %}<a href="{{ tier.ctaUrl }}" class="kraft-tag mt-8 w-full justify-center {% if not tier.highlighted %}kraft-tag--ghost{% endif %}">{{ tier.ctaLabel }}</a>{% endif %}
</div>
{% endfor %}
</div>
{% if billingNote %}<p class="coffee-body mt-8 text-center text-sm text-muted-foreground">{{ billingNote }}</p>{% endif %}
</div>
</section>

View File

@ -0,0 +1,6 @@
{% with style=style|default:"block" align=align|default:"left" %}
<figure class="bn-quote my-8 {% if style == "inline-card" %}coffee-card p-6 md:p-8{% elif style == "pull" %}border-l-4 border-accent pl-6 md:pl-8 py-2{% else %}border-l-2 border-dashed border-border pl-5 py-1{% endif %}{% if align == "center" %} text-center{% endif %}{% if class %} {{ class }}{% endif %}">
<blockquote{% if cite %} cite="{{ cite }}"{% endif %} class="coffee-display {% if style == "pull" %}text-2xl md:text-3xl leading-snug{% else %}text-xl md:text-2xl leading-relaxed{% endif %} text-primary">{{ quote|safe }}</blockquote>
{% if attribution or role or cite %}<figcaption class="coffee-hand mt-4 text-xl text-accent{% if align == "center" %} flex flex-col items-center{% endif %}">{% if attribution %}<span>{{ attribution }}</span>{% endif %}{% if role %}<span class="{% if attribution %}block {% endif %}text-lg text-muted-foreground">{{ role }}</span>{% endif %}{% if cite %}<a href="{{ cite }}" class="{% if attribution or role %}mt-1 {% endif %}coffee-body inline-block text-sm underline decoration-dashed underline-offset-2 hover:text-foreground">Source</a>{% endif %}</figcaption>{% endif %}
</figure>
{% endwith %}

View File

@ -0,0 +1,16 @@
{% if posts %}
<section class="border-t border-dashed border-border py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
<h2 class="coffee-display mb-8 text-2xl text-primary">{{ heading|default:"More from the counter" }}</h2>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% else %}lg:grid-cols-3{% endif %}">
{% for post in posts %}
<article class="group flex flex-col">
{% if post.featured_image_url %}<a href="{{ post.url }}" class="coffee-frame mb-3 block aspect-video overflow-hidden rounded-sm p-1.5">{% img post.featured_image_url alt=post.title class="h-full w-full rounded-sm object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
<h3 class="coffee-display text-base text-primary"><a href="{{ post.url }}" class="transition-colors hover:text-accent">{{ post.title }}</a></h3>
{% if post.published_at %}<p class="coffee-hand mt-1 text-base text-accent">{{ post.published_at|date:"M j, Y" }}</p>{% endif %}
</article>
{% endfor %}
</div>
</div>
</section>
{% endif %}

View File

@ -0,0 +1,13 @@
<section class="py-16 md:py-24 {% if background == "muted" %}bg-muted text-foreground{% elif background == "card" %}bg-card text-card-foreground{% else %}bg-background text-foreground{% endif %}{% if class %} {{ class }}{% endif %}">
<div class="mx-auto grid max-w-6xl items-center gap-10 px-4 md:grid-cols-2 md:gap-16">
<div class="{% if mediaPosition == "left" %}md:order-2{% else %}md:order-1{% endif %}">
{% if eyebrow %}<p class="coffee-hand text-2xl text-accent">{{ eyebrow }}</p>{% endif %}
{% if heading %}<h2 class="coffee-display mt-1 text-3xl text-primary md:text-4xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
{% if body %}<div class="coffee-body mt-4 space-y-4 text-lg text-muted-foreground [&_a]:text-accent [&_a]:underline [&_a]:decoration-dashed [&_strong]:text-primary">{{ body|safe }}</div>{% endif %}
{% if ctaUrl and ctaLabel %}<a href="{{ ctaUrl }}" class="kraft-tag mt-6 text-base px-5 py-3">{{ ctaLabel }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
</div>
<div class="{% if mediaPosition == "left" %}md:order-1{% else %}md:order-2{% endif %}">
{% if media %}<div class="coffee-frame coffee-torn-bottom overflow-hidden rounded-sm p-2">{% img media alt=mediaAlt class="h-auto w-full rounded-sm object-cover" %}</div>{% endif %}
</div>
</div>
</section>

View File

@ -0,0 +1 @@
{% if html %}<article class="rich-text coffee-body prose prose-lg max-w-none text-foreground [&_h1]:coffee-display [&_h2]:coffee-display [&_h3]:coffee-display [&_h1]:text-primary [&_h2]:text-primary [&_h3]:text-primary [&_a]:text-accent [&_a]:decoration-dashed [&_blockquote]:border-l-2 [&_blockquote]:border-accent [&_blockquote]:italic">{{ html|safe }}</article>{% endif %}

View File

@ -0,0 +1,12 @@
{% set compact = template == "compact" %}
<nav class="flex-1 {% if compact %}w-56 py-4 pr-3{% else %}w-64 py-6 pr-4{% endif %}{% if showBorder %} border-r border-dashed border-border{% endif %}{% if class %} {{ class }}{% endif %}">
{% if title %}<h3 class="coffee-hand {% if compact %}px-2 mb-3 text-lg{% else %}px-3 mb-4 text-xl{% endif %} text-accent">{{ title }}</h3>{% endif %}
<ul class="{% if compact %}space-y-0.5{% else %}space-y-1{% endif %}">
{% for item in menu.items %}
<li><a href="{{ item.url|default:'#' }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %}{% if item.title %} title="{{ item.title }}"{% endif %} data-bn-sidebar-link class="coffee-body flex items-center rounded-md text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground {% if compact %}px-2 py-1.5{% else %}px-3 py-2{% endif %} {{ item.css_class }}">{% if item.icon %}<svg class="mr-2 h-4 w-4" aria-hidden="true"><use href="/icons/{{ item.icon|split:":"|first }}.svg#{{ item.icon|split:":"|last }}"></use></svg>{% endif %}{{ item.label }}</a></li>
{% endfor %}
</ul>
</nav>
{% if highlightCurrent %}<script>
(function(){var p=window.location.pathname;document.querySelectorAll('[data-bn-sidebar-link]').forEach(function(a){if(a.getAttribute('href')===p){a.classList.remove('text-muted-foreground');a.classList.add('text-foreground','bg-muted','font-medium');}});})();
</script>{% endif %}

View File

@ -0,0 +1,13 @@
<div class="social-links{% if class %} {{ class }}{% endif %}">
{% if heading %}<p class="coffee-hand mb-3 text-xl text-accent">{{ heading }}</p>{% endif %}
<ul class="flex {% if style == "stack" %}flex-col gap-2{% else %}flex-wrap items-center gap-3{% endif %}">
{% for link in links %}{% if link.url %}
<li>
<a href="{{ link.url }}" target="_blank" rel="noopener" aria-label="{{ link.label|default:link.network }}" class="inline-flex items-center gap-2 rounded-full border border-dashed border-border text-foreground transition-colors hover:border-accent hover:text-accent {% if style == "stack" %}w-full px-4 py-2{% else %}p-2.5{% endif %}">
{% if link.network %}<svg class="h-5 w-5" aria-hidden="true"><use href="/icons/{{ link.network|split:":"|first }}.svg#{{ link.network|split:":"|last }}"></use></svg>{% endif %}
{% if style == "stack" and link.label %}<span class="coffee-body text-sm font-medium">{{ link.label }}</span>{% endif %}
</a>
</li>
{% endif %}{% endfor %}
</ul>
</div>

View File

@ -0,0 +1,44 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}"{% if countUp %} data-stats{% endif %}>
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="coffee-display text-center text-3xl text-primary md:text-4xl">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="coffee-body mx-auto mt-4 max-w-2xl text-center text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<dl class="mt-12 grid grid-cols-2 gap-8 {% if columns == 2 %}md:grid-cols-2{% elif columns == 3 %}md:grid-cols-3{% else %}md:grid-cols-4{% endif %}">
{% for item in items %}
<div class="text-center">
<dd class="coffee-display text-4xl text-accent md:text-5xl">{% if item.prefix %}{{ item.prefix }}{% endif %}<span{% if countUp %} data-countup-value="{{ item.value }}"{% endif %}>{{ item.value }}</span>{% if item.suffix %}{{ item.suffix }}{% endif %}</dd>
{% if item.label %}<dt class="coffee-hand mt-2 text-xl text-muted-foreground">{{ item.label }}</dt>{% endif %}
</div>
{% endfor %}
</dl>
</div>
</section>
{% if countUp %}<script>
(function(){
var sections = document.querySelectorAll('[data-stats]');
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function animate(section){
section.querySelectorAll('[data-countup-value]').forEach(function(node){
var raw = node.getAttribute('data-countup-value') || '';
var target = parseFloat(raw.replace(/[^0-9.\-]/g, ''));
if (isNaN(target)) return;
var decimals = (raw.split('.')[1] || '').length;
var start = null, dur = 1200;
function step(ts){
if (start === null) start = ts;
var p = Math.min((ts - start) / dur, 1);
var eased = 1 - Math.pow(1 - p, 3);
node.textContent = (target * eased).toFixed(decimals);
if (p < 1) requestAnimationFrame(step); else node.textContent = target.toFixed(decimals);
}
requestAnimationFrame(step);
});
}
sections.forEach(function(section){
if (reduce || !('IntersectionObserver' in window)) return;
var io = new IntersectionObserver(function(entries){
entries.forEach(function(e){ if (e.isIntersecting){ animate(section); io.disconnect(); } });
}, { threshold: 0.3 });
io.observe(section);
});
})();
</script>{% endif %}

View File

@ -0,0 +1,21 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="coffee-display text-center text-3xl text-primary md:text-4xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
{% if intro %}<p class="coffee-body mx-auto mt-4 max-w-2xl text-center text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<div class="mt-12 {% if layout == "list" %}mx-auto max-w-3xl space-y-6{% elif layout == "single" %}mx-auto max-w-xl{% else %}grid grid-cols-1 gap-8 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% elif columns == 4 %}lg:grid-cols-4{% else %}lg:grid-cols-3{% endif %}{% endif %}">
{% for member in members %}
<div class="{% if layout == "list" %}coffee-card flex items-start gap-5 p-5{% else %}text-center{% endif %}">
{% if member.photo %}<span class="{% if layout == "list" %}h-20 w-20{% else %}mx-auto h-28 w-28{% endif %} block shrink-0 overflow-hidden rounded-full bg-secondary ring-2 ring-border">{% img member.photo alt=member.name class="h-full w-full object-cover" %}</span>{% endif %}
<div class="{% if layout != "list" %}mt-4{% endif %}">
{% if member.name %}<h3 class="coffee-display text-lg text-primary">{{ member.name }}</h3>{% endif %}
{% if member.role %}<p class="coffee-hand text-lg text-accent">{{ member.role }}</p>{% endif %}
{% if member.bio %}<p class="coffee-body mt-2 text-sm text-muted-foreground">{{ member.bio }}</p>{% endif %}
{% if member.socials %}<div class="mt-3 flex gap-3 {% if layout != "list" %}justify-center{% endif %}">
{% for social in member.socials %}{% if social.url %}<a href="{{ social.url }}" class="text-muted-foreground transition-colors hover:text-accent" aria-label="Social link"><svg class="h-5 w-5" aria-hidden="true"><use href="/icons/{{ social.icon|split:":"|first }}.svg#{{ social.icon|split:":"|last }}"></use></svg></a>{% endif %}{% endfor %}
</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</section>

View File

@ -0,0 +1,26 @@
<section class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="coffee-display text-center text-3xl text-primary md:text-4xl"><span class="coffee-doodle-underline">{{ heading }}</span></h2>{% endif %}
<div class="mt-12 {% if layout == "single" %}mx-auto max-w-3xl{% elif layout == "carousel" %}flex snap-x snap-mandatory gap-6 overflow-x-auto pb-4{% else %}grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3{% endif %}">
{% for q in quotes %}
<figure class="coffee-card flex flex-col p-6{% if layout == "carousel" %} min-w-[18rem] max-w-sm shrink-0 snap-start{% endif %}">
{% if q.rating %}<div class="mb-3 flex gap-0.5" aria-label="Rated {{ q.rating }} out of 5">
<svg class="h-4 w-4 {% if q.rating >= 1 %}text-accent{% else %}text-muted-foreground/30{% endif %}" aria-hidden="true"><use href="/icons/lucide.svg#star"></use></svg>
<svg class="h-4 w-4 {% if q.rating >= 2 %}text-accent{% else %}text-muted-foreground/30{% endif %}" aria-hidden="true"><use href="/icons/lucide.svg#star"></use></svg>
<svg class="h-4 w-4 {% if q.rating >= 3 %}text-accent{% else %}text-muted-foreground/30{% endif %}" aria-hidden="true"><use href="/icons/lucide.svg#star"></use></svg>
<svg class="h-4 w-4 {% if q.rating >= 4 %}text-accent{% else %}text-muted-foreground/30{% endif %}" aria-hidden="true"><use href="/icons/lucide.svg#star"></use></svg>
<svg class="h-4 w-4 {% if q.rating >= 5 %}text-accent{% else %}text-muted-foreground/30{% endif %}" aria-hidden="true"><use href="/icons/lucide.svg#star"></use></svg>
</div>{% endif %}
<blockquote class="coffee-body flex-1 text-lg leading-relaxed text-foreground">{% if q.quote %}&ldquo;{{ q.quote }}&rdquo;{% endif %}</blockquote>
<figcaption class="mt-5 flex items-center gap-3">
{% if q.avatar %}<span class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-secondary">{% img q.avatar alt=q.name class="h-full w-full object-cover" %}</span>{% endif %}
<span>
{% if q.name %}<span class="coffee-display block text-base text-primary">{{ q.name }}</span>{% endif %}
{% if q.role %}<span class="coffee-hand block text-lg text-accent">{{ q.role }}</span>{% endif %}
</span>
</figcaption>
</figure>
{% endfor %}
</div>
</div>
</section>

View File

@ -1 +1 @@
<div class="coffee-body coffee-dropcap prose max-w-prose text-foreground {{ class }}">{{ text|safe }}</div> <div class="coffee-body prose max-w-none flex-1 text-foreground [&_a]:text-accent [&_a]:underline [&_a]:decoration-dashed [&_strong]:text-primary {{ proseClass }}">{{ text|safe }}</div>

View File

@ -0,0 +1,21 @@
<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-center text-3xl text-primary md:text-4xl">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="coffee-body mx-auto mt-4 max-w-2xl text-center text-lg text-muted-foreground">{{ intro }}</p>{% endif %}
<ol class="mt-12 {% if orientation == "horizontal" %}grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4{% else %}relative space-y-8 before:absolute before:left-4 before:top-2 before:h-[calc(100%-1rem)] before:w-px before:bg-border{% endif %}">
{% for step in steps %}
<li class="{% if orientation == "horizontal" %}text-center{% else %}relative pl-14{% endif %}">
<span class="coffee-stamp coffee-stamp-sm {% if orientation == "horizontal" %}mx-auto{% else %}absolute left-0 top-0 ring-4 ring-background{% endif %}">
{% if marker == "numbered" %}{{ forloop.counter }}{% elif marker == "dot" %}<span class="h-2 w-2 rounded-full bg-accent-foreground"></span>{% else %}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#coffee"></use></svg>{% endif %}
</span>
<div class="{% if orientation == "horizontal" %}mt-4{% else %}pt-1{% endif %}">
{% if step.label %}<p class="coffee-hand text-lg text-accent">{{ step.label }}</p>{% endif %}
{% if step.title %}<h3 class="coffee-display mt-1 text-lg text-primary">{{ step.title }}</h3>{% endif %}
{% if step.text %}<p class="coffee-body mt-1 text-muted-foreground">{{ step.text }}</p>{% endif %}
{% if step.media %}<div class="coffee-frame mt-3 overflow-hidden rounded-sm p-1.5">{% img step.media alt=step.title class="h-auto w-full rounded-sm object-cover" %}</div>{% endif %}
</div>
</li>
{% endfor %}
</ol>
</div>
</section>

View File

@ -0,0 +1,10 @@
{# coffee utility-bar — thin contact strip; left/right item lists {icon,label,link}. #}
{% set ubcls = "bg-muted text-muted-foreground border-b border-dashed border-border" %}
{% if variant == "primary" %}{% set ubcls = "bg-primary text-primary-foreground" %}{% elif variant == "dark" %}{% set ubcls = "bg-foreground text-background" %}{% endif %}
{% macro ubitem(it) %}{% if it.link %}<a href="{{ it.link }}" class="inline-flex items-center gap-1.5 transition-opacity hover:opacity-80">{% if it.icon %}<svg class="h-3.5 w-3.5" aria-hidden="true"><use href="/icons/{{ it.icon|split:":"|first }}.svg#{{ it.icon|split:":"|last }}"></use></svg>{% endif %}{% if it.label %}<span>{{ it.label }}</span>{% endif %}</a>{% else %}<span class="inline-flex items-center gap-1.5">{% if it.icon %}<svg class="h-3.5 w-3.5" aria-hidden="true"><use href="/icons/{{ it.icon|split:":"|first }}.svg#{{ it.icon|split:":"|last }}"></use></svg>{% endif %}{% if it.label %}<span>{{ it.label }}</span>{% endif %}</span>{% endif %}{% endmacro %}
<div class="coffee-body w-full text-xs {% if not showOnMobile %}hidden sm:block{% endif %} {{ ubcls }} {{ class }}">
<div class="mx-auto flex h-9 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<div class="flex flex-wrap items-center gap-4">{% for it in leftItems %}{{ ubitem(it) }}{% endfor %}</div>
<div class="flex flex-wrap items-center justify-end gap-4">{% for it in rightItems %}{{ ubitem(it) }}{% endfor %}</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
{% with aspect=aspect|default:"16:9" %}<figure class="bn-video my-8{% if class %} {{ class }}{% endif %}"><div class="bn-video-frame coffee-frame relative w-full overflow-hidden rounded-sm bg-secondary p-1.5 {% if aspect == "4:3" %}aspect-[4/3]{% elif aspect == "1:1" %}aspect-square{% elif aspect == "21:9" %}aspect-[21/9]{% else %}aspect-video{% endif %}" data-bn-video data-video-url="{{ url|default:"" }}" data-video-media="{% if media %}{{ media|mediaURL }}{% endif %}"><button type="button" data-bn-video-play class="group absolute inset-0 flex h-full w-full items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" aria-label="Play video{% if title %}: {{ title }}{% endif %}">{% if poster %}{% img poster alt=title|default:"" class="absolute inset-0 h-full w-full rounded-sm object-cover" %}{% endif %}<span class="absolute inset-0 bg-foreground/10 transition-colors group-hover:bg-foreground/20" aria-hidden="true"></span><span class="relative inline-flex h-16 w-16 items-center justify-center rounded-full bg-accent text-accent-foreground shadow-lg transition-transform group-hover:scale-105 motion-reduce:transform-none"><svg class="h-7 w-7" aria-hidden="true"><use href="/icons/lucide.svg#play"></use></svg></span></button></div>{% if caption %}<figcaption class="coffee-hand mt-3 text-center text-xl text-accent">{{ caption }}</figcaption>{% endif %}</figure><script>
(function(){
if(window.__bnVideoFacade)return;window.__bnVideoFacade=true;
function yt(u){var m=u.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{6,})/);return m?m[1]:null;}
function vim(u){var m=u.match(/vimeo\.com\/(?:video\/)?(\d+)/);return m?m[1]:null;}
document.addEventListener('click',function(e){
var btn=e.target.closest('[data-bn-video-play]');if(!btn)return;
var frame=btn.closest('[data-bn-video]');if(!frame)return;
e.preventDefault();
var url=frame.getAttribute('data-video-url')||'',media=frame.getAttribute('data-video-media')||'',node;
var vstyle='position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:rgba(0,0,0,1);';
if(media){node=document.createElement('video');node.src=media;node.controls=true;node.autoplay=true;node.setAttribute('playsinline','');node.style.cssText=vstyle;}
else{var y=yt(url),v=vim(url),embed;
if(y)embed='https://www.youtube-nocookie.com/embed/'+y+'?autoplay=1&rel=0';
else if(v)embed='https://player.vimeo.com/video/'+v+'?autoplay=1';
if(embed){node=document.createElement('iframe');node.src=embed;node.allow='autoplay; fullscreen; picture-in-picture';node.setAttribute('allowfullscreen','');node.setAttribute('title',btn.getAttribute('aria-label')||'Video');node.style.cssText='position:absolute;inset:0;width:100%;height:100%;border:0;';}
else if(url){node=document.createElement('video');node.src=url;node.controls=true;node.autoplay=true;node.setAttribute('playsinline','');node.style.cssText=vstyle;}
}
if(node){btn.remove();frame.appendChild(node);}
});
})();
</script>{% endwith %}