Compare commits

..

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

137 changed files with 4656 additions and 2843 deletions

1
.gitignore vendored
View File

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

View File

@ -1,65 +1,143 @@
# Noir — Build Report # Noir — Build Report
Wave A pass (WO-TF-022): re-persona from a photography/portfolio theme to the Implementation pass: wave-1 scaffold of the `noir` theme plugin.
LOCKED **detective / true-crime dossier** direction. Codeless `.bnp`.
Plugin slug: `noir` · kind: `theme` · version: `0.2.0` Plugin slug: `noir`
Module path: `git.dev.alexdunmow.com/block/themes/noir`
SDK pinned to: `git.dev.alexdunmow.com/block/core v0.11.1`
Go directive: `go 1.26.4`
Tech style: templ (per spec §11)
## Identity ## What landed
Case-file panels, typewriter type, redaction bars, evidence board and film ### Metadata & build glue
grain. High-key gallery by day (ink type, evidence-red accent); darkroom black by - `plugin.mod` — name, display_name, scope `@themes`, kind `theme`, version `0.1.0`, categories `["templates", "media"]`, tags array (8 entries, includes the four spec-required minimums), `[compatibility] block_core = ">=0.11.0 <0.12.0"`.
night (bone type, amber safelight accent). Motion tier MID: JS scroll-reveal - `go.mod` — pins `block/core v0.11.1` and `templ v0.3.1020`, mirrors gotham's indirect set, no `replace` directives.
(reduced-motion honored, static no-JS fallback) plus the builtin gallery lightbox - `Makefile` — local-only targets (`all`, `templ`, `clean`). No `rebuild` target. Default builds `noir.so` via `CGO_ENABLED=1 go build -buildmode=plugin`.
showpiece (no canvas). - `embed.go` — five canonical `//go:embed` directives plus a `ThemeCSSManifest()` that surfaces `assets/style.css` through `CSSManifest.InputCSSAppend` so the custom utilities (`.tracked-mono`, `.bleed`, `.hairline`, sprocket motif, lightbox overlay) survive Tailwind's content scanner.
- `registration.go` — exports `var Registration plugin.PluginRegistration` with all functions wired.
## What shipped ### System & page templates
- `tr.RegisterSystemTemplate({Key: "noir", …})` — exactly once.
- Four `tr.RegisterPageTemplate("noir", …)` calls with the spec's slot sets:
- `default``["header", "main", "footer"]`
- `landing``["hero", "main", "cta", "footer"]`
- `article``["header", "main", "aside", "footer"]`
- `full-width``["header", "main", "footer"]`
- Each page renderer lives in `template.templ` and consumes the shared `bn.Head` / `bn.AdminBypassBanner` / `bn.BodyEnd` helpers, mirroring gotham.
- **49 mandatory template overrides** in `templates/overrides/noir/` + a ### Blocks (6 theme-specific)
`template_overrides` entry per key in `manifest.yaml`. Each re-skins the frozen - `noir:lightbox_gallery` — grid + vanilla keyboard-aware lightbox overlay (Esc + Enter/Space, click-outside dismiss, ARIA-modal).
builtin default, preserving every content field, provider var, custom tag and - `noir:contact_sheet` — sprocket-framed numbered grid; `data-frame-number` attribute on each frame for UAT §13.9.
`<script>`/`<style>` block verbatim; only styling changes. Legacy button/card - `noir:case_study` — sticky meta rail (client / year / credits) + image stack.
field reads reconciled to the new builtin contract (link/style/label, text/media). - `noir:caption_strip` — 10px mono full-width strip.
- **7 page templates** (`templates/noir/`): default, full-width, landing, article, - `noir:image_pair` — 50/50 diptych with shared caption.
blog-index, contact, auth. - `noir:footer` — dissolved-rail footer with optional social links.
- **4 persona blocks** (`blocks/`): `case_file`, `evidence_board`,
`redacted_quote`, `case_dossier`. The old photography blocks (lightbox_gallery,
contact_sheet, caption_strip, image_pair, footer, case_study) were deleted — the
builtins they duplicated are now overrides.
- **5 presets** (`presets.json`), all 19 tokens, light + dark intentionally tuned
(`mode: both`): Cold Case (default), Evidence Red, Safelight, Interrogation,
Redaction.
- **Bundled fonts** (`fonts.json`, `assets/fonts/`, OFL): Source Serif 4 (body) and
IBM Plex Mono (typewriter heading/mono). Fallback stacks name Special Elite /
Courier Prime first for admins. Licenses in `assets/fonts/LICENSES.md`.
- **Themed email wrapper** (`templates/email/noir.ninjatpl`): table-based, inline
styles, dossier masthead.
- **Master pages** (`master_pages.json`) + **seed demo** (`seed/seed.json`,
`seed/workflows.json`): "The Cold File" true-crime site — home, about, blog (3
case write-ups), contact (form → `case_tips` data table → `row_inserted`
email-admins workflow), login.
- **noir CSS vocabulary** injected via `manifest.yaml` `css.input_css_append`:
`.noir-heading/-body/-mono/-label`, `.noir-file`, `.noir-btn/-btn-solid/-link`,
`.noir-rule/-hairline`, `.noir-stamp`, `.noir-redact`, `.noir-pin`, `.noir-tape`,
`.noir-grain`, `.noir-spotlight`, `.noir-reveal`. Kebab-case tokens only; all
font-family via `var(--font-heading|body|mono, <fallback>)`.
## Verification All registered with `Source: "noir"` and the appropriate `blocks.Category*` constant.
- `cd themes/noir && make build``Built noir@0.2.0 (codeless) → noir-0.2.0.bnp`, ### Schemas (6, draft-07)
exit 0. - Property names exactly match the Go `content["…"]` reads in each block.
- `cd check-safety && go run . themes/noir``32 checks: 19 ok 13 skip -> OK`, - Every `x-editor` value comes from the allowed set:
exit 0. `text`, `media`, `select`, `number`, `array`, `link`.
- `lightbox_gallery.columns` uses `x-editor: select` with `enum: [2, 3, 4]` (spec §8).
- `case_study.year` uses `x-editor: number` (spec §8).
- `case_study.credits` is `array<string>`; `case_study.images` is `array<media>` per spec §8.
- `footer.social` is `array<link>` with `text`/`url` fields per spec §8.
Visual quality is UNVERIFIED until Wave B screenshots. `preview.png` is a stale ### Template overrides (5)
photography-era image and will be regenerated in Wave B. - `RegisterTemplateOverride("noir", …)` for `heading`, `text`, `image`, `button`, `card` — exactly five calls, per UAT §3.
- Display headings render with `font-family: var(--font-heading)` and no underline.
- Button override is hairline 1px outline, transparent background, hover inverts to `--primary` / `--primary-foreground`.
- Card override is transparent with hairline border only.
- Image override emits the `.bleed` utility for full-bleed and a `figcaption.tracked-mono` for the mono caption.
## Known platform limits (per OVERRIDE-CONTRACT §11/§12, not theme bugs) ### Email wrapper
- `tr.RegisterEmailWrapper("noir", NoirEmailWrapper)` — pure black canvas, inline 600px column table, Tenor Sans masthead (18px, letter-spacing +0.05em), 16:10 cover image (600×375), mono caption strip with copyright + unsubscribe.
- All inline styles; no `<style>` block (one minor `<head><title>` only).
- Falls back to hex literals when `EmailContext.Colors` is empty so the email always renders without theme CSS variables.
- `auth-form`, `auth-status`, `password-reset-form`, `page-suggestions` are ### Master pages (2)
compiled builtins whose overrides do not yet dispatch — the stock builtin HTML - `noir:default-master``default`, `article` templates. Blocks in spec order: `navbar` (variant: minimal), `slot` (slotName: main), `noir:caption_strip`, `noir:footer`.
renders until the cms fix lands. Authored faithfully regardless. - `noir:gallery-master``landing`, `full-width` templates. Uses `navbar` variant `dissolved` and adds `noir:contact_sheet_footer` as the footer-rail block. (`noir:contact_sheet_footer` is referenced as a block key per spec §6; it is not currently provisioned as a separate block in this pass — see open items.)
- Provider-backed overrides (navbar/footer menus, featured/related/popular posts,
category-list, breadcrumbs, blog-hero, sidebar-nav, author-bio-hero) render ### Presets (3, all 19 tokens)
themed chrome but empty live lists under the codeless loader until the cms fix - `pure-noir` — mode `dark`, single `darkColors` block. Pure black ground, white primary, silver accent.
lands. Authored faithfully to the builtin provider var names. - `silver-print` — mode `light`, single `lightColors` block. Bone white ground, charcoal type.
- `platinum` — mode `both`, both `lightColors` and `darkColors` blocks per UAT §5.
- Every value is an HSL triple string (no `hsl(…)` wrapper); verified by `jq` against `^\d+ \d+% \d+%$`.
### Fonts policy
- `fonts.json = []` per `themes/docs/FONTS.md` wave-1 policy.
- CSS fallback stacks for `--font-heading` (Tenor Sans → Georgia → serif), `--font-body` (Inter → system sans), `--font-mono` (JetBrains Mono → Consolas → SFMono-Regular → monospace) live in `assets/style.css`.
- `RECOMMENDED_FONTS.md` written with picker instructions for the three Google Fonts.
- `LICENSES.md` deliberately omitted (nothing bundled), per FONTS.md wave-1 §4.
### Custom CSS (assets/style.css)
- `.tracked-mono` — 10px JetBrains Mono fallback, 0.18em tracking, uppercase, line-height 1.4. Also `.tracked-mono-sm` at 11px.
- `.bleed``width: 100vw` + `margin-left: calc((100vw - 100%) / 2 * -1)`. The UAT §13.7 grep is satisfied: `calc((100vw - 100%) / 2)` substring appears once.
- `.hairline` — 1px border at 40% alpha against `--border`.
- `.noir-btn` — hairline outline button with keyboard `:focus-visible` ring on `--ring`.
- `.noir-caption-strip` — full-width 10px-strip flex container.
- `.noir-sprocket` — sprocket-hole motif via `radial-gradient` pseudo-elements at top and bottom.
- Lightbox overlay — `[data-noir-lightbox]` styled `position: fixed; inset: 0; width: 100vw; height: 100vh` with `aria-hidden`-driven visibility, Esc/click-outside dismiss, and a `prefers-reduced-motion` opt-out.
## Build output
```
$ cd /home/alex/src/blockninja/themes/noir
$ go mod tidy # OK, go.sum populated, block/core v0.11.1 resolved
$ templ generate # OK, 9 *_templ.go produced
$ make # OK
$ ls -lh noir.so
-rw-rw-r-- 1 alex alex 21M noir.so # ~21 MB, expected for templ + bn helper compile
```
`noir.so` was produced via:
```
CGO_ENABLED=1 go build -buildmode=plugin -ldflags="-s -w" -o noir.so .
```
## Safety check
```
$ cd /home/alex/src/blockninja/check-safety
$ go run . /home/alex/src/blockninja/themes/noir \
--plugin-dir /home/alex/src/blockninja/themes/noir
# … all checks OK or SKIP for noir, exit code 0
```
Notable results:
- **Check 2c** (Standalone plugin SDK import boundaries): OK. `go.mod` does not locally replace `block/core`; only `block/core/...` is imported.
- **Check 3** (`go vet`, golangci-lint, strict lint): OK for the single noir module.
- **Check 6** (No hardcoded colors): OK across `.templ`, `.ninjatpl`, and `.css`. All colour values use `hsl(var(--token))` or HSL triples in `presets.json`.
- **Check 11** (No placeholder code): OK.
- **Check 17** (No TODO markers): OK.
- **Check 21** (Plugin presets validation): OK — presets unmarshal cleanly against `theme.Theme`.
- **Check 2e** (Warn on any usage): 30 warnings — same shape as gotham/lcars baseline (the public `BlockFunc` and `MasterPageBlock.Content` signatures are `map[string]any` in the SDK itself, so the warnings are inherent to the API). No failure.
> Note: the task description references `~/src/blockninja/backend/cmd/check-safety`, but on this checkout the binary lives at `~/src/blockninja/check-safety/`. The equivalent invocation is shown above and exits 0.
## Open items / deferred
1. **Real woff2 files** — deferred to wave-2 per FONTS.md §5. `fonts.json = []`, fallbacks in CSS, `RECOMMENDED_FONTS.md` documents the picker path.
2. **`noir:contact_sheet_footer` block** — referenced by `noir:gallery-master` (per spec §6) but not implemented as a distinct block in this pass; spec §8 lists six blocks and this would be a seventh. The master page entry is kept (so the seeder can find the block once added) but the gallery footer currently falls back to an empty contact-sheet item array. Build does not fail; the block resolves to the existing `noir:contact_sheet` registration when wired by the host. Track for follow-up.
3. **Demo content / "Atelier Vance" seed** — not part of theme-plugin scope in this pass.
4. **Marketplace screenshots (6 frames)** — require a running `instance-noir` container; not produced here.
5. **Email render testing in Litmus / Apple Mail / Outlook 365** — out of scope without a live SMTP test.
6. **`LICENSES.md`** — intentionally omitted per FONTS.md wave-1 §4 (nothing bundled).
7. **Lightbox JS hardening** — vanilla keyboard-trap is implemented but a full focus-trap (Tab cycling within the overlay) is left as a wave-2 polish item; current behaviour traps Esc + click-outside + Enter/Space activation.
8. **`make rebuild` workflow** — deliberately omitted from the Makefile per task brief; the deploy targets live in `gotham/Makefile` and can be lifted across when the theme is ready to land in the live CMS container.
9. **Versioned git tag**`plugin.mod version = "0.1.0"` is set but no git tag is created (theme directory is not a git repo on its own).
## Counts
- Page templates registered: **4** (`default`, `landing`, `article`, `full-width`).
- Theme-specific blocks registered: **6** (`lightbox_gallery`, `contact_sheet`, `case_study`, `caption_strip`, `image_pair`, `footer`).
- Template overrides registered: **5** (`heading`, `text`, `image`, `button`, `card`).
- Email wrappers registered: **1** (`noir`).
- Master pages provisioned: **2** (`noir:default-master`, `noir:gallery-master`).
- Presets: **3** (`pure-noir`, `silver-print`, `platinum`).
- Schemas: **6** (one per theme-specific block).
- Bundled fonts: **0** (wave-1 policy).

View File

@ -1,55 +1,31 @@
# noir — build & publish helpers (CODELESS .bnp workflow, WO-WZ-021) # Noir — build helpers (.so plugin workflow)
# #
# This theme is a CODELESS .bnp: pure declaration the host runs — blocks # Local single-shot build:
# (blocks/blocks.yaml + .ninjatpl), page/system templates, template overrides, # make # produces noir.so via CGO go build -buildmode=plugin
# an email wrapper, master pages, presets, fonts and CSS in manifest.yaml. No # make templ # regenerate *_templ.go files
# Go, no templ, no plugin.wasm, no block/core dependency. `ninja plugin build` # make clean # remove build artefacts
# classifies the repo as codeless (no Go at the root) and packs the artifact
# with manifest.pb (codeless: true) and NO plugin.wasm.
#
# Usage:
# make build # pack noir-<version>.bnp (codeless — no wasm)
# make verify # validate the built .bnp (loader-identical checks)
# make archive-check # prove a clean `git archive HEAD` still builds codeless
# make publish # ninja plugin publish --bnp (to the active registry)
# make clean # remove build artefacts
.PHONY: build verify archive-check publish clean help .PHONY: all clean templ help
PLUGIN_NAME := noir PLUGIN_NAME := noir
NINJA := ninja
BNP := $(PLUGIN_NAME)-$(shell grep '^version' plugin.mod | sed 's/.*"\(.*\)"/\1/').bnp
# Pack the codeless .bnp (manifest.pb synthesized from plugin.mod + manifest.yaml # Default target: build the .so locally.
# + blocks/ + templates/; no wasm compile, no DESCRIBE probe). all: $(PLUGIN_NAME).so
build:
$(NINJA) plugin build --dir .
# Validate the built .bnp exactly as the registry/loader will. # Local plugin build (no container). Compiles to .so.
verify: $(PLUGIN_NAME).so: $(wildcard *.go) plugin.mod go.mod
$(NINJA) plugin verify $(BNP) CGO_ENABLED=1 go build -buildmode=plugin -ldflags="-s -w" -o $(PLUGIN_NAME).so .
# Prove a clean `git archive HEAD` (what publish ships) still packs codeless. # Regenerate templ Go files locally.
archive-check: templ:
@T=$$(mktemp -d /tmp/archive-check-$(PLUGIN_NAME).XXXXXX) && \ templ generate
trap 'rm -rf "$$T"' EXIT && \
git archive HEAD | tar -x -C "$$T" && \
cd "$$T" && $(NINJA) plugin build --dir . && \
echo "archive-check OK"
# Publish the prebuilt .bnp to the active registry.
# Pass --host https://my.blockninja.dev for the dev orchestrator.
publish: build
$(NINJA) plugin publish --bnp $(BNP)
# Remove build artefacts. # Remove build artefacts.
clean: clean:
rm -f $(PLUGIN_NAME)-*.bnp plugin.wasm rm -f $(PLUGIN_NAME).so
help: help:
@echo "Targets:" @echo "Targets:"
@echo " build Pack the codeless .bnp (no wasm)" @echo " all Build $(PLUGIN_NAME).so (default)"
@echo " verify Validate the built .bnp (loader-identical checks)" @echo " templ Regenerate *_templ.go files"
@echo " archive-check Prove a clean git archive HEAD packs codeless" @echo " clean Remove build artefacts"
@echo " publish Publish the .bnp to the active registry"
@echo " clean Remove build artefacts"

View File

@ -1,24 +0,0 @@
# Noir
A detective's dossier laid out on the desk. Case-file cards, typewriter type, and black redaction bars set the tone, with a faint film grain over the whole surface. By day it reads like a high-key gallery wall. At night it drops to darkroom black. The mood is investigative and a little tense, and the type stays legible throughout.
## Good for
We built Noir for storytelling with an edge. Treat these as starting points, not limits. If the mood fits your brand, use it:
- True-crime podcasts and newsletters
- Investigative journalism and long-form reporting
- Mystery authors and book launches
- Escape rooms and immersive events
## What you get
- Five color presets, each with a tuned light mode and a tuned dark mode: Cold Case, Evidence Red, Safelight, Interrogation, and Redaction.
- Source Serif 4 for reading and IBM Plex Mono for labels and stamps, both bundled. Fonts stay admin-controlled, so you can swap them from the typography settings.
- Four persona blocks: a case dossier, a stamped case-file card, an evidence board with pinned notes, and a redacted quote with blacked-out lines.
- A demo site that installs when you activate the theme: 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,48 +1,46 @@
# Noir — Recommended Fonts # Noir — Recommended Fonts
Noir is a detective / true-crime dossier. The typographic voice is a typewriter Noir ships with `fonts.json = []` per the wave-1 fonts policy
for the file itself and a quiet reading serif for the case write-up. The theme (`themes/docs/FONTS.md`). No woff2s are bundled in this implementation
**bundles** two OFL faces so it renders on its intended stack with no admin setup pass. The site admin assigns fonts via the typography settings panel.
(`fonts.json`, woff2 in `assets/fonts/`, licenses in
`assets/fonts/LICENSES.md`):
- **IBM Plex Mono** (400, 500) — the shipped typewriter face. Serves both the The fallback stacks in `assets/style.css` already approximate the
heading (typed case-file headers) and mono (evidence labels, case numbers, intended Noir aesthetic (Tenor Sans display, Inter body, JetBrains
stamps) slots by default. Mono captions) using widely available system faces. The picks below
- **Source Serif 4** (400, 400 italic) — the shipped reading body for case prose. match the spec exactly and are recommended once an admin opens the
font picker.
Every family flows through `var(--font-heading|body|mono, <fallback>)`; nothing ## Heading
is hardcoded, so an admin can override any slot from the picker. The fallback
stacks name the *ideal* typewriter faces first, so assigning them lifts the look
further:
## Heading — typed case-file display - **Source**: `google:Tenor Sans`
- **Family**: `Tenor Sans`
- **Why**: high-contrast modern serif with the silver-print
monograph feel called for in spec §3.
- **How**: Site Settings → Typography → Heading → Google Fonts →
search "Tenor Sans" → Add → Assign to Heading slot.
- **Ideal**: `google:Special Elite` (Apache 2.0) — worn typewriter, the classic ## Body
true-crime header face.
- **Shipped default**: IBM Plex Mono 500 (bundled, OFL).
- **How**: Site Settings → Typography → Heading → Google Fonts → search
"Special Elite" → Add → assign to the Heading slot.
## Body — case write-up - **Source**: `google:Inter`
- **Family**: `Inter`
- **Why**: humanist sans, large x-height, reads cleanly at the
generous leading the override uses.
- **How**: Site Settings → Typography → Body → Google Fonts →
search "Inter" → Add → Assign to Body slot.
- **Ideal & shipped**: Source Serif 4 (bundled, OFL) — a calm high-legibility ## Mono
serif for long-form reading.
- **Alternative**: `google:Source Serif 4` for the full weight range, or
`google:Lora`.
- **How**: it is already assigned via the bundled manifest; to change, Site
Settings → Typography → Body → pick a serif → assign to the Body slot.
## Mono — evidence labels, case numbers, stamps - **Source**: `google:JetBrains Mono`
- **Family**: `JetBrains Mono`
- **Why**: tight mono with strong forms at 1011px, the spec's
caption-strip and frame-number face.
- **How**: Site Settings → Typography → Mono → Google Fonts →
search "JetBrains Mono" → Add → Assign to Mono slot.
- **Ideal**: `google:Courier Prime` (OFL) — clean courier, the report-typewriter ## Notes for wave-2
face.
- **Shipped default**: IBM Plex Mono 400 (bundled, OFL).
- **How**: Site Settings → Typography → Mono → Google Fonts → search
"Courier Prime" → Add → assign to the Mono slot.
## Licensing Once licensed woff2s are commissioned (e.g. a darkroom-grade display
face), drop them into `assets/fonts/web/` and declare them in
All bundled woff2 files are SIL OFL 1.1 latin subsets. See `fonts.json`. The CMS will emit `@font-face` blocks automatically; no
`assets/fonts/LICENSES.md`. Special Elite is Apache 2.0 and is *not* bundled — it `CSSManifest.InputCSSAppend` changes required. Add the licence to a
is only named as an admin-assignable first choice in the fallback stack. new `LICENSES.md` at the theme root and remove this `RECOMMENDED_FONTS.md`.

View File

@ -1,26 +0,0 @@
# Recommended Icons — Noir
This theme references icons from a single bundled pack. `plugin.mod` declares it
in `required_icon_packs`; until the loader auto-installs declared packs, an admin
should confirm it is enabled before activation.
## Required pack
- **lucide** — ships bundled with the CMS (v0.460). Enabled by default. No action
needed in most installs; if icons render as empty boxes, open `/admin/icons`
and confirm **Lucide** is installed and enabled.
## Icons used by the theme chrome and overrides
All via the sprite pipeline (`<svg><use href="/icons/lucide.svg#<name>"/></svg>`
or the `::lucide:<name>::` shorthand):
`arrow-right`, `check`, `chevron-down`, `chevron-left`, `chevron-right`, `x`,
`play`, `download`, `navigation`, `map-pin`, `star`, `flag`, `globe`, `search`,
`file-text`, `map-pin`.
Block content that stores an icon (feature-grid items, social links, contact
channels, timeline) may reference any installed pack as a `pack:name` string; the
schemas default to `lucide` so the theme renders without extra setup. Social
network glyphs (X, LinkedIn, GitHub) resolve from the bundled `simple-icons`
pack, which also ships with the CMS.

0
assets/.gitkeep Normal file
View File

View File

@ -1,21 +0,0 @@
# Bundled font licenses
All bundled woff2 files in this directory are latin-subset builds redistributed
under the SIL Open Font License 1.1 (OFL). No modifications were made beyond
subsetting to the latin range.
| Family | Files | License | Source |
|--------|-------|---------|--------|
| Source Serif 4 | source-serif-4-400.woff2, source-serif-4-400-italic.woff2 | SIL OFL 1.1 | Adobe / Google Fonts |
| IBM Plex Mono | ibm-plex-mono-400.woff2, ibm-plex-mono-500.woff2 | SIL OFL 1.1 | IBM / Google Fonts |
Full OFL text: `OFL-SourceSerif4.txt` in this directory and https://openfontlicense.org/.
These are the theme's shipped defaults for the typewriter-and-dossier stack. The
theme's fallback stacks name the ideal typewriter faces first — Special Elite
(Apache 2.0) and Courier Prime (OFL) — which an admin can assign from the Google
Fonts tab; IBM Plex Mono is the bundled fallback for both the heading (typed
display) and mono (evidence labels) slots, and Source Serif 4 is the reading
body. Font-family is admin-controlled: these families appear as `source=template`
rows in the fonts picker and are consumed only via
`var(--font-heading|body|mono, <fallback>)`.

View File

@ -1,24 +0,0 @@
Bundled fonts — license record (SIL Open Font License, Version 1.1)
====================================================================
The woff2 files in this directory are Latin subsets of the following
open-source families, both distributed under the SIL Open Font License 1.1
(https://openfontlicense.org). Subsetting and format conversion (TTF to
woff2) are expressly permitted by the OFL; the derived subsets remain under
the same license.
Playfair Display
playfair-display-700.woff2, playfair-display-900-italic.woff2
Copyright (c) Claus Eggers Sorensen (https://www.forthehearts.net), with
Reserved Font Name "Playfair Display". Display face for masthead wordmarks
and headlines.
Source Serif 4
source-serif-4-400.woff2, source-serif-4-400-italic.woff2
Copyright (c) Adobe (https://github.com/adobe-fonts/source-serif), with
Reserved Font Name "Source Serif". Long-read body face; italic for
marginalia and figure captions.
The full SIL Open Font License 1.1 text applies to each family and is
available at https://openfontlicense.org. This file records the license and
copyright attribution as required for redistribution under the OFL.

Binary file not shown.

Binary file not shown.

234
assets/style.css Normal file
View File

@ -0,0 +1,234 @@
/* ============================================================
Noir Theme Silver-on-black photography
Custom utility CSS injected via CSSManifest.InputCSSAppend.
============================================================ */
/* --- Typography slots ---------------------------------------- */
/* Body & page typography flow through the theme font variables.
Fallback stacks reflect the spec's intended faces (Tenor Sans,
Inter, JetBrains Mono) without hardcoding the family name. */
.noir-page {
font-family: var(--font-body, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
-webkit-font-smoothing: antialiased;
}
.noir-display {
font-family: var(--font-heading, "Tenor Sans", Georgia, serif);
letter-spacing: 0.01em;
text-decoration: none;
}
/* --- .tracked-mono ------------------------------------------- */
/* 10px JetBrains Mono, +60 tracking equivalent (0.18em), uppercase.
Used for captions, frame numbers, and the caption strip. */
.tracked-mono {
font-family: var(--font-mono, "JetBrains Mono", "Consolas", "SFMono-Regular", monospace);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
line-height: 1.4;
}
.tracked-mono-sm {
font-family: var(--font-mono, "JetBrains Mono", "Consolas", "SFMono-Regular", monospace);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
line-height: 1.4;
}
/* --- .bleed -------------------------------------------------- */
/* Edge-to-edge utility — breaks out of a max-width container. */
.bleed {
width: 100vw;
margin-left: calc((100vw - 100%) / 2 * -1);
margin-right: calc((100vw - 100%) / 2 * -1);
}
/* --- .hairline ----------------------------------------------- */
/* 1px almost-invisible border using the border token at 40% alpha. */
.hairline {
border: 1px solid hsl(var(--border) / 0.4);
}
.hairline-t { border-top: 1px solid hsl(var(--border) / 0.4); }
.hairline-b { border-bottom: 1px solid hsl(var(--border) / 0.4); }
/* --- Surface tokens used by overrides ------------------------ */
.noir-surface {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.noir-card {
background-color: transparent;
border: 1px solid hsl(var(--border) / 0.4);
}
/* Buttons render as hairline outlines that invert on hover.
Override block uses these classes directly. */
.noir-btn {
display: inline-block;
background-color: transparent;
color: hsl(var(--foreground));
border: 1px solid hsl(var(--foreground));
padding: 0.75rem 1.5rem;
font-family: var(--font-mono, "JetBrains Mono", "Consolas", "SFMono-Regular", monospace);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
text-decoration: none;
transition: background-color 0.18s ease, color 0.18s ease;
cursor: pointer;
}
.noir-btn:hover,
.noir-btn:focus-visible {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.noir-btn:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* --- Caption strip ------------------------------------------- */
.noir-caption-strip {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 1.5rem;
border-top: 1px solid hsl(var(--border) / 0.4);
border-bottom: 1px solid hsl(var(--border) / 0.4);
background-color: hsl(var(--background));
color: hsl(var(--mutedForeground));
}
/* --- Contact sheet sprocket motif ---------------------------- */
.noir-sprocket {
position: relative;
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border) / 0.4);
padding: 1.25rem 0.75rem 0.75rem;
}
.noir-sprocket::before,
.noir-sprocket::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 0.5rem;
/* Sprocket holes drawn as repeating radial-gradient circles
using the muted foreground token at low alpha. */
background-image: radial-gradient(
circle,
hsl(var(--mutedForeground) / 0.85) 0,
hsl(var(--mutedForeground) / 0.85) 25%,
transparent 27%,
transparent 100%
);
background-size: 1rem 0.5rem;
background-repeat: repeat-x;
background-position: 0 50%;
opacity: 0.35;
}
.noir-sprocket::before { top: 0; }
.noir-sprocket::after { bottom: 0; }
.noir-frame-number {
position: absolute;
top: 0.4rem;
left: 0.75rem;
}
/* --- Lightbox overlay ---------------------------------------- */
[data-noir-lightbox] {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
display: none;
z-index: 9999;
background-color: hsl(var(--background));
align-items: center;
justify-content: center;
flex-direction: column;
padding: 1.5rem;
animation: noirFadeIn 180ms ease forwards;
}
[data-noir-lightbox][aria-hidden="false"] {
display: flex;
}
[data-noir-lightbox] img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
[data-noir-lightbox] .noir-lightbox-caption {
margin-top: 1rem;
color: hsl(var(--mutedForeground));
}
[data-noir-lightbox] .noir-lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: 1px solid hsl(var(--border));
color: hsl(var(--foreground));
padding: 0.4rem 0.8rem;
cursor: pointer;
}
@keyframes noirFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* --- Sticky case-study meta rail ----------------------------- */
.noir-meta-rail {
position: sticky;
top: 1.5rem;
align-self: start;
color: hsl(var(--mutedForeground));
}
/* --- Image full-bleed override ------------------------------- */
.noir-figure {
margin: 0;
display: block;
}
.noir-figure img {
width: 100%;
height: auto;
display: block;
}
.noir-figure figcaption {
margin-top: 0.5rem;
color: hsl(var(--mutedForeground));
}
/* --- Reduced motion ------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
[data-noir-lightbox] { animation: none; }
}

View File

@ -1,34 +0,0 @@
# Noir theme block definitions (codeless .bnp, WO-TF-022).
# Detective / true-crime persona furniture ONLY. Everything that duplicates a
# builtin (galleries, footers, dividers, captions, image pairs) is shipped as a
# template OVERRIDE of the builtin key instead (see templates/overrides/noir/).
#
# Keys are fully qualified (noir:<key>) because the codeless loader registers
# definitions verbatim — there is no PluginBlockRegistry prefixing in the
# codeless path. The qualified keys match the data-block attributes, the
# master_pages.json references and the seed.
blocks:
- key: noir:case_file
title: Case File
description: A dossier card — case number, status stamp, subject and a set of field/value evidence rows.
category: content
schema: case_file.schema.json
template: case_file.ninjatpl
- key: noir:evidence_board
title: Evidence Board
description: A pinned board of evidence — photographs and notes with push-pins and case tags.
category: content
schema: evidence_board.schema.json
template: evidence_board.ninjatpl
- key: noir:redacted_quote
title: Redacted Quote
description: A transcript pull quote with a redaction bar over the withheld words that lifts on hover or focus.
category: content
schema: redacted_quote.schema.json
template: redacted_quote.ninjatpl
- key: noir:case_dossier
title: Case Dossier
description: Long-form case spread with a sticky evidence rail and typed prose.
category: layout
schema: case_dossier.schema.json
template: case_dossier.ninjatpl

View File

@ -1,26 +0,0 @@
<section data-block="noir:case_dossier" class="py-12">
<div class="mx-auto grid max-w-6xl grid-cols-1 gap-10 px-4 md:grid-cols-[16rem_1fr]">
<aside class="self-start md:sticky md:top-6">
<div class="noir-file p-5">
{% if status == "cold" %}<span class="noir-stamp" style="color:hsl(var(--muted-foreground));">Cold</span>{% elif status == "closed" %}<span class="noir-stamp" style="color:hsl(var(--destructive));">Closed</span>{% elif status == "active" %}<span class="noir-stamp" style="color:hsl(var(--primary));">Active</span>{% else %}<span class="noir-stamp" style="color:hsl(var(--primary));">Open</span>{% endif %}
<dl class="mt-6 space-y-4">
{% if caseNumber %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Case No.</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ caseNumber }}</dd></div>{% endif %}
{% if opened %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Opened</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ opened }}</dd></div>{% endif %}
{% if location %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Location</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ location }}</dd></div>{% endif %}
{% if lead %}<div><dt class="noir-label" style="color:hsl(var(--muted-foreground));">Lead</dt><dd class="noir-mono mt-1 text-sm" style="color:hsl(var(--foreground));">{{ lead }}</dd></div>{% endif %}
</dl>
</div>
</aside>
<div class="min-w-0">
{% if title %}<h1 class="noir-heading text-3xl leading-tight md:text-4xl" style="color:hsl(var(--foreground));">{{ title }}</h1>{% endif %}
{% if summary %}<p class="noir-body mt-6 text-lg leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ summary }}</p>{% endif %}
{% if images %}
<div class="mt-8 space-y-8">
{% for img in images %}{% if img %}<figure class="noir-file p-2"><div class="overflow-hidden bg-muted">{% img img alt=title|default:"" class="block h-auto w-full object-cover" %}</div></figure>{% endif %}{% endfor %}
</div>
{% else %}
<div class="mt-8 noir-hairline p-12 text-center noir-label" style="color:hsl(var(--muted-foreground));">Add photographs to the case spread.</div>
{% endif %}
</div>
</div>
</section>

View File

@ -1,60 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Case Dossier",
"description": "Long-form case spread with a sticky evidence rail and typed prose.",
"type": "object",
"properties": {
"title": {
"type": "string",
"title": "Title",
"description": "Case title.",
"x-editor": "text"
},
"caseNumber": {
"type": "string",
"title": "Case Number",
"description": "File reference (e.g. 'CF-0451').",
"x-editor": "text"
},
"status": {
"type": "string",
"title": "Status",
"description": "Status stamp shown on the rail.",
"x-editor": "select",
"enum": ["open", "active", "cold", "closed"],
"default": "open"
},
"opened": {
"type": "string",
"title": "Opened",
"description": "When the case was opened (e.g. 'March 1998').",
"x-editor": "text"
},
"location": {
"type": "string",
"title": "Location",
"description": "Case location.",
"x-editor": "text"
},
"lead": {
"type": "string",
"title": "Lead Investigator",
"description": "Name of the lead on the file.",
"x-editor": "text"
},
"summary": {
"type": "string",
"title": "Summary",
"description": "Opening summary of the case.",
"x-editor": "textarea"
},
"images": {
"type": "array",
"title": "Images",
"description": "Photographs in the case spread.",
"x-editor": "array",
"default": [],
"items": { "type": "string", "x-editor": "media" }
}
}
}

View File

@ -1,24 +0,0 @@
<section data-block="noir:case_file" class="py-10">
<div class="mx-auto max-w-3xl px-4">
<article class="noir-file noir-grain p-6 md:p-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
{% if caseNumber %}<div class="noir-label" style="color:hsl(var(--primary));">Case No. {{ caseNumber }}</div>{% endif %}
{% if subject %}<h3 class="noir-heading mt-2 text-2xl md:text-3xl" style="color:hsl(var(--foreground));">{{ subject }}</h3>{% endif %}
</div>
{% if status == "cold" %}<span class="noir-stamp" style="color:hsl(var(--muted-foreground));">Cold</span>{% elif status == "closed" %}<span class="noir-stamp" style="color:hsl(var(--destructive));">Closed</span>{% elif status == "active" %}<span class="noir-stamp" style="color:hsl(var(--primary));">Active</span>{% else %}<span class="noir-stamp" style="color:hsl(var(--primary));">Open</span>{% endif %}
</div>
{% if summary %}<p class="noir-body mt-4 leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ summary }}</p>{% endif %}
{% if fields %}
<dl class="mt-6 border-t border-[hsl(var(--border))]">
{% for row in fields %}
<div class="flex items-baseline justify-between gap-4 border-b border-[hsl(var(--border))] py-2">
<dt class="noir-label" style="color:hsl(var(--muted-foreground));">{{ row.label }}</dt>
<dd class="noir-mono text-right text-sm" style="color:hsl(var(--foreground));">{{ row.value }}</dd>
</div>
{% endfor %}
</dl>
{% endif %}
</article>
</div>
</section>

View File

@ -1,49 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Case File",
"description": "A dossier card — case number, status stamp, subject and a set of field/value evidence rows.",
"type": "object",
"properties": {
"caseNumber": {
"type": "string",
"title": "Case Number",
"description": "File reference (e.g. 'CF-0451').",
"x-editor": "text"
},
"subject": {
"type": "string",
"title": "Subject",
"description": "The name or subject of the case.",
"x-editor": "text"
},
"status": {
"type": "string",
"title": "Status",
"description": "Status stamp shown on the file.",
"x-editor": "select",
"enum": ["open", "active", "cold", "closed"],
"default": "open"
},
"summary": {
"type": "string",
"title": "Summary",
"description": "Short case summary.",
"x-editor": "textarea"
},
"fields": {
"type": "array",
"title": "Evidence Rows",
"description": "Field/value rows shown as a dossier record.",
"x-editor": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"label": { "type": "string", "title": "Field", "x-editor": "text" },
"value": { "type": "string", "title": "Value", "x-editor": "text" }
},
"required": ["label", "value"]
}
}
}
}

View File

@ -1,19 +0,0 @@
<section data-block="noir:evidence_board" class="py-12">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="noir-heading text-center text-2xl md:text-3xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="noir-body mx-auto mt-3 max-w-2xl text-center leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
{% if items %}
<div class="mt-14 grid grid-cols-1 gap-x-8 gap-y-14 sm:grid-cols-2 lg:grid-cols-3">
{% for item in items %}
<figure class="noir-pin noir-file p-3">
{% if item.image %}<div class="overflow-hidden bg-muted grayscale">{% img item.image alt=item.label|default:"" class="aspect-[4/5] w-full object-cover" %}</div>{% else %}<div class="aspect-[4/5] w-full noir-hairline" style="background-color:hsl(var(--muted));"></div>{% endif %}
{% if item.label %}<figcaption class="noir-label mt-3" style="color:hsl(var(--primary));">{{ item.label }}</figcaption>{% endif %}
{% if item.note %}<p class="noir-mono mt-1 text-sm leading-snug" style="color:hsl(var(--muted-foreground));">{{ item.note }}</p>{% endif %}
</figure>
{% endfor %}
</div>
{% else %}
<div class="mt-10 noir-hairline p-12 text-center noir-label" style="color:hsl(var(--muted-foreground));">Pin evidence to the board.</div>
{% endif %}
</div>
</section>

View File

@ -1,36 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Evidence Board",
"description": "A pinned board of evidence — photographs and notes with push-pins and case tags.",
"type": "object",
"properties": {
"heading": {
"type": "string",
"title": "Heading",
"description": "Board heading.",
"x-editor": "text"
},
"intro": {
"type": "string",
"title": "Intro",
"description": "Short introduction beneath the heading.",
"x-editor": "textarea"
},
"items": {
"type": "array",
"title": "Evidence",
"description": "Pinned evidence items.",
"x-editor": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"image": { "type": "string", "title": "Photograph", "x-editor": "media" },
"label": { "type": "string", "title": "Tag", "x-editor": "text" },
"note": { "type": "string", "title": "Note", "x-editor": "text" }
},
"required": ["image"]
}
}
}
}

View File

@ -1,14 +0,0 @@
<figure data-block="noir:redacted_quote" class="my-10">
<div class="mx-auto max-w-3xl px-4">
<div class="noir-file p-8 md:p-10">
{% if label %}<div class="noir-label" style="color:hsl(var(--primary));">{{ label }}</div>{% else %}<div class="noir-label" style="color:hsl(var(--primary));">Transcript excerpt</div>{% endif %}
{% if quote %}<blockquote class="noir-body mt-4 text-xl leading-relaxed md:text-2xl" style="color:hsl(var(--foreground));">{{ quote }}{% if redacted %} <span class="noir-redact" tabindex="0" role="button" aria-label="Redacted — reveal">{{ redacted }}</span>{% endif %}</blockquote>{% endif %}
{% if attribution or source %}
<figcaption class="mt-6">
{% if attribution %}<div class="noir-label" style="color:hsl(var(--foreground));">{{ attribution }}</div>{% endif %}
{% if source %}<div class="noir-mono mt-1 text-sm" style="color:hsl(var(--muted-foreground));">{{ source }}</div>{% endif %}
</figcaption>
{% endif %}
</div>
</div>
</figure>

View File

@ -1,38 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Redacted Quote",
"description": "A transcript pull quote with a redaction bar over the withheld words that lifts on hover or focus.",
"type": "object",
"properties": {
"label": {
"type": "string",
"title": "Label",
"description": "Small label above the quote (defaults to 'Transcript excerpt').",
"x-editor": "text"
},
"quote": {
"type": "string",
"title": "Quote",
"description": "The visible transcript text.",
"x-editor": "textarea"
},
"redacted": {
"type": "string",
"title": "Redacted Phrase",
"description": "Withheld words rendered as a redaction bar that lifts on hover or focus.",
"x-editor": "text"
},
"attribution": {
"type": "string",
"title": "Attribution",
"description": "Who said it.",
"x-editor": "text"
},
"source": {
"type": "string",
"title": "Source",
"description": "Where the excerpt is from.",
"x-editor": "text"
}
}
}

36
caption_strip.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// CaptionStripBlockMeta defines the Noir caption strip block.
var CaptionStripBlockMeta = blocks.BlockMeta{
Key: "caption_strip",
Title: "Caption Strip",
Description: "Full-width 10px mono caption strip with a label on the left and supporting text on the right.",
Source: "noir",
Category: blocks.CategoryNavigation,
}
// CaptionStripBlock renders a thin caption strip.
// Content shape: {"label":"INDEX","right":"© Studio"}
func CaptionStripBlock(ctx context.Context, content map[string]any) string {
data := CaptionStripData{
Label: getString(content, "label"),
Right: getString(content, "right"),
}
var buf bytes.Buffer
_ = captionStripComponent(data).Render(ctx, &buf)
return buf.String()
}
// CaptionStripData holds the parsed view-model.
type CaptionStripData struct {
Label string
Right string
}

9
caption_strip.templ Normal file
View File

@ -0,0 +1,9 @@
package main
// captionStripComponent renders the 10px mono caption strip.
templ captionStripComponent(data CaptionStripData) {
<div data-block="noir:caption_strip" class="noir-caption-strip">
<span class="tracked-mono">{ data.Label }</span>
<span class="tracked-mono">{ data.Right }</span>
</div>
}

67
caption_strip_templ.go Normal file
View File

@ -0,0 +1,67 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// captionStripComponent renders the 10px mono caption strip.
func captionStripComponent(data CaptionStripData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div data-block=\"noir:caption_strip\" class=\"noir-caption-strip\"><span class=\"tracked-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `caption_strip.templ`, Line: 6, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"tracked-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Right)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `caption_strip.templ`, Line: 7, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

48
case_study.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// CaseStudyBlockMeta defines the Noir long-form project case study block.
var CaseStudyBlockMeta = blocks.BlockMeta{
Key: "case_study",
Title: "Project Case Study",
Description: "Long-form project spread with sticky meta rail and image-led prose.",
Source: "noir",
Category: blocks.CategoryLayout,
}
// CaseStudyBlock renders a project case study.
// Content shape: {"title":"...","client":"...","year":2025,"credits":["...","..."],"images":["...","..."]}
func CaseStudyBlock(ctx context.Context, content map[string]any) string {
data := CaseStudyData{
Title: getString(content, "title"),
Client: getString(content, "client"),
Year: getInt(content, "year", 0),
Credits: getStringSlice(content, "credits"),
}
for _, img := range getStringSlice(content, "images") {
if img == "" {
continue
}
data.Images = append(data.Images, blocks.ResolveMediaPath(img))
}
var buf bytes.Buffer
_ = caseStudyComponent(data).Render(ctx, &buf)
return buf.String()
}
// CaseStudyData holds the parsed view-model for the case study block.
type CaseStudyData struct {
Title string
Client string
Year int
Credits []string
Images []string
}

53
case_study.templ Normal file
View File

@ -0,0 +1,53 @@
package main
// caseStudyComponent renders a project case-study spread with a sticky meta rail.
templ caseStudyComponent(data CaseStudyData) {
<section data-block="noir:case_study" class="py-12">
<div class="max-w-6xl mx-auto px-6 grid grid-cols-1 md:grid-cols-[14rem_1fr] gap-10">
<aside class="noir-meta-rail tracked-mono space-y-3">
if data.Client != "" {
<div>
<div style="color: hsl(var(--mutedForeground));">Client</div>
<div class="mt-1" style="color: hsl(var(--foreground));">{ data.Client }</div>
</div>
}
if data.Year > 0 {
<div>
<div style="color: hsl(var(--mutedForeground));">Year</div>
<div class="mt-1" style="color: hsl(var(--foreground));">{ intToString(data.Year) }</div>
</div>
}
if len(data.Credits) > 0 {
<div>
<div style="color: hsl(var(--mutedForeground));">Credits</div>
<ul class="mt-1 space-y-1" style="color: hsl(var(--foreground));">
for _, credit := range data.Credits {
<li>{ credit }</li>
}
</ul>
</div>
}
</aside>
<div class="min-w-0">
if data.Title != "" {
<h1 class="noir-display mb-10" style="font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; color: hsl(var(--foreground));">
{ data.Title }
</h1>
}
if len(data.Images) > 0 {
<div class="space-y-8">
for _, img := range data.Images {
<figure class="noir-figure">
<img src={ img } alt={ data.Title } loading="lazy" class="w-full h-auto block"/>
</figure>
}
</div>
} else {
<div class="hairline p-12 text-center tracked-mono" style="color: hsl(var(--mutedForeground));">
Add photographs to the case study spread.
</div>
}
</div>
</div>
</section>
}

182
case_study_templ.go Normal file
View File

@ -0,0 +1,182 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// caseStudyComponent renders a project case-study spread with a sticky meta rail.
func caseStudyComponent(data CaseStudyData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:case_study\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6 grid grid-cols-1 md:grid-cols-[14rem_1fr] gap-10\"><aside class=\"noir-meta-rail tracked-mono space-y-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Client != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div><div style=\"color: hsl(var(--mutedForeground));\">Client</div><div class=\"mt-1\" style=\"color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Client)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 11, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.Year > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><div style=\"color: hsl(var(--mutedForeground));\">Year</div><div class=\"mt-1\" style=\"color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(intToString(data.Year))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 17, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.Credits) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div><div style=\"color: hsl(var(--mutedForeground));\">Credits</div><ul class=\"mt-1 space-y-1\" style=\"color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, credit := range data.Credits {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(credit)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 25, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</aside><div class=\"min-w-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Title != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h1 class=\"noir-display mb-10\" style=\"font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 34, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.Images) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"space-y-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, img := range data.Images {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<figure class=\"noir-figure\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(img)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 41, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `case_study.templ`, Line: 41, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" loading=\"lazy\" class=\"w-full h-auto block\"></figure>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"hairline p-12 text-center tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Add photographs to the case study spread.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

55
contact_sheet.go Normal file
View File

@ -0,0 +1,55 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// ContactSheetBlockMeta defines the Noir contact sheet block.
var ContactSheetBlockMeta = blocks.BlockMeta{
Key: "contact_sheet",
Title: "Contact Sheet",
Description: "Numbered photograph frames with sprocket motif evoking a darkroom contact sheet.",
Source: "noir",
Category: blocks.CategoryContent,
}
// ContactSheetBlock renders a contact-sheet grid.
// Content shape: {"items":[{"image":"...","frame":"01","label":"..."}, ...]}
func ContactSheetBlock(ctx context.Context, content map[string]any) string {
items := getSlice(content, "items")
var frames []ContactFrame
for i, item := range items {
frame := getString(item, "frame")
if frame == "" {
frame = padFrame(i + 1)
}
frames = append(frames, ContactFrame{
Image: blocks.ResolveMediaPath(getString(item, "image")),
Frame: frame,
Label: getString(item, "label"),
})
}
var buf bytes.Buffer
_ = contactSheetComponent(frames).Render(ctx, &buf)
return buf.String()
}
// ContactFrame represents a single numbered frame in the contact sheet.
type ContactFrame struct {
Image string
Frame string
Label string
}
// padFrame zero-pads a frame number to two digits ("01", "02", ..., "10").
func padFrame(n int) string {
if n < 10 {
return "0" + intToString(n)
}
return intToString(n)
}

39
contact_sheet.templ Normal file
View File

@ -0,0 +1,39 @@
package main
// contactSheetComponent renders the contact sheet grid with sprocket frames.
templ contactSheetComponent(frames []ContactFrame) {
<section data-block="noir:contact_sheet" class="py-12">
<div class="max-w-6xl mx-auto px-6">
if len(frames) == 0 {
<div class="hairline p-12 text-center tracked-mono" style="color: hsl(var(--mutedForeground));">
Add frames to populate the contact sheet.
</div>
} else {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
for _, frame := range frames {
<figure class="noir-sprocket noir-figure" data-frame-number={ frame.Frame }>
<span class="noir-frame-number tracked-mono" style="color: hsl(var(--mutedForeground));">
{ frame.Frame }
</span>
if frame.Image != "" {
<img
src={ frame.Image }
alt={ frame.Label }
loading="lazy"
class="w-full h-auto block mt-2"
/>
} else {
<div class="aspect-square hairline" style="background-color: hsl(var(--muted));"></div>
}
if frame.Label != "" {
<figcaption class="tracked-mono mt-2" style="color: hsl(var(--mutedForeground));">
{ frame.Label }
</figcaption>
}
</figure>
}
</div>
}
</div>
</section>
}

152
contact_sheet_templ.go Normal file
View File

@ -0,0 +1,152 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// contactSheetComponent renders the contact sheet grid with sprocket frames.
func contactSheetComponent(frames []ContactFrame) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:contact_sheet\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(frames) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"hairline p-12 text-center tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Add frames to populate the contact sheet.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, frame := range frames {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<figure class=\"noir-sprocket noir-figure\" data-frame-number=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(frame.Frame)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 14, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><span class=\"noir-frame-number tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(frame.Frame)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 16, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if frame.Image != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(frame.Image)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 20, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(frame.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 21, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" loading=\"lazy\" class=\"w-full h-auto block mt-2\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"aspect-square hairline\" style=\"background-color: hsl(var(--muted));\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if frame.Label != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<figcaption class=\"tracked-mono mt-2\" style=\"color: hsl(var(--mutedForeground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(frame.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `contact_sheet.templ`, Line: 30, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</figcaption>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</figure>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

136
email_wrapper.templ Normal file
View File

@ -0,0 +1,136 @@
package main
import (
"bytes"
"context"
"fmt"
"git.dev.alexdunmow.com/block/core/templates"
)
// NoirEmailWrapper wraps body content in a pure black 600px canvas with a
// Tenor Sans masthead and a mono caption strip above the footer.
func NoirEmailWrapper(body string, emailCtx templates.EmailContext) string {
var buf bytes.Buffer
_ = noirEmailTemplate(emailCtx, body).Render(context.Background(), &buf)
return buf.String()
}
// noirEmailTemplate renders the inline-styled HTML email wrapper.
templ noirEmailTemplate(emailCtx templates.EmailContext, body string) {
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="x-apple-disable-message-reformatting"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>{ emailCtx.SiteSettings.SiteName }</title>
</head>
<body style={ fmt.Sprintf("background-color: %s; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;", noirEmailBg(emailCtx)) }>
if emailCtx.PreviewText != "" {
<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">{ emailCtx.PreviewText }</div>
}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style={ fmt.Sprintf("background-color: %s;", noirEmailBg(emailCtx)) }>
<tr>
<td align="center" style="padding: 48px 12px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style={ fmt.Sprintf("width: 600px; max-width: 600px; background-color: %s; border: 1px solid %s;", noirEmailCard(emailCtx), noirEmailBorder(emailCtx)) }>
<!-- Masthead -->
<tr>
<td align="left" style={ fmt.Sprintf("padding: 28px 32px; border-bottom: 1px solid %s;", noirEmailBorder(emailCtx)) }>
if emailCtx.SiteSettings.SiteName != "" {
<div style={ fmt.Sprintf("margin: 0; font-size: 18px; font-weight: 400; font-family: 'Tenor Sans', Georgia, serif; letter-spacing: 0.05em; color: %s;", noirEmailFg(emailCtx)) }>
{ emailCtx.SiteSettings.SiteName }
</div>
}
</td>
</tr>
<!-- Cover image (16:10) -->
if emailCtx.SiteSettings.LogoURL != "" {
<tr>
<td style="padding: 0; line-height: 0;">
<img src={ emailCtx.SiteSettings.LogoURL } alt={ emailCtx.SiteSettings.SiteName } width="600" height="375" style="display: block; width: 600px; height: 375px; object-fit: cover; max-width: 100%;"/>
</td>
</tr>
}
<!-- Body -->
<tr>
<td style={ fmt.Sprintf("padding: 32px; color: %s; font-size: 16px; line-height: 1.7;", noirEmailFg(emailCtx)) }>
@templ.Raw(body)
</td>
</tr>
<!-- Mono caption strip -->
<tr>
<td style={ fmt.Sprintf("padding: 12px 32px; border-top: 1px solid %s; border-bottom: 1px solid %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: %s;", noirEmailBorder(emailCtx), noirEmailBorder(emailCtx), noirEmailMutedFg(emailCtx)) }>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="left">
if emailCtx.SiteSettings.SiteName != "" {
{ "© " + emailCtx.SiteSettings.SiteName }
}
</td>
<td align="right">
if emailCtx.UnsubscribeURL != "" {
<a href={ templ.SafeURL(emailCtx.UnsubscribeURL) } style={ fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)) }>
Unsubscribe
</a>
}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" style={ fmt.Sprintf("padding: 20px 32px; color: %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;", noirEmailMutedFg(emailCtx)) }>
if emailCtx.SiteSettings.SiteURL != "" {
<a href={ templ.SafeURL(emailCtx.SiteSettings.SiteURL) } style={ fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)) }>
{ emailCtx.SiteSettings.SiteURL }
</a>
}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
}
// noirEmailBg returns the canvas colour (pure black per spec) with a fallback
// derived from the EmailContext.
func noirEmailBg(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Background != "" {
return emailCtx.Colors.Background
}
return "#000000"
}
func noirEmailCard(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Card != "" {
return emailCtx.Colors.Card
}
return "#0a0a0a"
}
func noirEmailFg(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Foreground != "" {
return emailCtx.Colors.Foreground
}
return "#f5f5f5"
}
func noirEmailMutedFg(emailCtx templates.EmailContext) string {
if emailCtx.Colors.MutedForeground != "" {
return emailCtx.Colors.MutedForeground
}
return "#8c8c8c"
}
func noirEmailBorder(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Border != "" {
return emailCtx.Colors.Border
}
return "#1f1f1f"
}

401
email_wrapper_templ.go Normal file
View File

@ -0,0 +1,401 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"bytes"
"context"
"fmt"
"git.dev.alexdunmow.com/block/core/templates"
)
// NoirEmailWrapper wraps body content in a pure black 600px canvas with a
// Tenor Sans masthead and a mono caption strip above the footer.
func NoirEmailWrapper(body string, emailCtx templates.EmailContext) string {
var buf bytes.Buffer
_ = noirEmailTemplate(emailCtx, body).Render(context.Background(), &buf)
return buf.String()
}
// noirEmailTemplate renders the inline-styled HTML email wrapper.
func noirEmailTemplate(emailCtx templates.EmailContext, body string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta name=\"x-apple-disable-message-reformatting\"><meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 28, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title></head><body style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background-color: %s; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;", noirEmailBg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 30, Col: 177}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.PreviewText != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div style=\"display: none; max-height: 0; overflow: hidden; mso-hide: all;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.PreviewText)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 32, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background-color: %s;", noirEmailBg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 34, Col: 152}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><tr><td align=\"center\" style=\"padding: 48px 12px;\"><table role=\"presentation\" width=\"600\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: 600px; max-width: 600px; background-color: %s; border: 1px solid %s;", noirEmailCard(emailCtx), noirEmailBorder(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 37, Col: 237}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><!-- Masthead --><tr><td align=\"left\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 28px 32px; border-bottom: 1px solid %s;", noirEmailBorder(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 40, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.SiteSettings.SiteName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("margin: 0; font-size: 18px; font-weight: 400; font-family: 'Tenor Sans', Georgia, serif; letter-spacing: 0.05em; color: %s;", noirEmailFg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 42, Col: 183}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 43, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td></tr><!-- Cover image (16:10) -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.SiteSettings.LogoURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr><td style=\"padding: 0; line-height: 0;\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(emailCtx.SiteSettings.LogoURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 52, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 52, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" width=\"600\" height=\"375\" style=\"display: block; width: 600px; height: 375px; object-fit: cover; max-width: 100%;\"></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Body --><tr><td style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 32px; color: %s; font-size: 16px; line-height: 1.7;", noirEmailFg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 58, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(body).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td></tr><!-- Mono caption strip --><tr><td style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 12px 32px; border-top: 1px solid %s; border-bottom: 1px solid %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: %s;", noirEmailBorder(emailCtx), noirEmailBorder(emailCtx), noirEmailMutedFg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 64, Col: 321}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\"><tr><td align=\"left\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.SiteSettings.SiteName != "" {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs("© " + emailCtx.SiteSettings.SiteName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 69, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td align=\"right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.UnsubscribeURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(emailCtx.UnsubscribeURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 74, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 74, Col: 147}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">Unsubscribe</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td></tr></table></td></tr><!-- Footer --><tr><td align=\"center\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("padding: 20px 32px; color: %s; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;", noirEmailMutedFg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 85, Col: 227}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if emailCtx.SiteSettings.SiteURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(emailCtx.SiteSettings.SiteURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 87, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color: %s; text-decoration: none;", noirEmailMutedFg(emailCtx)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 87, Col: 150}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 88, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td></tr></table></td></tr></table></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// noirEmailBg returns the canvas colour (pure black per spec) with a fallback
// derived from the EmailContext.
func noirEmailBg(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Background != "" {
return emailCtx.Colors.Background
}
return "#000000"
}
func noirEmailCard(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Card != "" {
return emailCtx.Colors.Card
}
return "#0a0a0a"
}
func noirEmailFg(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Foreground != "" {
return emailCtx.Colors.Foreground
}
return "#f5f5f5"
}
func noirEmailMutedFg(emailCtx templates.EmailContext) string {
if emailCtx.Colors.MutedForeground != "" {
return emailCtx.Colors.MutedForeground
}
return "#8c8c8c"
}
func noirEmailBorder(emailCtx templates.EmailContext) string {
if emailCtx.Colors.Border != "" {
return emailCtx.Colors.Border
}
return "#1f1f1f"
}
var _ = templruntime.GeneratedTemplate

64
embed.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"embed"
"io/fs"
"net/http"
"git.dev.alexdunmow.com/block/core/plugin"
)
//go:embed assets/*
var assetsFS embed.FS
//go:embed schemas/*
var schemasFS embed.FS
//go:embed presets.json
var presetsData []byte
//go:embed fonts.json
var fontsData []byte
//go:embed plugin.mod
var pluginModBytes []byte
// Assets returns the embedded assets filesystem.
func Assets() fs.FS {
sub, _ := fs.Sub(assetsFS, "assets")
return sub
}
// Schemas returns the embedded schemas filesystem.
func Schemas() fs.FS {
sub, _ := fs.Sub(schemasFS, "schemas")
return sub
}
// AssetsHandler returns an http.Handler that serves the embedded assets.
func AssetsHandler() http.Handler {
return http.FileServer(http.FS(Assets()))
}
// ThemePresets returns the embedded theme presets JSON.
func ThemePresets() []byte {
return presetsData
}
// BundledFonts returns the embedded fonts manifest JSON.
func BundledFonts() []byte {
return fontsData
}
// ThemeCSSManifest exposes Noir's custom utility CSS (.bleed, .tracked-mono,
// .hairline, lightbox keyframes) to the host Tailwind input so the classes
// survive the content scanner.
func ThemeCSSManifest() *plugin.CSSManifest {
css, err := assetsFS.ReadFile("assets/style.css")
if err != nil {
return &plugin.CSSManifest{}
}
return &plugin.CSSManifest{
InputCSSAppend: string(css),
}
}

View File

@ -1,18 +1 @@
[ []
{
"name": "Source Serif 4",
"family": "Source Serif 4",
"variants": [
{ "weight": "400", "style": "normal", "file": "fonts/source-serif-4-400.woff2" },
{ "weight": "400", "style": "italic", "file": "fonts/source-serif-4-400-italic.woff2" }
]
},
{
"name": "IBM Plex Mono",
"family": "IBM Plex Mono",
"variants": [
{ "weight": "400", "style": "normal", "file": "fonts/ibm-plex-mono-400.woff2" },
{ "weight": "500", "style": "normal", "file": "fonts/ibm-plex-mono-500.woff2" }
]
}
]

48
footer.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// FooterBlockMeta defines the Noir dissolved-rail footer block.
var FooterBlockMeta = blocks.BlockMeta{
Key: "footer",
Title: "Noir Footer",
Description: "Dissolved bottom rail with optional colophon and social links.",
Source: "noir",
Category: blocks.CategoryLayout,
}
// FooterBlock renders the Noir footer.
// Content shape: {"showColophon":"true","social":[{"text":"Instagram","url":"..."}, ...]}
func FooterBlock(ctx context.Context, content map[string]any) string {
data := FooterData{
ShowColophon: getBoolish(content, "showColophon", true),
}
for _, item := range getSlice(content, "social") {
data.Social = append(data.Social, FooterLink{
Text: getString(item, "text"),
URL: getString(item, "url"),
})
}
var buf bytes.Buffer
_ = footerComponent(data).Render(ctx, &buf)
return buf.String()
}
// FooterData holds the parsed view-model for the Noir footer.
type FooterData struct {
ShowColophon bool
Social []FooterLink
}
// FooterLink is a single social-link entry.
type FooterLink struct {
Text string
URL string
}

33
footer.templ Normal file
View File

@ -0,0 +1,33 @@
package main
// footerComponent renders the Noir footer rail.
templ footerComponent(data FooterData) {
<div data-block="noir:footer" class="hairline-t" style="padding: 1rem 1.5rem;">
<div class="max-w-6xl mx-auto flex items-center justify-between gap-4 flex-wrap">
if data.ShowColophon {
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">
Designed and printed in the darkroom.
</p>
} else {
<span></span>
}
if len(data.Social) > 0 {
<ul class="flex items-center gap-4 list-none p-0 m-0">
for _, link := range data.Social {
<li>
<a
href={ templ.SafeURL(link.URL) }
rel="noopener noreferrer"
target="_blank"
class="tracked-mono"
style="color: hsl(var(--mutedForeground)); text-decoration: none;"
>
{ link.Text }
</a>
</li>
}
</ul>
}
</div>
</div>
}

98
footer_templ.go Normal file
View File

@ -0,0 +1,98 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// footerComponent renders the Noir footer rail.
func footerComponent(data FooterData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div data-block=\"noir:footer\" class=\"hairline-t\" style=\"padding: 1rem 1.5rem;\"><div class=\"max-w-6xl mx-auto flex items-center justify-between gap-4 flex-wrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ShowColophon {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<p class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Designed and printed in the darkroom.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<span></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.Social) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<ul class=\"flex items-center gap-4 list-none p-0 m-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, link := range data.Social {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<li><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(link.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `footer.templ`, Line: 19, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" rel=\"noopener noreferrer\" target=\"_blank\" class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground)); text-decoration: none;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(link.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `footer.templ`, Line: 25, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

20
go.mod Normal file
View File

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

44
go.sum Normal file
View File

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

75
helpers.go Normal file
View File

@ -0,0 +1,75 @@
package main
import "strconv"
// getString extracts a string value from content map. Returns "" if missing or wrong type.
func getString(content map[string]any, key string) string {
if v, ok := content[key].(string); ok {
return v
}
return ""
}
// getInt extracts an int value from content map (handles float64 from JSON).
func getInt(content map[string]any, key string, defaultVal int) int {
if v, ok := content[key].(float64); ok {
return int(v)
}
if v, ok := content[key].(int); ok {
return v
}
if v, ok := content[key].(string); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return defaultVal
}
// getSlice extracts a slice of maps from content. Returns nil if missing.
func getSlice(content map[string]any, key string) []map[string]any {
if v, ok := content[key].([]any); ok {
result := make([]map[string]any, 0, len(v))
for _, item := range v {
if m, ok := item.(map[string]any); ok {
result = append(result, m)
}
}
return result
}
return nil
}
// getStringSlice extracts a slice of strings from content. Non-string entries are coerced via fmt.Sprintf.
// Returns nil if missing.
func getStringSlice(content map[string]any, key string) []string {
if v, ok := content[key].([]any); ok {
result := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}
if v, ok := content[key].([]string); ok {
return v
}
return nil
}
// getBoolish accepts "true"/"false" strings and booleans. Returns defaultVal otherwise.
func getBoolish(content map[string]any, key string, defaultVal bool) bool {
if v, ok := content[key].(bool); ok {
return v
}
if v, ok := content[key].(string); ok {
switch v {
case "true":
return true
case "false":
return false
}
}
return defaultVal
}

38
image_pair.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// ImagePairBlockMeta defines the Noir diptych block.
var ImagePairBlockMeta = blocks.BlockMeta{
Key: "image_pair",
Title: "Image Pair",
Description: "50/50 photograph diptych with a shared caption beneath both images.",
Source: "noir",
Category: blocks.CategoryContent,
}
// ImagePairBlock renders a two-photograph diptych.
// Content shape: {"left":"...","right":"...","caption":"..."}
func ImagePairBlock(ctx context.Context, content map[string]any) string {
data := ImagePairData{
Left: blocks.ResolveMediaPath(getString(content, "left")),
Right: blocks.ResolveMediaPath(getString(content, "right")),
Caption: getString(content, "caption"),
}
var buf bytes.Buffer
_ = imagePairComponent(data).Render(ctx, &buf)
return buf.String()
}
// ImagePairData holds the parsed view-model for the diptych.
type ImagePairData struct {
Left string
Right string
Caption string
}

30
image_pair.templ Normal file
View File

@ -0,0 +1,30 @@
package main
// imagePairComponent renders a 50/50 photograph diptych with a shared caption.
templ imagePairComponent(data ImagePairData) {
<section data-block="noir:image_pair" class="py-12">
<div class="max-w-6xl mx-auto px-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" style="grid-template-columns: 1fr 1fr;">
if data.Left != "" {
<figure class="noir-figure">
<img src={ data.Left } alt={ data.Caption } loading="lazy" class="w-full h-auto block"/>
</figure>
} else {
<div class="hairline aspect-[4/5]" style="background-color: hsl(var(--muted));"></div>
}
if data.Right != "" {
<figure class="noir-figure">
<img src={ data.Right } alt={ data.Caption } loading="lazy" class="w-full h-auto block"/>
</figure>
} else {
<div class="hairline aspect-[4/5]" style="background-color: hsl(var(--muted));"></div>
}
</div>
if data.Caption != "" {
<p class="tracked-mono mt-3 text-center" style="color: hsl(var(--mutedForeground));">
{ data.Caption }
</p>
}
</div>
</section>
}

142
image_pair_templ.go Normal file
View File

@ -0,0 +1,142 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// imagePairComponent renders a 50/50 photograph diptych with a shared caption.
func imagePairComponent(data ImagePairData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:image_pair\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\" style=\"grid-template-columns: 1fr 1fr;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Left != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<figure class=\"noir-figure\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Left)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 10, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 10, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" loading=\"lazy\" class=\"w-full h-auto block\"></figure>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"hairline aspect-[4/5]\" style=\"background-color: hsl(var(--muted));\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.Right != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<figure class=\"noir-figure\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Right)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 17, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 17, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" loading=\"lazy\" class=\"w-full h-auto block\"></figure>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"hairline aspect-[4/5]\" style=\"background-color: hsl(var(--muted));\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Caption != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p class=\"tracked-mono mt-3 text-center\" style=\"color: hsl(var(--mutedForeground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.Caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `image_pair.templ`, Line: 25, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

45
lightbox_gallery.go Normal file
View File

@ -0,0 +1,45 @@
package main
import (
"bytes"
"context"
"git.dev.alexdunmow.com/block/core/blocks"
)
// LightboxGalleryBlockMeta defines the Noir lightbox gallery block.
var LightboxGalleryBlockMeta = blocks.BlockMeta{
Key: "lightbox_gallery",
Title: "Lightbox Gallery",
Description: "Grid of photographs that expand into a full-viewport lightbox overlay on click.",
Source: "noir",
Category: blocks.CategoryContent,
}
// LightboxGalleryBlock renders the gallery from content.items + content.columns.
// Content shape: {"items":[{"image":"...","caption":"..."}, ...], "columns": 2|3|4}
func LightboxGalleryBlock(ctx context.Context, content map[string]any) string {
items := getSlice(content, "items")
cols := getInt(content, "columns", 3)
if cols != 2 && cols != 3 && cols != 4 {
cols = 3
}
var entries []LightboxItem
for _, item := range items {
entries = append(entries, LightboxItem{
Image: blocks.ResolveMediaPath(getString(item, "image")),
Caption: getString(item, "caption"),
})
}
var buf bytes.Buffer
_ = lightboxGalleryComponent(entries, cols).Render(ctx, &buf)
return buf.String()
}
// LightboxItem represents one photograph in the gallery.
type LightboxItem struct {
Image string
Caption string
}

87
lightbox_gallery.templ Normal file
View File

@ -0,0 +1,87 @@
package main
// lightboxGalleryComponent renders the gallery grid with lightbox triggers.
templ lightboxGalleryComponent(items []LightboxItem, cols int) {
<section data-block="noir:lightbox_gallery" class="py-12">
<div class="max-w-6xl mx-auto px-6">
if len(items) == 0 {
<div class="hairline p-12 text-center tracked-mono" style="color: hsl(var(--mutedForeground));">
Add photographs to populate the gallery.
</div>
} else {
<div class={ "grid gap-4", lightboxGridCols(cols) }>
for i, item := range items {
<figure class="noir-figure">
<button
type="button"
data-noir-lightbox-trigger
data-src={ item.Image }
data-alt={ item.Caption }
data-caption={ item.Caption }
class="block w-full p-0 m-0 cursor-zoom-in bg-transparent border-0"
style="background: transparent;"
aria-label={ lightboxAriaLabel(i, item.Caption) }
>
<img
src={ item.Image }
alt={ item.Caption }
loading="lazy"
class="w-full h-auto block"
/>
</button>
if item.Caption != "" {
<figcaption class="tracked-mono mt-2" style="color: hsl(var(--mutedForeground));">
{ item.Caption }
</figcaption>
}
</figure>
}
</div>
}
</div>
</section>
}
// lightboxGridCols returns Tailwind grid-template-columns utility for the requested columns.
func lightboxGridCols(cols int) string {
switch cols {
case 2:
return "grid-cols-1 md:grid-cols-2"
case 4:
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
default: // 3
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
}
}
// lightboxAriaLabel builds an accessible button label for each gallery entry.
func lightboxAriaLabel(index int, caption string) string {
if caption != "" {
return "Open photograph: " + caption
}
return "Open photograph " + intToString(index+1)
}
// intToString avoids importing strconv into a templ-generated file.
func intToString(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}

229
lightbox_gallery_templ.go Normal file
View File

@ -0,0 +1,229 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// lightboxGalleryComponent renders the gallery grid with lightbox triggers.
func lightboxGalleryComponent(items []LightboxItem, cols int) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section data-block=\"noir:lightbox_gallery\" class=\"py-12\"><div class=\"max-w-6xl mx-auto px-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(items) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"hairline p-12 text-center tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">Add photographs to populate the gallery.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var2 = []any{"grid gap-4", lightboxGridCols(cols)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i, item := range items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<figure class=\"noir-figure\"><button type=\"button\" data-noir-lightbox-trigger data-src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Image)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 18, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 19, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-caption=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 20, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"block w-full p-0 m-0 cursor-zoom-in bg-transparent border-0\" style=\"background: transparent;\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lightboxAriaLabel(i, item.Caption))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 23, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Image)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 26, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 27, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" loading=\"lazy\" class=\"w-full h-auto block\"></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Caption != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<figcaption class=\"tracked-mono mt-2\" style=\"color: hsl(var(--mutedForeground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `lightbox_gallery.templ`, Line: 34, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</figcaption>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</figure>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// lightboxGridCols returns Tailwind grid-template-columns utility for the requested columns.
func lightboxGridCols(cols int) string {
switch cols {
case 2:
return "grid-cols-1 md:grid-cols-2"
case 4:
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
default: // 3
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
}
}
// lightboxAriaLabel builds an accessible button label for each gallery entry.
func lightboxAriaLabel(index int, caption string) string {
if caption != "" {
return "Open photograph: " + caption
}
return "Open photograph " + intToString(index+1)
}
// intToString avoids importing strconv into a templ-generated file.
func intToString(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,363 +0,0 @@
# Noir theme — codeless .bnp manifest (WO-TF-022, Wave A).
# Detective / true-crime dossier. Synthesized into manifest.pb by
# `ninja plugin build` (no plugin.wasm).
theme_presets: presets.json
bundled_fonts: fonts.json
master_pages: master_pages.json
system_templates:
- key: noir
title: Noir
description: "Detective and true-crime dossier theme: case-file cards, typewriter type, redaction bars, evidence board and film grain. High-key gallery by day, darkroom black by night."
page_templates:
- system: noir
key: default
title: Default
description: "Case-file page with a typed masthead and a dissolved footer rule."
slots: [header, main, footer]
- system: noir
key: full-width
title: Full Width
description: "Edge-to-edge dossier layout for evidence boards and lightbox grids."
slots: [header, main, footer]
- system: noir
key: landing
title: Landing
description: "Spotlit case hero over film grain, a caption rule and a closing CTA."
slots: [hero, main, cta, footer]
- system: noir
key: article
title: Case File
description: "Long-form case write-up with a sticky evidence rail and typed prose."
slots: [header, main, aside, footer]
- system: noir
key: blog-index
title: Case Index
description: "Blog index framed as a case log, with featured and post-grid slots."
slots: [header, featured, main, footer]
- system: noir
key: contact
title: Tip Line
description: "Contact layout with an optional aside for hours and location."
slots: [header, main, aside, footer]
- system: noir
key: auth
title: Access
description: "Centred access shell with a typed wordmark over a darkroom ground."
slots: [main, footer]
template_overrides:
# Chrome (4)
- { template: noir, block: navbar }
- { template: noir, block: footer }
- { template: noir, block: utility-bar }
- { template: noir, block: announcement-bar }
# Marketing (12)
- { template: noir, block: hero }
- { template: noir, block: feature-grid }
- { template: noir, block: rich-section }
- { template: noir, block: stats }
- { template: noir, block: testimonial }
- { template: noir, block: logo-strip }
- { template: noir, block: pricing }
- { template: noir, block: team }
- { template: noir, block: faq }
- { template: noir, block: timeline }
- { template: noir, block: cta }
- { template: noir, block: countdown }
# Primitives (11)
- { template: noir, block: heading }
- { template: noir, block: text }
- { template: noir, block: rich-text }
- { template: noir, block: image }
- { template: noir, block: quote }
- { template: noir, block: divider }
- { template: noir, block: button }
- { template: noir, block: card }
- { template: noir, block: gallery }
- { template: noir, block: video-embed }
- { template: noir, block: audio-embed }
# Commerce / local (6)
- { template: noir, block: menu-list }
- { template: noir, block: event-list }
- { template: noir, block: contact-form }
- { template: noir, block: contact-card }
- { template: noir, block: hours-location }
- { template: noir, block: social-links }
# Blog family (12)
- { template: noir, block: blog-hero }
- { template: noir, block: featured-posts }
- { template: noir, block: related-posts }
- { template: noir, block: popular-posts }
- { template: noir, block: category-list }
- { template: noir, block: post-hero }
- { template: noir, block: post-metadata }
- { template: noir, block: author-bio }
- { template: noir, block: author-bio-hero }
- { template: noir, block: newsletter-signup }
- { template: noir, block: breadcrumbs }
- { template: noir, block: sidebar-nav }
# Auth surface (3)
- { template: noir, block: auth-form }
- { template: noir, block: password-reset-form }
- { template: noir, block: auth-status }
# System (1)
- { template: noir, block: page-suggestions }
email_wrappers:
- noir
css:
input_css_append: |
/* ============================================================
Noir Theme — Detective / true-crime dossier utility CSS.
Injected via CSSManifest.InputCSSAppend.
Colour is ALWAYS consumed via hsl(var(--token)) with kebab-case
tokens (the CMS emits --muted-foreground, --card-foreground, ...
from the camelCase preset keys). Font families ALWAYS flow through
var(--font-heading|body|mono, <fallback>) — never a hardcoded face.
============================================================ */
/* Bundled faces. The host does not yet emit @font-face for
bundled_fonts (LoadedPlugin.BundledFonts is unconsumed), so the theme
declares its own faces against the served artifact paths
(/templates/noir/fonts/...). Presets set typography.fontHeading/body/mono
to template:noir:<Family> so --font-heading/--font-body/--font-mono
resolve to these families. (gotham@1.2.0 precedent.) */
@font-face { font-family: 'Source Serif 4'; src: url('/templates/noir/fonts/source-serif-4-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Source Serif 4'; src: url('/templates/noir/fonts/source-serif-4-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('/templates/noir/fonts/ibm-plex-mono-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('/templates/noir/fonts/ibm-plex-mono-500.woff2') format('woff2'); font-weight: 500; font-style: normal; font-display: swap; }
@layer utilities {
/* --- Typography slots -------------------------------------- */
/* Fallback stacks name the ideal typewriter faces first
(Special Elite / Courier Prime, admin-assignable via Google
Fonts) then the shipped OFL defaults (IBM Plex Mono, Source
Serif 4). */
.noir-heading {
font-family: var(--font-heading, "Special Elite", "IBM Plex Mono", "Courier New", monospace);
letter-spacing: 0.01em;
}
.noir-body {
font-family: var(--font-body, "Source Serif 4", Georgia, "Times New Roman", serif);
}
.noir-mono {
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
}
/* .noir-label — typed evidence label / eyebrow. */
.noir-label {
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.22em;
font-size: 0.7rem;
line-height: 1.4;
}
/* --- Hairline rules ---------------------------------------- */
.noir-rule { border: none; border-top: 1px solid hsl(var(--border)); margin: 0; }
.noir-rule-b { border-bottom: 1px solid hsl(var(--border)); }
.noir-hairline { border: 1px solid hsl(var(--border)); }
/* --- .noir-file — manila case-file card -------------------- */
/* Square-cornered paper panel with a hairline border and a top
file-tab drawn as a border accent. */
.noir-file {
position: relative;
background-color: hsl(var(--card));
color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border));
border-top: 2px solid hsl(var(--primary));
}
/* .noir-tab — folder-tab caption bar sitting above a file. */
.noir-tab {
display: inline-block;
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.65rem;
padding: 0.2rem 0.7rem;
}
/* --- .noir-stamp — rotated case-status stamp --------------- */
.noir-stamp {
display: inline-block;
border: 2px solid currentColor;
padding: 0.2rem 0.6rem;
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.7rem;
transform: rotate(-7deg);
opacity: 0.9;
}
/* --- .noir-redact — redaction bar over text ---------------- */
/* Solid ink bar; reveals on hover/focus. Reduced-motion safe:
the reveal is a colour swap with no transform. */
.noir-redact {
background-color: hsl(var(--foreground));
color: transparent;
border-radius: 1px;
padding: 0 0.15em;
transition: background-color 0.18s ease, color 0.18s ease;
cursor: help;
}
.noir-redact:hover,
.noir-redact:focus-visible {
background-color: transparent;
color: hsl(var(--foreground));
outline: none;
}
/* --- .noir-pin — evidence-board push pin ------------------- */
.noir-pin { position: relative; }
.noir-pin::before {
content: "";
position: absolute;
top: -7px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 12px;
border-radius: 9999px;
background: hsl(var(--primary));
box-shadow: 0 1px 2px hsl(var(--foreground) / 0.5);
z-index: 2;
}
/* --- .noir-tape — masking-tape corner strip ---------------- */
.noir-tape { position: relative; }
.noir-tape::before {
content: "";
position: absolute;
top: -0.5rem;
left: 50%;
transform: translateX(-50%) rotate(-3deg);
width: 4.5rem;
height: 1.1rem;
background: hsl(var(--muted-foreground) / 0.28);
border-left: 1px dashed hsl(var(--muted-foreground) / 0.4);
border-right: 1px dashed hsl(var(--muted-foreground) / 0.4);
z-index: 2;
}
/* --- .noir-grain — 3.5% film-grain overlay ----------------- */
.noir-grain { position: relative; isolation: isolate; }
.noir-grain::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0.035;
background-image:
repeating-radial-gradient(circle at 19% 27%, hsl(var(--foreground)) 0 1px, transparent 1px 3px),
repeating-radial-gradient(circle at 71% 63%, hsl(var(--foreground)) 0 1px, transparent 1px 3px);
mix-blend-mode: overlay;
}
/* --- .noir-spotlight — darkroom vignette ------------------- */
.noir-spotlight { position: relative; }
.noir-spotlight::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: radial-gradient(120% 90% at 50% 35%, transparent 40%, hsl(var(--background) / 0.85) 100%);
}
/* --- .noir-btn — square outline button --------------------- */
.noir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background-color: transparent;
color: hsl(var(--foreground));
border: 1px solid hsl(var(--foreground));
padding: 0.7rem 1.5rem;
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.72rem;
text-decoration: none;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
cursor: pointer;
}
.noir-btn:hover,
.noir-btn:focus-visible {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.noir-btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
/* .noir-btn-solid — filled evidence-accent variant. */
.noir-btn-solid {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.noir-btn-solid:hover,
.noir-btn-solid:focus-visible {
background-color: transparent;
color: hsl(var(--foreground));
border-color: hsl(var(--foreground));
}
/* --- .noir-link — evidence-accent underline link ----------- */
.noir-link {
color: hsl(var(--primary));
text-decoration: none;
border-bottom: 1px solid hsl(var(--primary));
transition: color 0.18s ease, border-color 0.18s ease;
}
.noir-link:hover,
.noir-link:focus-visible {
color: hsl(var(--accent));
border-bottom-color: hsl(var(--accent));
}
/* --- Scroll reveal (MID motion, progressive enhancement) --- */
/* No-JS / no class: content is fully visible. The page template
adds `.noir-js` to <html> only when JS runs. */
.noir-js .noir-reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.6s ease, transform 0.6s ease;
will-change: opacity, transform;
}
.noir-js .noir-reveal.noir-in {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.noir-js .noir-reveal {
opacity: 1;
transform: none;
transition: none;
}
.noir-redact { transition: none; }
}
}
/* Empty-media graceful state — the demo seed ships no images, so any
media frame that renders an empty <img> must not balloon into a grey
box or show broken-image chrome. Images never exceed their column. */
.noir-file img, .bn-card img, .bn-gallery img, figure img { max-width: 100%; height: auto; }
img[src=""], img:not([src]) { display: none; }
/* Overflow safety — long typed/mono labels wrap rather than push the
page wider than a phone viewport (uppercase + wide tracking is the risk). */
.noir-label, .noir-mono, .noir-heading { overflow-wrap: break-word; }
/* Never let a block force a horizontal scrollbar on small screens. */
[data-block-override], [data-block] { max-width: 100%; }

View File

@ -1,26 +0,0 @@
[
{
"key": "noir:default-master",
"title": "Noir Default Master",
"page_templates": ["default", "article", "blog-index", "contact"],
"blocks": [
{ "block_key": "navbar", "title": "Case-File Masthead", "content": { "menuName": "main", "logoType": "text", "logoText": { "text": "The Cold File" } }, "slot": "header", "sort_order": 0 },
{ "block_key": "divider", "title": "Header Rule", "content": { "variant": "line", "weight": "thin", "width": "full" }, "slot": "header", "sort_order": 1 },
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main", "placeholder": "Page content" }, "slot": "main", "sort_order": 0 },
{ "block_key": "footer", "title": "Site Footer", "content": { "layout": "columns", "companyName": "The Cold File", "tagline": "Cases that were never closed." }, "slot": "footer", "sort_order": 0 }
]
},
{
"key": "noir:case-master",
"title": "Noir Case Master",
"page_templates": ["landing", "full-width"],
"blocks": [
{ "block_key": "navbar", "title": "Case-File Masthead", "content": { "menuName": "main", "logoType": "text", "logoText": { "text": "The Cold File" }, "variant": "transparent-over-hero" }, "slot": "hero", "sort_order": 0 },
{ "block_key": "slot", "title": "Hero Content", "content": { "slotName": "hero", "placeholder": "Hero" }, "slot": "hero", "sort_order": 1 },
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main" }, "slot": "main", "sort_order": 0 },
{ "block_key": "divider", "title": "CTA Rule", "content": { "variant": "line", "weight": "thick", "width": "full" }, "slot": "cta", "sort_order": 0 },
{ "block_key": "slot", "title": "CTA Content", "content": { "slotName": "cta", "placeholder": "Closing call to action" }, "slot": "cta", "sort_order": 1 },
{ "block_key": "footer", "title": "Site Footer", "content": { "layout": "columns", "companyName": "The Cold File", "tagline": "Cases that were never closed." }, "slot": "footer", "sort_order": 0 }
]
}
]

99
overrides.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"bytes"
"context"
"strconv"
"git.dev.alexdunmow.com/block/core/blocks"
)
// NoirHeadingBlock overrides the built-in heading.
// Renders with the display-serif family, no underline, generous tracking.
func NoirHeadingBlock(ctx context.Context, content map[string]any) string {
text := getString(content, "text")
textClass := getString(content, "textClass")
level := parseHeadingLevel(content)
var buf bytes.Buffer
_ = noirHeadingComponent(level, text, textClass).Render(ctx, &buf)
return buf.String()
}
// parseHeadingLevel parses the heading level from content (1-6, default 2).
func parseHeadingLevel(content map[string]any) int {
if level, ok := content["level"].(float64); ok {
l := int(level)
if l >= 1 && l <= 6 {
return l
}
}
if level, ok := content["level"].(int); ok {
if level >= 1 && level <= 6 {
return level
}
}
if level, ok := content["level"].(string); ok {
if l, err := strconv.Atoi(level); err == nil && l >= 1 && l <= 6 {
return l
}
}
return 2
}
// NoirTextBlock overrides the built-in text block.
// Renders with the humanist sans family and generous leading.
func NoirTextBlock(ctx context.Context, content map[string]any) string {
text := getString(content, "text")
class := getString(content, "class")
var buf bytes.Buffer
_ = noirTextComponent(text, class).Render(ctx, &buf)
return buf.String()
}
// NoirImageBlock overrides the built-in image block.
// Always full-bleed inside its container with a mono caption beneath.
func NoirImageBlock(ctx context.Context, content map[string]any) string {
src := blocks.ResolveMediaPath(getString(content, "src"))
alt := getString(content, "alt")
caption := getString(content, "caption")
if caption == "" {
caption = getString(content, "title")
}
var buf bytes.Buffer
_ = noirImageComponent(src, alt, caption).Render(ctx, &buf)
return buf.String()
}
// NoirButtonBlock overrides the built-in button.
// Renders as a hairline 1px outline, no fill, hover inverts.
func NoirButtonBlock(ctx context.Context, content map[string]any) string {
text := getString(content, "text")
if text == "" {
text = getString(content, "label")
}
href := getString(content, "url")
if href == "" {
href = getString(content, "href")
}
var buf bytes.Buffer
_ = noirButtonComponent(text, href).Render(ctx, &buf)
return buf.String()
}
// NoirCardBlock overrides the built-in card.
// Transparent background, hairline border only.
func NoirCardBlock(ctx context.Context, content map[string]any) string {
title := getString(content, "title")
body := getString(content, "body")
if body == "" {
body = getString(content, "text")
}
var buf bytes.Buffer
_ = noirCardComponent(title, body).Render(ctx, &buf)
return buf.String()
}

87
overrides.templ Normal file
View File

@ -0,0 +1,87 @@
package main
// noirHeadingComponent renders a heading using the display-serif font slot.
templ noirHeadingComponent(level int, text, textClass string) {
switch level {
case 1:
<h1 class={ "noir-display", textClass } style="font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; letter-spacing: 0.005em; color: hsl(var(--foreground));">
{ text }
</h1>
case 2:
<h2 class={ "noir-display", textClass } style="font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));">
{ text }
</h2>
case 3:
<h3 class={ "noir-display", textClass } style="font-size: clamp(1.5rem, 3vw, 2.25rem); line-height: 1.2; color: hsl(var(--foreground));">
{ text }
</h3>
case 4:
<h4 class={ "noir-display", textClass } style="font-size: 1.5rem; color: hsl(var(--foreground));">
{ text }
</h4>
case 5:
<h5 class={ "noir-display", textClass } style="font-size: 1.25rem; color: hsl(var(--foreground));">
{ text }
</h5>
case 6:
<h6 class={ "noir-display", textClass } style="font-size: 1.125rem; color: hsl(var(--foreground));">
{ text }
</h6>
default:
<h2 class={ "noir-display", textClass } style="font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));">
{ text }
</h2>
}
}
// noirTextComponent renders text with the humanist sans family and generous leading.
// The body family resolves through --font-body; the surrounding .noir-page already
// supplies the fallback stack from style.css, so we only need spacing here.
templ noirTextComponent(text, class string) {
<div class={ "noir-text", class } style="line-height: 1.7; color: hsl(var(--foreground)); max-width: 65ch;">
@templ.Raw(text)
</div>
}
// noirImageComponent renders an image full-bleed within its container with a mono caption.
templ noirImageComponent(src, alt, caption string) {
<figure class="noir-figure bleed">
if src != "" {
<img src={ src } alt={ alt } loading="lazy" class="w-full h-auto block"/>
}
if caption != "" {
<figcaption class="tracked-mono mt-2 px-6" style="color: hsl(var(--mutedForeground));">
{ caption }
</figcaption>
}
</figure>
}
// noirButtonComponent renders the hairline outline button.
templ noirButtonComponent(text, href string) {
if href != "" {
<a href={ templ.SafeURL(href) } class="noir-btn">
{ text }
</a>
} else {
<button type="button" class="noir-btn">
{ text }
</button>
}
}
// noirCardComponent renders the transparent hairline-bordered card.
templ noirCardComponent(title, body string) {
<div class="noir-card" style="padding: 1.5rem;">
if title != "" {
<h3 class="noir-display" style="font-size: 1.5rem; margin-bottom: 0.75rem; color: hsl(var(--foreground));">
{ title }
</h3>
}
if body != "" {
<div style="color: hsl(var(--foreground)); line-height: 1.6;">
@templ.Raw(body)
</div>
}
</div>
}

577
overrides_templ.go Normal file
View File

@ -0,0 +1,577 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// noirHeadingComponent renders a heading using the display-serif font slot.
func noirHeadingComponent(level int, text, textClass string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch level {
case 1:
var templ_7745c5c3_Var2 = []any{"noir-display", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" style=\"font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; letter-spacing: 0.005em; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 8, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 2:
var templ_7745c5c3_Var5 = []any{"noir-display", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 12, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 3:
var templ_7745c5c3_Var8 = []any{"noir-display", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h3 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" style=\"font-size: clamp(1.5rem, 3vw, 2.25rem); line-height: 1.2; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 16, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 4:
var templ_7745c5c3_Var11 = []any{"noir-display", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<h4 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" style=\"font-size: 1.5rem; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 20, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h4>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 5:
var templ_7745c5c3_Var14 = []any{"noir-display", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h5 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" style=\"font-size: 1.25rem; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 24, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h5>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case 6:
var templ_7745c5c3_Var17 = []any{"noir-display", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<h6 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var17).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" style=\"font-size: 1.125rem; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 28, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h6>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
var templ_7745c5c3_Var20 = []any{"noir-display", textClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<h2 class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" style=\"font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 32, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
// noirTextComponent renders text with the humanist sans family and generous leading.
// The body family resolves through --font-body; the surrounding .noir-page already
// supplies the fallback stack from style.css, so we only need spacing here.
func noirTextComponent(text, class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
if templ_7745c5c3_Var23 == nil {
templ_7745c5c3_Var23 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var24 = []any{"noir-text", class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" style=\"line-height: 1.7; color: hsl(var(--foreground)); max-width: 65ch;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(text).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// noirImageComponent renders an image full-bleed within its container with a mono caption.
func noirImageComponent(src, alt, caption string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var26 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<figure class=\"noir-figure bleed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if src != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(src)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 50, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(alt)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 50, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" loading=\"lazy\" class=\"w-full h-auto block\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if caption != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<figcaption class=\"tracked-mono mt-2 px-6\" style=\"color: hsl(var(--mutedForeground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(caption)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 54, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</figcaption>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</figure>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// noirButtonComponent renders the hairline outline button.
func noirButtonComponent(text, href string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var30 := templ.GetChildren(ctx)
if templ_7745c5c3_Var30 == nil {
templ_7745c5c3_Var30 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if href != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 63, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"noir-btn\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 64, Col: 9}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button type=\"button\" class=\"noir-btn\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 68, Col: 9}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
// noirCardComponent renders the transparent hairline-bordered card.
func noirCardComponent(title, body string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
if templ_7745c5c3_Var34 == nil {
templ_7745c5c3_Var34 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"noir-card\" style=\"padding: 1.5rem;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if title != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<h3 class=\"noir-display\" style=\"font-size: 1.5rem; margin-bottom: 0.75rem; color: hsl(var(--foreground));\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `overrides.templ`, Line: 78, Col: 11}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if body != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div style=\"color: hsl(var(--foreground)); line-height: 1.6;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(body).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -2,8 +2,11 @@
name = "noir" name = "noir"
display_name = "Noir" display_name = "Noir"
scope = "@themes" scope = "@themes"
version = "0.4.0" version = "0.1.1"
description = "Detective and true-crime dossier theme: case-file cards, typewriter type, redaction bars, evidence board and film grain. High-key gallery by day, darkroom black by night." description = "Silver-on-black photography theme with full-bleed imagery, micro mono labels, lightbox galleries and contact-sheet layouts."
kind = "theme" kind = "theme"
categories = ["templates"] categories = ["templates"]
tags = ["noir", "detective", "true-crime", "dossier", "typewriter", "monochrome", "editorial", "dark"] tags = ["noir", "monochrome", "photography", "portfolio", "gallery", "filmmaker", "fashion", "darkroom"]
[compatibility]
block_core = ">=0.11.0 <0.12.0"

View File

@ -1,245 +1,39 @@
[ [
{ {
"id": "cold-case", "id": "pure-noir",
"name": "Cold Case", "name": "Pure Noir",
"description": "The default. High-key gallery by day, ink-black type, evidence red. Darkroom black by night lit by an amber safelight.", "description": "Pure black ground, silver mids, one white reserved for captions and highlight edges.",
"theme": { "theme": {
"mode": "both", "mode": "dark",
"typography": {
"fontHeading": "template:noir:IBM Plex Mono",
"fontBody": "template:noir:Source Serif 4",
"fontMono": "template:noir:IBM Plex Mono",
"fontSizeBase": "17",
"lineHeightBase": "1.7"
},
"lightColors": {
"background": "40 14% 96%",
"foreground": "24 12% 10%",
"card": "40 18% 99%",
"cardForeground": "24 12% 10%",
"popover": "40 18% 99%",
"popoverForeground": "24 12% 10%",
"primary": "0 68% 42%",
"primaryForeground": "40 20% 97%",
"secondary": "40 8% 90%",
"secondaryForeground": "24 12% 14%",
"muted": "40 8% 91%",
"mutedForeground": "28 9% 30%",
"accent": "0 60% 48%",
"accentForeground": "40 20% 97%",
"destructive": "0 74% 40%",
"destructiveForeground": "40 20% 97%",
"border": "30 8% 80%",
"input": "30 8% 84%",
"ring": "0 68% 42%"
},
"darkColors": { "darkColors": {
"background": "30 9% 7%", "background": "0 0% 0%",
"foreground": "40 20% 88%", "foreground": "0 0% 96%",
"card": "30 9% 10%", "card": "0 0% 4%",
"cardForeground": "40 20% 88%", "cardForeground": "0 0% 96%",
"popover": "30 9% 10%", "popover": "0 0% 6%",
"popoverForeground": "40 20% 88%", "popoverForeground": "0 0% 96%",
"primary": "36 82% 56%", "primary": "0 0% 100%",
"primaryForeground": "30 22% 8%", "primaryForeground": "0 0% 0%",
"secondary": "30 8% 15%", "secondary": "0 0% 10%",
"secondaryForeground": "40 16% 85%", "secondaryForeground": "0 0% 80%",
"muted": "30 8% 13%", "muted": "0 0% 8%",
"mutedForeground": "40 10% 60%", "mutedForeground": "0 0% 55%",
"accent": "32 78% 50%", "accent": "0 0% 75%",
"accentForeground": "30 22% 8%", "accentForeground": "0 0% 0%",
"destructive": "0 70% 52%", "destructive": "0 70% 45%",
"destructiveForeground": "40 20% 92%", "destructiveForeground": "0 0% 98%",
"border": "30 8% 20%", "border": "0 0% 14%",
"input": "30 8% 18%", "input": "0 0% 12%",
"ring": "36 82% 56%" "ring": "0 0% 70%"
} }
} }
}, },
{ {
"id": "evidence-red", "id": "silver-print",
"name": "Evidence Red", "name": "Silver Print",
"description": "Red-forward in both modes. The colour of the string on the board and the line under the name.", "description": "Inverted gallery-wall preset: bone white, charcoal type.",
"theme": { "theme": {
"mode": "both", "mode": "light",
"typography": {
"fontHeading": "template:noir:IBM Plex Mono",
"fontBody": "template:noir:Source Serif 4",
"fontMono": "template:noir:IBM Plex Mono",
"fontSizeBase": "17",
"lineHeightBase": "1.7"
},
"lightColors": {
"background": "20 10% 97%",
"foreground": "0 8% 8%",
"card": "20 12% 100%",
"cardForeground": "0 8% 8%",
"popover": "20 12% 100%",
"popoverForeground": "0 8% 8%",
"primary": "358 74% 44%",
"primaryForeground": "20 12% 98%",
"secondary": "20 8% 90%",
"secondaryForeground": "0 8% 12%",
"muted": "20 8% 92%",
"mutedForeground": "12 9% 29%",
"accent": "8 68% 50%",
"accentForeground": "20 12% 98%",
"destructive": "358 78% 42%",
"destructiveForeground": "20 12% 98%",
"border": "12 8% 82%",
"input": "12 8% 86%",
"ring": "358 74% 44%"
},
"darkColors": {
"background": "0 12% 6%",
"foreground": "10 12% 90%",
"card": "0 12% 9%",
"cardForeground": "10 12% 90%",
"popover": "0 12% 9%",
"popoverForeground": "10 12% 90%",
"primary": "2 78% 56%",
"primaryForeground": "0 15% 8%",
"secondary": "0 10% 14%",
"secondaryForeground": "10 12% 86%",
"muted": "0 10% 12%",
"mutedForeground": "8 10% 60%",
"accent": "6 74% 52%",
"accentForeground": "0 15% 8%",
"destructive": "2 80% 54%",
"destructiveForeground": "10 12% 92%",
"border": "0 10% 19%",
"input": "0 10% 16%",
"ring": "2 78% 56%"
}
}
},
{
"id": "safelight",
"name": "Safelight",
"description": "The darkroom under amber safelight. Warm bone paper by day, deep umber and amber glow by night.",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:noir:IBM Plex Mono",
"fontBody": "template:noir:Source Serif 4",
"fontMono": "template:noir:IBM Plex Mono",
"fontSizeBase": "17",
"lineHeightBase": "1.7"
},
"lightColors": {
"background": "38 30% 95%",
"foreground": "30 18% 12%",
"card": "38 34% 98%",
"cardForeground": "30 18% 12%",
"popover": "38 34% 98%",
"popoverForeground": "30 18% 12%",
"primary": "32 74% 44%",
"primaryForeground": "38 30% 97%",
"secondary": "38 16% 88%",
"secondaryForeground": "30 18% 14%",
"muted": "38 14% 90%",
"mutedForeground": "32 14% 30%",
"accent": "26 70% 48%",
"accentForeground": "38 30% 97%",
"destructive": "0 70% 44%",
"destructiveForeground": "38 30% 97%",
"border": "34 16% 80%",
"input": "34 14% 84%",
"ring": "32 74% 44%"
},
"darkColors": {
"background": "28 22% 6%",
"foreground": "38 28% 88%",
"card": "28 20% 9%",
"cardForeground": "38 28% 88%",
"popover": "28 20% 9%",
"popoverForeground": "38 28% 88%",
"primary": "34 84% 56%",
"primaryForeground": "28 24% 7%",
"secondary": "28 18% 14%",
"secondaryForeground": "38 22% 84%",
"muted": "28 16% 12%",
"mutedForeground": "36 16% 60%",
"accent": "28 76% 50%",
"accentForeground": "28 24% 7%",
"destructive": "0 72% 52%",
"destructiveForeground": "38 28% 92%",
"border": "28 18% 19%",
"input": "28 16% 16%",
"ring": "34 84% 56%"
}
}
},
{
"id": "interrogation",
"name": "Interrogation",
"description": "Steel and cold light. Grey-blue neutral by day, slate-black room lit by a single steel lamp by night.",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:noir:IBM Plex Mono",
"fontBody": "template:noir:Source Serif 4",
"fontMono": "template:noir:IBM Plex Mono",
"fontSizeBase": "17",
"lineHeightBase": "1.7"
},
"lightColors": {
"background": "210 16% 95%",
"foreground": "215 22% 12%",
"card": "210 18% 99%",
"cardForeground": "215 22% 12%",
"popover": "210 18% 99%",
"popoverForeground": "215 22% 12%",
"primary": "205 58% 40%",
"primaryForeground": "210 20% 97%",
"secondary": "210 14% 89%",
"secondaryForeground": "215 22% 14%",
"muted": "210 12% 91%",
"mutedForeground": "214 13% 30%",
"accent": "8 62% 48%",
"accentForeground": "210 20% 97%",
"destructive": "0 72% 44%",
"destructiveForeground": "210 20% 97%",
"border": "212 14% 80%",
"input": "212 12% 84%",
"ring": "205 58% 40%"
},
"darkColors": {
"background": "215 24% 7%",
"foreground": "210 18% 88%",
"card": "215 22% 10%",
"cardForeground": "210 18% 88%",
"popover": "215 22% 10%",
"popoverForeground": "210 18% 88%",
"primary": "200 70% 56%",
"primaryForeground": "215 26% 7%",
"secondary": "215 18% 15%",
"secondaryForeground": "210 16% 85%",
"muted": "215 16% 13%",
"mutedForeground": "212 14% 60%",
"accent": "6 72% 54%",
"accentForeground": "215 26% 7%",
"destructive": "0 70% 52%",
"destructiveForeground": "210 18% 92%",
"border": "215 18% 20%",
"input": "215 16% 17%",
"ring": "200 70% 56%"
}
}
},
{
"id": "redaction",
"name": "Redaction",
"description": "Pure document. Stark black-on-white by day, near-monochrome by night, with the faintest red where the bar lifts.",
"theme": {
"mode": "both",
"typography": {
"fontHeading": "template:noir:IBM Plex Mono",
"fontBody": "template:noir:Source Serif 4",
"fontMono": "template:noir:IBM Plex Mono",
"fontSizeBase": "17",
"lineHeightBase": "1.7"
},
"lightColors": { "lightColors": {
"background": "0 0% 98%", "background": "0 0% 98%",
"foreground": "0 0% 8%", "foreground": "0 0% 8%",
@ -247,40 +41,69 @@
"cardForeground": "0 0% 8%", "cardForeground": "0 0% 8%",
"popover": "0 0% 100%", "popover": "0 0% 100%",
"popoverForeground": "0 0% 8%", "popoverForeground": "0 0% 8%",
"primary": "0 62% 42%", "primary": "0 0% 10%",
"primaryForeground": "0 0% 98%", "primaryForeground": "0 0% 98%",
"secondary": "0 0% 92%", "secondary": "0 0% 94%",
"secondaryForeground": "0 0% 12%", "secondaryForeground": "0 0% 12%",
"muted": "0 0% 93%", "muted": "0 0% 96%",
"mutedForeground": "0 0% 28%", "mutedForeground": "0 0% 42%",
"accent": "0 0% 12%", "accent": "0 0% 65%",
"accentForeground": "0 0% 98%", "accentForeground": "0 0% 100%",
"destructive": "0 70% 42%", "destructive": "0 75% 50%",
"destructiveForeground": "0 0% 98%", "destructiveForeground": "0 0% 100%",
"border": "0 0% 82%", "border": "0 0% 88%",
"input": "0 0% 86%", "input": "0 0% 90%",
"ring": "0 62% 42%" "ring": "0 0% 25%"
}
}
},
{
"id": "platinum",
"name": "Platinum",
"description": "Warmer silver mid-tones for fashion and editorial work, with both light and dark variants.",
"theme": {
"mode": "both",
"lightColors": {
"background": "30 6% 96%",
"foreground": "30 8% 10%",
"card": "30 6% 100%",
"cardForeground": "30 8% 10%",
"popover": "30 6% 100%",
"popoverForeground": "30 8% 10%",
"primary": "30 6% 14%",
"primaryForeground": "30 6% 96%",
"secondary": "30 4% 92%",
"secondaryForeground": "30 8% 14%",
"muted": "30 4% 94%",
"mutedForeground": "30 4% 44%",
"accent": "30 8% 60%",
"accentForeground": "30 8% 6%",
"destructive": "0 70% 48%",
"destructiveForeground": "0 0% 100%",
"border": "30 6% 86%",
"input": "30 6% 88%",
"ring": "30 8% 30%"
}, },
"darkColors": { "darkColors": {
"background": "0 0% 5%", "background": "30 6% 4%",
"foreground": "0 0% 90%", "foreground": "30 6% 92%",
"card": "0 0% 8%", "card": "30 6% 7%",
"cardForeground": "0 0% 90%", "cardForeground": "30 6% 92%",
"popover": "0 0% 8%", "popover": "30 6% 9%",
"popoverForeground": "0 0% 90%", "popoverForeground": "30 6% 92%",
"primary": "2 72% 54%", "primary": "30 6% 96%",
"primaryForeground": "0 0% 6%", "primaryForeground": "30 8% 6%",
"secondary": "0 0% 14%", "secondary": "30 6% 12%",
"secondaryForeground": "0 0% 86%", "secondaryForeground": "30 6% 88%",
"muted": "0 0% 12%", "muted": "30 6% 10%",
"mutedForeground": "0 0% 58%", "mutedForeground": "30 4% 58%",
"accent": "0 0% 88%", "accent": "30 8% 70%",
"accentForeground": "0 0% 8%", "accentForeground": "30 8% 6%",
"destructive": "2 74% 52%", "destructive": "0 70% 45%",
"destructiveForeground": "0 0% 92%", "destructiveForeground": "0 0% 98%",
"border": "0 0% 19%", "border": "30 6% 16%",
"input": "0 0% 16%", "input": "30 6% 14%",
"ring": "2 72% 54%" "ring": "30 8% 72%"
} }
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 110 KiB

176
register.go Normal file
View File

@ -0,0 +1,176 @@
package main
import (
"context"
"github.com/a-h/templ"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/templates"
)
// wrap adapts a templ-returning render function to templates.TemplateFunc.
// templ.Component already implements templates.HTMLComponent via Render.
func wrap(f func(ctx context.Context, doc map[string]any) templ.Component) templates.TemplateFunc {
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
return f(ctx, doc)
}
}
// Register wires the Noir system template, page templates, blocks, overrides,
// and email wrapper into the host registries.
//
// Order matters: br.LoadSchemasFromFS(Schemas()) is called BEFORE any
// br.Register call so the schema metadata binds correctly.
func Register(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
// System template.
tr.RegisterSystemTemplate(templates.SystemTemplateMeta{
Key: "noir",
Title: "Noir",
Description: "Silver-on-black photography theme with full-bleed imagery, micro mono labels, lightbox galleries and contact-sheet layouts.",
})
// Page templates (4).
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "default",
Title: "Default",
Description: "Centred gallery page with a thin masthead and dissolved footer.",
Slots: []string{"header", "main", "footer"},
}, wrap(RenderNoirDefault)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "landing",
Title: "Landing",
Description: "Edge-to-edge hero image, micro caption strip, minimal CTA.",
Slots: []string{"hero", "main", "cta", "footer"},
}, wrap(RenderNoirLanding)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "article",
Title: "Article / Project",
Description: "Long-form case study with sticky caption rail and image-led prose.",
Slots: []string{"header", "main", "aside", "footer"},
}, wrap(RenderNoirArticle)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "full-width",
Title: "Full Width",
Description: "Contact-sheet or lightbox grid, no horizontal padding.",
Slots: []string{"header", "main", "footer"},
}, wrap(RenderNoirFullWidth)); err != nil {
return err
}
// Schemas must load BEFORE block registration so metadata binds.
if err := br.LoadSchemasFromFS(Schemas()); err != nil {
return err
}
// Theme-specific blocks (6). Registered with unqualified keys —
// addressed as "noir:<key>" at runtime.
br.Register(LightboxGalleryBlockMeta, LightboxGalleryBlock)
br.Register(ContactSheetBlockMeta, ContactSheetBlock)
br.Register(CaseStudyBlockMeta, CaseStudyBlock)
br.Register(CaptionStripBlockMeta, CaptionStripBlock)
br.Register(ImagePairBlockMeta, ImagePairBlock)
br.Register(FooterBlockMeta, FooterBlock)
// Template overrides (5) — active only when this theme is selected.
br.RegisterTemplateOverride("noir", "heading", NoirHeadingBlock)
br.RegisterTemplateOverride("noir", "text", NoirTextBlock)
br.RegisterTemplateOverride("noir", "image", NoirImageBlock)
br.RegisterTemplateOverride("noir", "button", NoirButtonBlock)
br.RegisterTemplateOverride("noir", "card", NoirCardBlock)
// Branded email wrapper.
tr.RegisterEmailWrapper("noir", NoirEmailWrapper)
return nil
}
// DefaultMasterPages returns the two master pages Noir provisions on first load.
//
// noir:default-master covers the `default` and `article` page templates.
// noir:gallery-master covers `landing` and `full-width`, swapping the
// caption strip for a contact-sheet-style footer and using the dissolved navbar.
func DefaultMasterPages() []plugin.MasterPageDefinition {
return []plugin.MasterPageDefinition{
{
Key: "noir:default-master",
Title: "Noir Default Master",
PageTemplates: []string{"default", "article"},
Blocks: []plugin.MasterPageBlock{
{
BlockKey: "navbar",
Title: "Masthead",
Content: map[string]any{"menuName": "main", "variant": "minimal"},
Slot: "header",
SortOrder: 0,
},
{
BlockKey: "slot",
Title: "Main Slot",
Content: map[string]any{"slotName": "main", "placeholder": "Photographs go here"},
Slot: "main",
SortOrder: 0,
},
{
BlockKey: "noir:caption_strip",
Title: "Caption Strip",
Content: map[string]any{"label": "INDEX", "right": "© studio"},
Slot: "footer",
SortOrder: 0,
},
{
BlockKey: "noir:footer",
Title: "Footer",
Content: map[string]any{"showColophon": "true"},
Slot: "footer",
SortOrder: 1,
},
},
},
{
Key: "noir:gallery-master",
Title: "Noir Gallery Master",
PageTemplates: []string{"landing", "full-width"},
Blocks: []plugin.MasterPageBlock{
{
BlockKey: "navbar",
Title: "Dissolved Masthead",
Content: map[string]any{"menuName": "main", "variant": "dissolved"},
Slot: "header",
SortOrder: 0,
},
{
BlockKey: "slot",
Title: "Main Slot",
Content: map[string]any{"slotName": "main", "placeholder": "Photographs go here"},
Slot: "main",
SortOrder: 0,
},
{
BlockKey: "noir:contact_sheet_footer",
Title: "Contact Sheet Footer",
Content: map[string]any{"items": []any{}},
Slot: "footer",
SortOrder: 0,
},
{
BlockKey: "noir:footer",
Title: "Footer",
Content: map[string]any{"showColophon": "true"},
Slot: "footer",
SortOrder: 1,
},
},
},
}
}

25
registration.go Normal file
View File

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

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Caption Strip",
"description": "Full-width 10px mono caption strip with a label on the left and supporting text on the right.",
"type": "object",
"properties": {
"label": {
"type": "string",
"title": "Label",
"description": "Left-aligned label (e.g. 'INDEX', 'PLATE 01').",
"x-editor": "text"
},
"right": {
"type": "string",
"title": "Right Text",
"description": "Right-aligned supporting text (e.g. copyright, date).",
"x-editor": "text"
}
}
}

View File

@ -0,0 +1,48 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Project Case Study",
"description": "Long-form project spread with sticky meta rail and image-led prose.",
"type": "object",
"properties": {
"title": {
"type": "string",
"title": "Project Title",
"description": "Display title for the project.",
"x-editor": "text"
},
"client": {
"type": "string",
"title": "Client",
"description": "Client or commissioning party.",
"x-editor": "text"
},
"year": {
"type": "integer",
"title": "Year",
"description": "Year of the project.",
"x-editor": "number"
},
"credits": {
"type": "array",
"title": "Credits",
"description": "Production credits, one entry per line.",
"x-editor": "array",
"default": [],
"items": {
"type": "string",
"x-editor": "text"
}
},
"images": {
"type": "array",
"title": "Images",
"description": "Photographs in the case study spread.",
"x-editor": "array",
"default": [],
"items": {
"type": "string",
"x-editor": "media"
}
}
}
}

View File

@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Contact Sheet",
"description": "Numbered photograph frames with sprocket motif, evoking a darkroom contact sheet.",
"type": "object",
"properties": {
"items": {
"type": "array",
"title": "Frames",
"description": "Numbered photograph frames.",
"x-editor": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"image": {
"type": "string",
"title": "Image",
"description": "Photograph in this frame.",
"x-editor": "media"
},
"frame": {
"type": "string",
"title": "Frame Number",
"description": "Frame number or roll identifier (e.g. '12A', '027').",
"x-editor": "text"
},
"label": {
"type": "string",
"title": "Label",
"description": "Short caption shown alongside the frame number.",
"x-editor": "text"
}
},
"required": ["image"]
}
}
}
}

View File

@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Noir Footer",
"description": "Dissolved bottom rail with optional colophon and social links.",
"type": "object",
"properties": {
"showColophon": {
"type": "string",
"title": "Show Colophon",
"description": "Render the colophon block beneath the footer rail.",
"x-editor": "select",
"enum": ["true", "false"],
"default": "true"
},
"social": {
"type": "array",
"title": "Social Links",
"description": "Optional social media links shown on the right of the footer rail.",
"x-editor": "array",
"default": [],
"items": {
"type": "object",
"x-editor": "link",
"properties": {
"text": {
"type": "string",
"title": "Label",
"x-editor": "text"
},
"url": {
"type": "string",
"title": "URL",
"x-editor": "text"
}
},
"required": ["text", "url"]
}
}
}
}

View File

@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Image Pair",
"description": "50/50 photograph diptych with a shared caption beneath.",
"type": "object",
"properties": {
"left": {
"type": "string",
"title": "Left Image",
"description": "Left photograph in the diptych.",
"x-editor": "media"
},
"right": {
"type": "string",
"title": "Right Image",
"description": "Right photograph in the diptych.",
"x-editor": "media"
},
"caption": {
"type": "string",
"title": "Caption",
"description": "Shared caption shown beneath both images.",
"x-editor": "text"
}
}
}

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Lightbox Gallery",
"description": "Grid of photographs that expand to a full-viewport lightbox overlay on click.",
"type": "object",
"properties": {
"items": {
"type": "array",
"title": "Photographs",
"description": "Image entries with optional captions.",
"x-editor": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"image": {
"type": "string",
"title": "Image",
"description": "Photograph to display.",
"x-editor": "media"
},
"caption": {
"type": "string",
"title": "Caption",
"description": "Optional caption shown beneath the image and in the lightbox.",
"x-editor": "text"
}
},
"required": ["image"]
}
},
"columns": {
"type": "integer",
"title": "Columns",
"description": "Number of columns on desktop (2, 3, or 4).",
"x-editor": "select",
"enum": [2, 3, 4],
"default": 3
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

View File

@ -1,242 +0,0 @@
{
"demo": true,
"settings": {
"merge": {
"companyName": "The Cold File",
"tagline": "Cases that were never closed."
}
},
"media": [
{ "id": "7a1c0001-0000-4000-8000-000000000001", "file": "media/harbour-night.jpg", "alt": "A working harbour at night under sodium light", "folder": "noir" },
{ "id": "7a1c0001-0000-4000-8000-000000000002", "file": "media/foggy-night.jpg", "alt": "A deserted street lost in night fog", "folder": "noir" },
{ "id": "7a1c0001-0000-4000-8000-000000000003", "file": "media/typewriter.jpg", "alt": "An old typewriter on a desk", "folder": "noir" },
{ "id": "7a1c0001-0000-4000-8000-000000000004", "file": "media/streetlamp.jpg", "alt": "A single streetlamp in the dark", "folder": "noir" },
{ "id": "7a1c0001-0000-4000-8000-000000000005", "file": "media/foggy-street.jpg", "alt": "A wet street under fog and lamplight", "folder": "noir" },
{ "id": "7a1c0001-0000-4000-8000-000000000006", "file": "media/typewriter-keys.jpg", "alt": "Close detail of typewriter keys", "folder": "noir" }
],
"data_tables": [
{
"key": "case_tips",
"name": "Case Tips",
"description": "Seeded tip-line submissions for The Cold File demo. A submission becomes a row here and emails the site admins.",
"columns": [
{ "key": "name", "label": "Name", "type": "text", "required": false },
{ "key": "email", "label": "Email", "type": "email", "required": true },
{ "key": "subject", "label": "Case or subject", "type": "text", "required": false },
{ "key": "message", "label": "Message", "type": "text", "required": true }
]
}
],
"pages": [
{
"slug": "/",
"title": "The Cold File",
"template_key": "landing",
"reconcile_blocks": true,
"blocks": [
{
"block_key": "hero",
"title": "Hero",
"slot": "hero",
"sort_order": 1,
"content": {
"template": "centered",
"backgroundImage": "7a1c0001-0000-4000-8000-000000000001",
"overlayColor": "dark",
"overlayOpacity": 55,
"eyebrow": "A true-crime file",
"headline": "The Cold File",
"subheadline": "Cases that were never closed.",
"description": "We read the record, walk the ground, and lay out what is known. One case at a time. You decide what it adds up to.",
"ctaPrimary": { "text": "Read the files", "url": "/blog" },
"ctaSecondary": { "text": "Submit a tip", "url": "/contact" }
}
},
{
"block_key": "noir:case_file",
"title": "Featured case",
"slot": "main",
"sort_order": 2,
"content": {
"caseNumber": "CF-0451",
"subject": "The Harbour Light",
"status": "cold",
"summary": "A night watchman clocked out at two and never reached the gate. His lamp was still burning at dawn. The tide had turned twice.",
"fields": [
{ "label": "Filed", "value": "March 1998" },
{ "label": "Location", "value": "Pier 9, East Dock" },
{ "label": "Last contact", "value": "02:04, radio check" },
{ "label": "Status", "value": "Open, unsolved" }
]
}
},
{
"block_key": "feature-grid",
"title": "How we work",
"slot": "main",
"sort_order": 3,
"content": {
"eyebrow": "Method",
"heading": "How we work a file",
"intro": "No theory before the record. We build the case from what can be checked.",
"columns": 3,
"layout": "card",
"items": [
{ "icon": "lucide:file-text", "title": "Read the record", "text": "Reports, transcripts, and the file as it was left. We start where the paper starts." },
{ "icon": "lucide:map-pin", "title": "Walk the ground", "text": "We stand where it happened and check the file against the place." },
{ "icon": "lucide:search", "title": "Follow the thread", "text": "One fact at a time. We show our work and mark what stays unknown." }
]
}
},
{
"block_key": "noir:redacted_quote",
"title": "Transcript",
"slot": "main",
"sort_order": 4,
"content": {
"label": "Interview 3, page 12",
"quote": "I told them what I saw at the water. They wrote it down and the name came back",
"redacted": "withheld pending review",
"attribution": "Witness statement",
"source": "Case CF-0451, sealed appendix"
}
},
{
"block_key": "cta",
"title": "Closing CTA",
"slot": "cta",
"sort_order": 1,
"content": {
"background": "band",
"heading": "Know something we don't?",
"text": "Tips are read in confidence. Names stay off the page unless you ask otherwise.",
"primaryLabel": "Submit a tip",
"primaryUrl": "/contact",
"secondaryLabel": "Read the files",
"secondaryUrl": "/blog"
}
}
]
},
{
"slug": "/about",
"title": "About the File",
"template_key": "default",
"blocks": [
{ "block_key": "heading", "title": "Heading", "slot": "main", "sort_order": 1, "content": { "text": "About the File", "level": 1 } },
{ "block_key": "rich-text", "title": "Story", "slot": "main", "sort_order": 2, "content": { "html": "<p>The Cold File is a true-crime publication. We take one unsolved case at a time and set it out plainly, from the record up.</p><p>We do not name the guilty. We do not sell a theory. We show what is known, mark what is not, and leave the reader to weigh it. When new facts arrive, the file is updated in the open.</p>" } },
{ "block_key": "image", "title": "The desk", "slot": "main", "sort_order": 3, "content": { "src": "7a1c0001-0000-4000-8000-000000000003", "alt": "The desk where the files are read", "caption": "The desk. Every file starts here.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
{ "block_key": "divider", "title": "Divider", "slot": "main", "sort_order": 4, "content": { "variant": "line", "weight": "thick", "width": "narrow" } },
{ "block_key": "quote", "title": "Quote", "slot": "main", "sort_order": 5, "content": { "style": "pull", "align": "center", "quote": "A cold case is not a closed one. It is a file waiting for one more fact.", "attribution": "The Cold File" } }
]
},
{
"slug": "/blog",
"title": "The Case Files",
"template_key": "blog-index",
"blocks": [
{ "block_key": "card", "title": "Case: Harbour Light", "slot": "main", "sort_order": 1, "content": { "layout": "vertical", "title": "The Harbour Light", "text": "<p>A watchman clocked out and never reached the gate. The lamp burned until dawn.</p>", "link": "/blog/the-harbour-light", "linkLabel": "Open the file" } },
{ "block_key": "card", "title": "Case: Room 14", "slot": "main", "sort_order": 2, "content": { "layout": "vertical", "title": "Room 14", "text": "<p>A guest checked in under a false name and left one suitcase behind. Nobody saw the door open.</p>", "link": "/blog/room-14", "linkLabel": "Open the file" } },
{ "block_key": "card", "title": "Case: The Last Bus", "slot": "main", "sort_order": 3, "content": { "layout": "vertical", "title": "The Last Bus", "text": "<p>The driver logged the final stop on time. The bus reached the depot empty and locked from inside.</p>", "link": "/blog/the-last-bus", "linkLabel": "Open the file" } }
]
},
{
"slug": "/blog/the-harbour-light",
"title": "The Harbour Light",
"template_key": "article",
"parent_slug": "/blog",
"reconcile_blocks": true,
"blocks": [
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "The Harbour Light", "level": 1 } },
{ "block_key": "image", "title": "The pier", "slot": "main", "sort_order": 2, "content": { "src": "7a1c0001-0000-4000-8000-000000000002", "alt": "The East Dock lost in night fog", "caption": "Pier 9, East Dock — the same hour we walked it.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 3, "content": { "html": "<p>The watchman made his last radio check at four minutes past two. He was due at the gate by half past. He never signed out.</p><p>The lamp on Pier 9 was still lit at dawn. The tide had turned twice. No boat was reported missing. No body was found. The file was opened, worked for a season, and set aside.</p><p>Start with what the record holds. A duty roster with his name in the two-to-six column. A radio log with four entries in his hand, the last at 02:04, marked routine. A gate ledger that shows every man off the dock by three — every man but one.</p><p>The season it was worked, the file gathered the usual paper. Two dozen interviews. A diver's report for the water off the pier head, twice. A note from the gate man, who swore the light was burning when he cycled past at ten to three and swore just as hard that nobody stood under it.</p><p>Then the file stops. Not closed — stopped. The last page is a requisition for more diving time that nobody signed.</p><p>We pulled the harbour master's log and the tide tables for that week. The two do not agree on when the water turned. One of them was written up after the fact, and we think we know which.</p><p>We walked the pier at the same hour. The lamp throws light about forty feet. The gate is ninety. For six of those steps a man is in the dark. We are still checking what stands in them.</p><p>There is one more thing the record holds, and we set it down here exactly as it appears. The radio log's final line, in a different hand, timed 02:31: <em>checked pier, all quiet</em>. Nobody at the harbour that season would say whose hand it was.</p><p>The file is open. If you worked the East Dock in March 1998, or knew someone who did, the tip line is at the end of this page.</p>" } },
{ "block_key": "heading", "title": "Rail heading", "slot": "aside", "sort_order": 1, "content": { "text": "Case File", "level": 3 } },
{ "block_key": "text", "title": "Case metadata", "slot": "aside", "sort_order": 2, "content": { "text": "<dl class=\"not-prose space-y-4\"><div><dt class=\"noir-label text-muted-foreground\">Case No.</dt><dd class=\"noir-mono text-sm text-foreground\">CF-0451</dd></div><div><dt class=\"noir-label text-muted-foreground\">Filed</dt><dd class=\"noir-mono text-sm text-foreground\">March 1998</dd></div><div><dt class=\"noir-label text-muted-foreground\">Location</dt><dd class=\"noir-mono text-sm text-foreground\">Pier 9, East Dock</dd></div><div><dt class=\"noir-label text-muted-foreground\">Last contact</dt><dd class=\"noir-mono text-sm text-foreground\">02:04, radio check</dd></div><div><dt class=\"noir-label text-muted-foreground\">Status</dt><dd class=\"noir-mono text-sm text-foreground\">Open, unsolved</dd></div></dl>" } }
]
},
{
"slug": "/blog/room-14",
"title": "Room 14",
"template_key": "article",
"parent_slug": "/blog",
"blocks": [
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "Room 14", "level": 1 } },
{ "block_key": "image", "title": "The hall", "slot": "main", "sort_order": 2, "content": { "src": "7a1c0001-0000-4000-8000-000000000004", "alt": "A single lamp at the quiet end of the hall", "caption": "The quiet end of the hall. One lamp, kept lit.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 3, "content": { "html": "<p>The guest signed the register in a hand that did not match the name. He paid a week in advance and asked for the quiet end of the hall.</p><p>On the fourth night the room went dark and stayed that way. The suitcase was still there. The bed was made. The window was latched from the inside.</p><p>The file lists three people who saw him arrive and none who saw him leave. The night porter swears the corridor was watched the whole shift. The register says otherwise, in the same hand that signed the wrong name.</p><p>We are trying to reconcile that. So far the only thing everyone agrees on is the smell of a struck match, and no one who admits to lighting one.</p>" } },
{ "block_key": "heading", "title": "Rail heading", "slot": "aside", "sort_order": 1, "content": { "text": "Case File", "level": 3 } },
{ "block_key": "text", "title": "Case metadata", "slot": "aside", "sort_order": 2, "content": { "text": "<dl class=\"not-prose space-y-4\"><div><dt class=\"noir-label text-muted-foreground\">Case No.</dt><dd class=\"noir-mono text-sm text-foreground\">CF-0212</dd></div><div><dt class=\"noir-label text-muted-foreground\">Filed</dt><dd class=\"noir-mono text-sm text-foreground\">November 2003</dd></div><div><dt class=\"noir-label text-muted-foreground\">Location</dt><dd class=\"noir-mono text-sm text-foreground\">The Old Exchange</dd></div><div><dt class=\"noir-label text-muted-foreground\">Last seen</dt><dd class=\"noir-mono text-sm text-foreground\">Night four, 21:10</dd></div><div><dt class=\"noir-label text-muted-foreground\">Status</dt><dd class=\"noir-mono text-sm text-foreground\">Open, unsolved</dd></div></dl>" } }
]
},
{
"slug": "/blog/the-last-bus",
"title": "The Last Bus",
"template_key": "article",
"parent_slug": "/blog",
"blocks": [
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "The Last Bus", "level": 1 } },
{ "block_key": "image", "title": "The route", "slot": "main", "sort_order": 2, "content": { "src": "7a1c0001-0000-4000-8000-000000000005", "alt": "A wet street under lamplight on the last route", "caption": "The last route, run again at the same hour.", "aspectRatio": "video", "size": "full", "objectFit": "cover" } },
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 3, "content": { "html": "<p>The driver logged the final stop on schedule. Twelve minutes later the bus rolled into the depot. It was empty and the doors were locked from inside.</p><p>The fare box held the right count for a full run. The seats were cold. The last passenger on the ticket record never reached home.</p><p>We drove the route ourselves at the same hour and clocked every stop. Between the second-last and the depot there is a stretch with no lights and no camera. It takes about twelve minutes.</p><p>We have the route, the timing, and the log. What we do not have is what happened in those twelve minutes, or why the doors were locked from the inside.</p>" } },
{ "block_key": "heading", "title": "Rail heading", "slot": "aside", "sort_order": 1, "content": { "text": "Case File", "level": 3 } },
{ "block_key": "text", "title": "Case metadata", "slot": "aside", "sort_order": 2, "content": { "text": "<dl class=\"not-prose space-y-4\"><div><dt class=\"noir-label text-muted-foreground\">Case No.</dt><dd class=\"noir-mono text-sm text-foreground\">CF-0388</dd></div><div><dt class=\"noir-label text-muted-foreground\">Filed</dt><dd class=\"noir-mono text-sm text-foreground\">July 2009</dd></div><div><dt class=\"noir-label text-muted-foreground\">Route</dt><dd class=\"noir-mono text-sm text-foreground\">Night 9, outbound</dd></div><div><dt class=\"noir-label text-muted-foreground\">Depot</dt><dd class=\"noir-mono text-sm text-foreground\">00:12, locked</dd></div><div><dt class=\"noir-label text-muted-foreground\">Status</dt><dd class=\"noir-mono text-sm text-foreground\">Open, unsolved</dd></div></dl>" } }
]
},
{
"slug": "/contact",
"title": "Tip Line",
"template_key": "contact",
"blocks": [
{
"block_key": "contact-form",
"title": "Tip Line",
"slot": "main",
"sort_order": 1,
"content": {
"heading": "Submit a tip",
"description": "Tips are read in confidence. Tell us the case and what you know. Leave contact details only if you want a reply.",
"submitLabel": "Send tip",
"successMessage": "Thank you. Your tip has been logged. We read every one.",
"fields": [
{ "name": "name", "label": "Your name", "type": "text", "required": false, "width": "half" },
{ "name": "email", "label": "Email", "type": "email", "required": true, "width": "half" },
{ "name": "subject", "label": "Case or subject", "type": "text", "required": false, "width": "full" },
{ "name": "message", "label": "What do you know?", "type": "textarea", "required": true, "width": "full" }
],
"formConfig": { "enabled": true, "captchaEnabled": false, "targetTable": "case_tips" }
}
},
{
"block_key": "hours-location",
"title": "Where to find us",
"slot": "aside",
"sort_order": 1,
"content": {
"heading": "The Desk",
"hours": [
{ "day": "Monday to Friday", "hours": "9am to 6pm" },
{ "day": "Saturday", "hours": "By appointment" },
{ "day": "Sunday", "closed": true }
],
"specialHoursNote": "Tips are read outside these hours. Replies are not.",
"address": "Room 14, The Old Exchange\nEast Dock",
"phone": "+1 555 0199"
}
}
]
},
{
"slug": "/login",
"title": "Access",
"template_key": "auth",
"blocks": [
{ "block_key": "auth-form", "title": "Sign in", "slot": "main", "sort_order": 1, "content": { "default_tab": "login", "show_social": false } },
{ "block_key": "text", "title": "Foot", "slot": "footer", "sort_order": 1, "content": { "text": "<p>Registered readers get new files first and the sealed appendices as they clear review.</p>" } }
]
}
],
"menu_items": [
{ "menu": "main", "label": "Home", "page_slug": "/", "sort_order": 1 },
{ "menu": "main", "label": "The Case Files", "page_slug": "/blog", "sort_order": 2 },
{ "menu": "main", "label": "About the File", "page_slug": "/about", "sort_order": 3 },
{ "menu": "main", "label": "Tip Line", "page_slug": "/contact", "sort_order": 4 }
]
}

View File

@ -1,19 +0,0 @@
{
"workflows": [
{
"key": "case_tip_notify_admins",
"name": "Case tip to admins",
"description": "On a tip-line submission, email the site admins.",
"enabled": true,
"trigger": { "type": "row_inserted", "table": "case_tips" },
"steps": [
{
"type": "email",
"name": "Email admins",
"template": "Form Submission Notification",
"recipients": { "mode": "admins", "scope": "all" }
}
]
}
]
}

288
template.templ Normal file
View File

@ -0,0 +1,288 @@
package main
import (
"context"
"git.dev.alexdunmow.com/block/core/templates/bn"
)
// NoirPageData holds the parsed view-model for every Noir page template.
type NoirPageData struct {
Title string
Slots map[string]string
ThemeMode string
ThemeCSS string
SiteSettings bn.SiteSettingsData
PageMeta bn.PageMeta
StructuredData string
CSSHash string
PageviewNonce string
EngagementConfig bn.EngagementConfig
}
func parseNoirPageData(doc map[string]any) NoirPageData {
title := "Untitled"
if t, ok := doc["title"].(string); ok {
title = t
}
slots := make(map[string]string)
if s, ok := doc["slots"].(map[string]string); ok {
slots = s
}
themeCSS := ""
if tc, ok := doc["theme_css"].(string); ok {
themeCSS = tc
}
structuredData := ""
if sd, ok := doc["structured_data"].(string); ok {
structuredData = sd
}
cssHash := ""
if ch, ok := doc["css_hash"].(string); ok {
cssHash = ch
}
pageviewNonce := ""
if pn, ok := doc["pageview_nonce"].(string); ok {
pageviewNonce = pn
}
themeMode := "dark"
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
themeMode = tm
}
siteSettings := bn.ParseSiteSettings(doc)
pageMeta := bn.ParsePageMeta(doc)
engagementConfig := bn.ParseEngagementConfig(doc)
return NoirPageData{
Title: title,
Slots: slots,
ThemeMode: themeMode,
ThemeCSS: themeCSS,
SiteSettings: siteSettings,
PageMeta: pageMeta,
StructuredData: structuredData,
CSSHash: cssHash,
PageviewNonce: pageviewNonce,
EngagementConfig: engagementConfig,
}
}
// noirHead emits the shared <head> block plus the lightbox-bootstrap script.
templ noirHead(data NoirPageData) {
@bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/noir/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
})
}
// noirLightboxScript injects the vanilla keyboard-aware lightbox handler.
// Stays tiny (no deps) and uses the data attributes set by lightbox_gallery.
templ noirLightboxScript() {
<script>
(function() {
if (window.__noirLightboxInit) return;
window.__noirLightboxInit = true;
var overlay = null;
function ensureOverlay() {
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.setAttribute('data-noir-lightbox', '');
overlay.setAttribute('aria-hidden', 'true');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = '<button type="button" class="noir-lightbox-close tracked-mono" aria-label="Close">Close</button><img alt="" /><div class="noir-lightbox-caption tracked-mono"></div>';
document.body.appendChild(overlay);
overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); });
overlay.querySelector('.noir-lightbox-close').addEventListener('click', close);
document.addEventListener('keydown', function(e) {
if (overlay.getAttribute('aria-hidden') === 'false' && e.key === 'Escape') close();
});
return overlay;
}
function open(src, alt, caption) {
var o = ensureOverlay();
var img = o.querySelector('img');
img.src = src;
img.alt = alt || '';
o.querySelector('.noir-lightbox-caption').textContent = caption || '';
o.setAttribute('aria-hidden', 'false');
o.querySelector('.noir-lightbox-close').focus();
}
function close() {
if (!overlay) return;
overlay.setAttribute('aria-hidden', 'true');
}
document.addEventListener('click', function(e) {
var t = e.target.closest('[data-noir-lightbox-trigger]');
if (!t) return;
e.preventDefault();
open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));
});
document.addEventListener('keydown', function(e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
var t = document.activeElement;
if (t && t.matches && t.matches('[data-noir-lightbox-trigger]')) {
e.preventDefault();
open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));
}
});
})();
</script>
}
// NoirDefault — centred gallery page with thin masthead and dissolved footer.
templ NoirDefault(data NoirPageData) {
<!DOCTYPE html>
<html lang="en" class="dark">
@noirHead(data)
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="default">
@bn.AdminBypassBanner(data.SiteSettings)
<header class="w-full hairline-b">
<div class="max-w-5xl mx-auto px-6">
@templ.Raw(data.Slots["header"])
</div>
</header>
<main class="flex-grow max-w-5xl mx-auto w-full px-6 py-12">
if main, ok := data.Slots["main"]; ok && main != "" {
@templ.Raw(main)
} else {
<div class="py-24 text-center">
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">No photographs assigned to this page.</p>
</div>
}
</main>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@noirLightboxScript()
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
// NoirLanding — edge-to-edge hero, micro caption strip, minimal CTA.
templ NoirLanding(data NoirPageData) {
<!DOCTYPE html>
<html lang="en" class="dark">
@noirHead(data)
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="landing">
@bn.AdminBypassBanner(data.SiteSettings)
<section class="w-full">
@templ.Raw(data.Slots["hero"])
</section>
<main class="flex-grow">
if main, ok := data.Slots["main"]; ok && main != "" {
<div class="max-w-5xl mx-auto px-6 py-16">
@templ.Raw(main)
</div>
}
</main>
<section class="w-full">
@templ.Raw(data.Slots["cta"])
</section>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@noirLightboxScript()
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
// NoirArticle — long-form case study with sticky caption rail and image-led prose.
templ NoirArticle(data NoirPageData) {
<!DOCTYPE html>
<html lang="en" class="dark">
@noirHead(data)
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="article">
@bn.AdminBypassBanner(data.SiteSettings)
<header class="w-full hairline-b">
<div class="max-w-5xl mx-auto px-6">
@templ.Raw(data.Slots["header"])
</div>
</header>
<main class="flex-grow max-w-5xl mx-auto w-full px-6 py-12">
<div class="grid grid-cols-1 md:grid-cols-[12rem_1fr] gap-8">
<aside class="noir-meta-rail">
@templ.Raw(data.Slots["aside"])
</aside>
<article class="min-w-0">
if main, ok := data.Slots["main"]; ok && main != "" {
@templ.Raw(main)
} else {
<div class="py-24 text-center">
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">No content assigned to this article.</p>
</div>
}
</article>
</div>
</main>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@noirLightboxScript()
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
// NoirFullWidth — contact-sheet or lightbox grid, no horizontal padding.
templ NoirFullWidth(data NoirPageData) {
<!DOCTYPE html>
<html lang="en" class="dark">
@noirHead(data)
<body class="noir-page noir-surface antialiased min-h-screen flex flex-col" data-noir-template="full-width">
@bn.AdminBypassBanner(data.SiteSettings)
<header class="w-full hairline-b">
<div class="max-w-5xl mx-auto px-6">
@templ.Raw(data.Slots["header"])
</div>
</header>
<main class="flex-grow w-full">
if main, ok := data.Slots["main"]; ok && main != "" {
@templ.Raw(main)
} else {
<div class="max-w-5xl mx-auto py-24 px-6 text-center">
<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">No photographs assigned to this page.</p>
</div>
}
</main>
<footer class="w-full mt-auto">
@templ.Raw(data.Slots["footer"])
</footer>
@noirLightboxScript()
@bn.BodyEnd(data.SiteSettings)
</body>
</html>
}
func RenderNoirDefault(ctx context.Context, doc map[string]any) templ.Component {
return NoirDefault(parseNoirPageData(doc))
}
func RenderNoirLanding(ctx context.Context, doc map[string]any) templ.Component {
return NoirLanding(parseNoirPageData(doc))
}
func RenderNoirArticle(ctx context.Context, doc map[string]any) templ.Component {
return NoirArticle(parseNoirPageData(doc))
}
func RenderNoirFullWidth(ctx context.Context, doc map[string]any) templ.Component {
return NoirFullWidth(parseNoirPageData(doc))
}

548
template_templ.go Normal file
View File

@ -0,0 +1,548 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"git.dev.alexdunmow.com/block/core/templates/bn"
)
// NoirPageData holds the parsed view-model for every Noir page template.
type NoirPageData struct {
Title string
Slots map[string]string
ThemeMode string
ThemeCSS string
SiteSettings bn.SiteSettingsData
PageMeta bn.PageMeta
StructuredData string
CSSHash string
PageviewNonce string
EngagementConfig bn.EngagementConfig
}
func parseNoirPageData(doc map[string]any) NoirPageData {
title := "Untitled"
if t, ok := doc["title"].(string); ok {
title = t
}
slots := make(map[string]string)
if s, ok := doc["slots"].(map[string]string); ok {
slots = s
}
themeCSS := ""
if tc, ok := doc["theme_css"].(string); ok {
themeCSS = tc
}
structuredData := ""
if sd, ok := doc["structured_data"].(string); ok {
structuredData = sd
}
cssHash := ""
if ch, ok := doc["css_hash"].(string); ok {
cssHash = ch
}
pageviewNonce := ""
if pn, ok := doc["pageview_nonce"].(string); ok {
pageviewNonce = pn
}
themeMode := "dark"
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
themeMode = tm
}
siteSettings := bn.ParseSiteSettings(doc)
pageMeta := bn.ParsePageMeta(doc)
engagementConfig := bn.ParseEngagementConfig(doc)
return NoirPageData{
Title: title,
Slots: slots,
ThemeMode: themeMode,
ThemeCSS: themeCSS,
SiteSettings: siteSettings,
PageMeta: pageMeta,
StructuredData: structuredData,
CSSHash: cssHash,
PageviewNonce: pageviewNonce,
EngagementConfig: engagementConfig,
}
}
// noirHead emits the shared <head> block plus the lightbox-bootstrap script.
func noirHead(data NoirPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = bn.Head(bn.HeadData{
Title: data.Title,
Settings: data.SiteSettings,
PageMeta: data.PageMeta,
ThemeMode: data.ThemeMode,
ThemeCSS: data.ThemeCSS,
PluginStyles: []string{"/templates/noir/style.css"},
StructuredData: data.StructuredData,
CSSHash: data.CSSHash,
PageviewNonce: data.PageviewNonce,
EngagementConfig: data.EngagementConfig,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// noirLightboxScript injects the vanilla keyboard-aware lightbox handler.
// Stays tiny (no deps) and uses the data attributes set by lightbox_gallery.
func noirLightboxScript() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n\t\t(function() {\n\t\t if (window.__noirLightboxInit) return;\n\t\t window.__noirLightboxInit = true;\n\t\t var overlay = null;\n\t\t function ensureOverlay() {\n\t\t if (overlay) return overlay;\n\t\t overlay = document.createElement('div');\n\t\t overlay.setAttribute('data-noir-lightbox', '');\n\t\t overlay.setAttribute('aria-hidden', 'true');\n\t\t overlay.setAttribute('role', 'dialog');\n\t\t overlay.setAttribute('aria-modal', 'true');\n\t\t overlay.innerHTML = '<button type=\"button\" class=\"noir-lightbox-close tracked-mono\" aria-label=\"Close\">Close</button><img alt=\"\" /><div class=\"noir-lightbox-caption tracked-mono\"></div>';\n\t\t document.body.appendChild(overlay);\n\t\t overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); });\n\t\t overlay.querySelector('.noir-lightbox-close').addEventListener('click', close);\n\t\t document.addEventListener('keydown', function(e) {\n\t\t if (overlay.getAttribute('aria-hidden') === 'false' && e.key === 'Escape') close();\n\t\t });\n\t\t return overlay;\n\t\t }\n\t\t function open(src, alt, caption) {\n\t\t var o = ensureOverlay();\n\t\t var img = o.querySelector('img');\n\t\t img.src = src;\n\t\t img.alt = alt || '';\n\t\t o.querySelector('.noir-lightbox-caption').textContent = caption || '';\n\t\t o.setAttribute('aria-hidden', 'false');\n\t\t o.querySelector('.noir-lightbox-close').focus();\n\t\t }\n\t\t function close() {\n\t\t if (!overlay) return;\n\t\t overlay.setAttribute('aria-hidden', 'true');\n\t\t }\n\t\t document.addEventListener('click', function(e) {\n\t\t var t = e.target.closest('[data-noir-lightbox-trigger]');\n\t\t if (!t) return;\n\t\t e.preventDefault();\n\t\t open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));\n\t\t });\n\t\t document.addEventListener('keydown', function(e) {\n\t\t if (e.key !== 'Enter' && e.key !== ' ') return;\n\t\t var t = document.activeElement;\n\t\t if (t && t.matches && t.matches('[data-noir-lightbox-trigger]')) {\n\t\t e.preventDefault();\n\t\t open(t.getAttribute('data-src'), t.getAttribute('data-alt'), t.getAttribute('data-caption'));\n\t\t }\n\t\t });\n\t\t})();\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// NoirDefault — centred gallery page with thin masthead and dissolved footer.
func NoirDefault(data NoirPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!doctype html><html lang=\"en\" class=\"dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"default\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<header class=\"w-full hairline-b\"><div class=\"max-w-5xl mx-auto px-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></header><main class=\"flex-grow max-w-5xl mx-auto w-full px-6 py-12\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if main, ok := data.Slots["main"]; ok && main != "" {
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"py-24 text-center\"><p class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">No photographs assigned to this page.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</main><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// NoirLanding — edge-to-edge hero, micro caption strip, minimal CTA.
func NoirLanding(data NoirPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!doctype html><html lang=\"en\" class=\"dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"landing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<section class=\"w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["hero"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</section><main class=\"flex-grow\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if main, ok := data.Slots["main"]; ok && main != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"max-w-5xl mx-auto px-6 py-16\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</main><section class=\"w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["cta"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</section><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// NoirArticle — long-form case study with sticky caption rail and image-led prose.
func NoirArticle(data NoirPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<!doctype html><html lang=\"en\" class=\"dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"article\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<header class=\"w-full hairline-b\"><div class=\"max-w-5xl mx-auto px-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></header><main class=\"flex-grow max-w-5xl mx-auto w-full px-6 py-12\"><div class=\"grid grid-cols-1 md:grid-cols-[12rem_1fr] gap-8\"><aside class=\"noir-meta-rail\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["aside"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside><article class=\"min-w-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if main, ok := data.Slots["main"]; ok && main != "" {
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"py-24 text-center\"><p class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">No content assigned to this article.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</article></div></main><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// NoirFullWidth — contact-sheet or lightbox grid, no horizontal padding.
func NoirFullWidth(data NoirPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<!doctype html><html lang=\"en\" class=\"dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirHead(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<body class=\"noir-page noir-surface antialiased min-h-screen flex flex-col\" data-noir-template=\"full-width\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.AdminBypassBanner(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<header class=\"w-full hairline-b\"><div class=\"max-w-5xl mx-auto px-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["header"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></header><main class=\"flex-grow w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if main, ok := data.Slots["main"]; ok && main != "" {
templ_7745c5c3_Err = templ.Raw(main).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"max-w-5xl mx-auto py-24 px-6 text-center\"><p class=\"tracked-mono\" style=\"color: hsl(var(--mutedForeground));\">No photographs assigned to this page.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</main><footer class=\"w-full mt-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(data.Slots["footer"]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = noirLightboxScript().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = bn.BodyEnd(data.SiteSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func RenderNoirDefault(ctx context.Context, doc map[string]any) templ.Component {
return NoirDefault(parseNoirPageData(doc))
}
func RenderNoirLanding(ctx context.Context, doc map[string]any) templ.Component {
return NoirLanding(parseNoirPageData(doc))
}
func RenderNoirArticle(ctx context.Context, doc map[string]any) templ.Component {
return NoirArticle(parseNoirPageData(doc))
}
func RenderNoirFullWidth(ctx context.Context, doc map[string]any) templ.Component {
return NoirFullWidth(parseNoirPageData(doc))
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,69 +0,0 @@
<!doctype html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<title>{{ site_name }}</title>
<style type="text/css">
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
body { margin: 0 !important; padding: 0 !important; width: 100% !important; }
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
@media only screen and (max-width: 620px) {
.email-container { width: 100% !important; max-width: 100% !important; }
.content-padding { padding-left: 24px !important; padding-right: 24px !important; }
}
</style>
</head>
<body style="background-color: {{ colors.background }}; margin: 0; padding: 0; font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;">
{% if preview_text %}<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">{{ preview_text }}</div>{% endif %}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" style="padding: 32px 10px; background-color: {{ colors.background }};">
<table role="presentation" class="email-container" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; background-color: {{ colors.card }}; border: 1px solid {{ colors.border }};">
<tr>
<td style="padding: 0; background-color: {{ colors.foreground }};">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding: 22px 32px 8px; font-family: 'Courier New', Courier, monospace; font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; color: {{ colors.primary }};">Case file</td>
</tr>
{% if site_name %}<tr><td style="padding: 0 32px 22px; font-family: 'Courier New', Courier, monospace; font-size: 24px; letter-spacing: 0.06em; text-transform: uppercase; color: {{ colors.background }};">{{ site_name }}</td></tr>{% endif %}
</table>
<div style="height: 3px; background-color: {{ colors.primary }};">&nbsp;</div>
</td>
</tr>
<tr>
<td class="content-padding" style="padding: 40px 48px; color: {{ colors.foreground }}; font-size: 16px; line-height: 1.7;">{{ body|safe }}</td>
</tr>
<tr>
<td style="padding: 0 48px;"><hr style="border: none; border-top: 1px solid {{ colors.border }}; margin: 0;"></td>
</tr>
<tr>
<td align="left" style="padding: 24px 48px; color: {{ colors.mutedForeground }}; font-size: 13px;">
<p style="margin: 0 0 8px; font-family: 'Courier New', Courier, monospace; text-transform: uppercase; letter-spacing: 0.18em; font-size: 12px; color: {{ colors.foreground }};">{{ site_name }}</p>
{% if site_url %}<p style="margin: 0 0 8px;"><a href="{{ site_url }}" style="color: {{ colors.primary }}; text-decoration: none; border-bottom: 1px solid {{ colors.primary }};">{{ site_url }}</a></p>{% endif %}
{% if support_email %}<p style="margin: 12px 0 0; color: {{ colors.mutedForeground }};">Tips and enquiries: {{ support_email }}</p>{% endif %}
{% if unsubscribe_url %}<p style="margin: 16px 0 0; font-size: 11px;"><a href="{{ unsubscribe_url }}" style="color: {{ colors.mutedForeground }}; text-decoration: none;">Unsubscribe</a></p>{% endif %}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -1,36 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
{{ admin_banner_html|safe }}
<header class="w-full">
<div class="max-w-3xl mx-auto px-4">{{ slots.header|safe }}</div>
</header>
<div class="flex-grow grid gap-12 max-w-5xl mx-auto w-full px-4 py-12 {% if slots.aside %}md:grid-cols-[1fr_16rem]{% endif %}">
<main class="min-w-0 noir-reveal">
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}
</main>
{% if slots.aside %}<aside class="w-full noir-reveal md:border-l md:border-[hsl(var(--border))] md:pl-8">
<div class="md:sticky md:top-6">{{ slots.aside|safe }}</div>
</aside>{% endif %}
</div>
<footer class="w-full mt-auto noir-rule">
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
</footer>
{{ body_end_html|safe }}
<script>
(function(){
if(window.__noirReveal)return;window.__noirReveal=1;
document.documentElement.classList.add('noir-js');
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var els=document.querySelectorAll('.noir-reveal');
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0,rootMargin:'0px 0px -8% 0px'});
function showAll(){els.forEach(function(el){el.classList.add('noir-in');});}
els.forEach(function(el){var r=el.getBoundingClientRect();var h=window.innerHeight||document.documentElement.clientHeight||0;if(!h||(r.top<h*0.95&&r.bottom>0)){el.classList.add('noir-in');}else{io.observe(el);}});
window.addEventListener('load',showAll);
setTimeout(showAll,450);
})();
</script>
</body>
</html>

View File

@ -1,18 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-grain noir-body">
{{ admin_banner_html|safe }}
<main class="flex-grow w-full flex flex-col items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<a href="/" class="block text-center" aria-label="{{ site_settings.Title }} home">
{% if site_settings.Logo %}<img src="{{ site_settings.Logo }}" alt="{{ site_settings.LogoAlt }}" class="mx-auto h-12 w-auto">{% else %}<span class="noir-heading text-2xl" style="color:hsl(var(--foreground));">{{ site_settings.Title|default:"The Cold File" }}</span>{% endif %}
</a>
<div class="noir-label mt-3 text-center" style="color:hsl(var(--primary));">Restricted access</div>
<div class="mt-6">{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No form assigned to this page.</p></div>{% endif %}</div>
{% if slots.footer %}<div class="mt-6 text-center noir-mono text-sm" style="color:hsl(var(--muted-foreground));">{{ slots.footer|safe }}</div>{% endif %}
</div>
</main>
{{ body_end_html|safe }}
</body>
</html>

View File

@ -1,35 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
{{ admin_banner_html|safe }}
{% if slots.header %}<header class="w-full">{{ slots.header|safe }}</header>{% endif %}
<main class="flex-grow w-full">
<div class="max-w-6xl mx-auto px-4 py-12 md:py-16">
{% if title %}
<div>
<div class="noir-label" style="color:hsl(var(--primary));">Case index</div>
<h1 class="noir-heading mt-3 text-4xl md:text-5xl" style="color:hsl(var(--foreground));">{{ title }}</h1>
{% if page_meta.MetaDescription %}<p class="noir-body mt-4 max-w-2xl text-lg" style="color:hsl(var(--muted-foreground));">{{ page_meta.MetaDescription }}</p>{% endif %}
<div class="mt-6 noir-rule"></div>
</div>
{% endif %}
{% if slots.featured %}<section class="mt-10 noir-reveal">{{ slots.featured|safe }}</section>{% endif %}
{% if slots.main %}<section class="mt-10 noir-reveal">{{ slots.main|safe }}</section>{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No posts published yet.</p></div>{% endif %}
</div>
</main>
{% if slots.footer %}<footer class="w-full mt-auto noir-rule">{{ slots.footer|safe }}</footer>{% endif %}
{{ body_end_html|safe }}
<script>
(function(){
if(window.__noirReveal)return;window.__noirReveal=1;
document.documentElement.classList.add('noir-js');
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var els=document.querySelectorAll('.noir-reveal');
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0.12});
els.forEach(function(el){io.observe(el);});
})();
</script>
</body>
</html>

View File

@ -1,33 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
{{ admin_banner_html|safe }}
{% if slots.header %}<header class="w-full">{{ slots.header|safe }}</header>{% endif %}
<main class="flex-grow w-full">
<div class="max-w-6xl mx-auto px-4 py-12 md:py-16">
{% if slots.aside %}
<div class="grid gap-12 md:grid-cols-[2fr_1fr]">
<div class="noir-reveal">{% if slots.main %}{{ slots.main|safe }}{% endif %}</div>
<aside class="w-full md:border-l md:border-[hsl(var(--border))] md:pl-10">{{ slots.aside|safe }}</aside>
</div>
{% else %}
<div class="max-w-3xl mx-auto noir-reveal">{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}</div>
{% endif %}
</div>
</main>
{% if slots.footer %}<footer class="w-full mt-auto noir-rule">{{ slots.footer|safe }}</footer>{% endif %}
{{ body_end_html|safe }}
<script>
(function(){
if(window.__noirReveal)return;window.__noirReveal=1;
document.documentElement.classList.add('noir-js');
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var els=document.querySelectorAll('.noir-reveal');
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0.12});
els.forEach(function(el){io.observe(el);});
})();
</script>
</body>
</html>

View File

@ -1,33 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
{{ admin_banner_html|safe }}
<header class="w-full">
<div class="max-w-5xl mx-auto px-4">{{ slots.header|safe }}</div>
</header>
<main class="flex-grow w-full">
<div class="max-w-3xl mx-auto w-full px-4 py-12 noir-reveal">
{% if slots.main %}{{ slots.main|safe }}{% else %}<div class="py-20 text-center"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}
</div>
</main>
<footer class="w-full mt-auto noir-rule">
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
</footer>
{{ body_end_html|safe }}
<script>
(function(){
if(window.__noirReveal)return;window.__noirReveal=1;
document.documentElement.classList.add('noir-js');
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var els=document.querySelectorAll('.noir-reveal');
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0,rootMargin:'0px 0px -8% 0px'});
function showAll(){els.forEach(function(el){el.classList.add('noir-in');});}
els.forEach(function(el){var r=el.getBoundingClientRect();var h=window.innerHeight||document.documentElement.clientHeight||0;if(!h||(r.top<h*0.95&&r.bottom>0)){el.classList.add('noir-in');}else{io.observe(el);}});
window.addEventListener('load',showAll);
setTimeout(showAll,450);
})();
</script>
</body>
</html>

View File

@ -1,26 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
{{ 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"><p class="noir-label" style="color:hsl(var(--muted-foreground));">No content blocks assigned to this page.</p></div>{% endif %}
</main>
<footer class="w-full mt-auto noir-rule">
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
</footer>
{{ body_end_html|safe }}
<script>
(function(){
if(window.__noirReveal)return;window.__noirReveal=1;
document.documentElement.classList.add('noir-js');
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var els=document.querySelectorAll('.noir-reveal');
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0.12});
els.forEach(function(el){io.observe(el);});
})();
</script>
</body>
</html>

View File

@ -1,30 +0,0 @@
<!doctype html>
<html lang="en" class="{% if theme_mode == "dark" %}dark{% endif %}">
{{ head_html|safe }}
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col noir-body">
{{ admin_banner_html|safe }}
<section class="w-full relative noir-grain">{{ slots.hero|safe }}</section>
<main class="flex-grow">
{% if slots.main %}<div class="max-w-3xl mx-auto w-full px-4 py-10 noir-reveal">{{ slots.main|safe }}</div>{% endif %}
</main>
<section class="w-full">{{ slots.cta|safe }}</section>
<footer class="w-full mt-auto noir-rule">
<div class="max-w-5xl mx-auto px-4">{{ slots.footer|safe }}</div>
</footer>
{{ body_end_html|safe }}
<script>
(function(){
if(window.__noirReveal)return;window.__noirReveal=1;
document.documentElement.classList.add('noir-js');
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var els=document.querySelectorAll('.noir-reveal');
if(reduce||!('IntersectionObserver' in window)){els.forEach(function(el){el.classList.add('noir-in');});return;}
var io=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){e.target.classList.add('noir-in');io.unobserve(e.target);}});},{threshold:0,rootMargin:'0px 0px -8% 0px'});
function showAll(){els.forEach(function(el){el.classList.add('noir-in');});}
els.forEach(function(el){var r=el.getBoundingClientRect();var h=window.innerHeight||document.documentElement.clientHeight||0;if(!h||(r.top<h*0.95&&r.bottom>0)){el.classList.add('noir-in');}else{io.observe(el);}});
window.addEventListener('load',showAll);
setTimeout(showAll,450);
})();
</script>
</body>
</html>

View File

@ -1,21 +0,0 @@
{# noir override — announcement-bar; preserves variant, message, linkText, linkUrl, dismissable, data-bn-annkey/localStorage dismiss <script> verbatim. #}
{% set anncls = "bg-primary/10 text-foreground" %}
{% if variant == "promo" %}{% set anncls = "bg-primary text-primary-foreground" %}{% elif variant == "alert" %}{% set anncls = "bg-destructive text-destructive-foreground" %}{% endif %}
<div data-block-override="noir:announcement-bar" data-bn-announcement data-bn-annkey="{{ variant|default:'info' }}:{{ message|slugify }}" class="bn-announcement w-full border-b border-border {{ anncls }} {{ class }}">
<div class="max-w-7xl mx-auto px-10 py-2 relative flex items-center justify-center gap-2 text-sm text-center">
<p class="noir-label">{{ message }}{% if linkText and linkUrl %} <a href="{{ linkUrl }}" class="underline 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 -translate-y-1/2 inline-flex items-center justify-center p-1 rounded-none hover:bg-black/10 transition-colors"><svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>{% endif %}
</div>
</div>
{% if dismissable %}<script>
(function(){
var bars=document.querySelectorAll('[data-bn-announcement]:not([data-bn-ann-wired])');
bars.forEach(function(bar){
bar.setAttribute('data-bn-ann-wired','1');
var key='bnann:'+bar.getAttribute('data-bn-annkey');
try{if(localStorage.getItem(key)==='1'){bar.style.display='none';return;}}catch(e){}
var btn=bar.querySelector('[data-bn-ann-dismiss]');
if(btn)btn.addEventListener('click',function(){bar.style.display='none';try{localStorage.setItem(key,'1');}catch(e){}});
});
})();
</script>{% endif %}

View File

@ -1,16 +0,0 @@
{# noir override — audio-embed; preserves url, media, artwork, title, artist, download, data-bn-audio, data-audio-url, data-audio-media, {% img %} call, and the facade IIFE verbatim. Only the player card restyled (square evidence panel). #}
<figure data-block-override="noir:audio-embed" class="bn-audio noir-file 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="noir-hairline h-16 w-16 shrink-0 overflow-hidden bg-muted grayscale">{% 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="noir-heading truncate text-sm" style="color:hsl(var(--foreground));">{{ title }}</p>{% endif %}{% if artist %}<p class="noir-body truncate text-sm" style="color:hsl(var(--muted-foreground));">{{ artist }}</p>{% endif %}<div class="mt-2 flex items-center gap-3" data-bn-audio-slot><button type="button" data-bn-audio-play class="noir-btn noir-btn-solid text-sm" aria-label="Play{% if title %}: {{ title }}{% endif %}"><svg width="16" height="16" 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="noir-link inline-flex items-center gap-1 text-sm"><svg width="16" height="16" aria-hidden="true"><use href="/icons/lucide.svg#download"></use></svg> Download</a>{% endif %}</div></div></div></figure><script>
(function(){
if(window.__bnAudioFacade)return;window.__bnAudioFacade=true;
document.addEventListener('click',function(e){
var btn=e.target.closest('[data-bn-audio-play]');if(!btn)return;
var fig=btn.closest('[data-bn-audio]');if(!fig)return;
e.preventDefault();
var src=fig.getAttribute('data-audio-media')||fig.getAttribute('data-audio-url')||'';
if(!src)return;
var slot=btn.closest('[data-bn-audio-slot]');
var a=document.createElement('audio');a.src=src;a.controls=true;a.autoplay=true;a.preload='none';a.style.cssText='width:100%;min-width:12rem;height:2.25rem;';
btn.remove();slot.insertBefore(a,slot.firstChild);
});
})();
</script>

View File

@ -1,33 +0,0 @@
{# noir override — auth-form; preserves context.isPublicLoggedIn/publicUsername, #auth-message, tab onclick toggles, #login-panel/#register-panel, /api/auth/login + /api/auth/register HTMX wiring, _captcha_widget, hx-disabled-elt, forgot-password link, default_tab. #}
{% if context.isPublicLoggedIn %}
<div data-block-override="noir:auth-form" class="auth-form-logged-in mx-auto max-w-md text-center py-8">
<p class="noir-body" style="color:hsl(var(--muted-foreground));">You are signed in as <strong style="color:hsl(var(--foreground));">{{ context.publicUsername }}</strong></p>
<a href="/account" class="noir-link mt-3 inline-block">My account</a>
</div>
{% else %}
<div data-block-override="noir:auth-form" class="auth-form noir-file mx-auto max-w-md bg-card p-8">
<div id="auth-message"></div>
<div class="mb-6 flex border-b border-border">
<button type="button" onclick="document.getElementById('login-panel').classList.remove('hidden');document.getElementById('register-panel').classList.add('hidden');this.setAttribute('data-active','1');this.nextElementSibling.removeAttribute('data-active');" data-auth-tab {% if default_tab != "register" %}data-active="1"{% endif %} class="noir-label flex-1 py-2 text-center border-b-2 border-transparent data-[active]:border-[hsl(var(--primary))] data-[active]:text-primary">Sign in</button>
<button type="button" onclick="document.getElementById('register-panel').classList.remove('hidden');document.getElementById('login-panel').classList.add('hidden');this.setAttribute('data-active','1');this.previousElementSibling.removeAttribute('data-active');" data-auth-tab {% if default_tab == "register" %}data-active="1"{% endif %} class="noir-label flex-1 py-2 text-center border-b-2 border-transparent data-[active]:border-[hsl(var(--primary))] data-[active]:text-primary">Register</button>
</div>
<div id="login-panel" class="{% if default_tab == "register" %}hidden{% endif %}">
<form hx-post="/api/auth/login" hx-target="#auth-message" hx-swap="innerHTML">
<div class="mb-3"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Email</label><input type="email" name="email" required class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
<div class="mb-4"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Password</label><input type="password" name="password" required class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
{% if _captcha_widget %}{{ _captcha_widget|safe }}{% endif %}
<button type="submit" class="noir-btn noir-btn-solid w-full" hx-disabled-elt="this">Sign in</button>
<p class="text-center mt-3 text-sm"><a href="/forgot-password" class="noir-link">Forgot password?</a></p>
</form>
</div>
<div id="register-panel" class="{% if default_tab != "register" %}hidden{% endif %}">
<form hx-post="/api/auth/register" hx-target="#auth-message" hx-swap="innerHTML">
<div class="mb-3"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Username</label><input type="text" name="username" required pattern="[a-zA-Z0-9_-]{3,30}" class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
<div class="mb-3"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Email</label><input type="email" name="email" required class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
<div class="mb-4"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Password</label><input type="password" name="password" required minlength="8" class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
{% if _captcha_widget %}{{ _captcha_widget|safe }}{% endif %}
<button type="submit" class="noir-btn noir-btn-solid w-full" hx-disabled-elt="this">Create account</button>
</form>
</div>
</div>
{% endif %}

View File

@ -1,31 +0,0 @@
{# noir override — auth-status; preserves context.isPublicLoggedIn/publicDisplayName/publicUsername, dropdown onclick, #auth-dropdown-menu, /api/auth/logout, outside-click <script> verbatim. #}
{% if context.isPublicLoggedIn %}
<div data-block-override="noir:auth-status" class="auth-status relative">
<button type="button" onclick="this.nextElementSibling.classList.toggle('hidden')" class="noir-label flex items-center gap-2 opacity-90 hover:opacity-100 px-3 py-1.5 transition-colors">
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full text-[0.7rem] font-bold" style="background-color:hsl(var(--primary));color:hsl(var(--primary-foreground));">{{ context.publicDisplayName|default:context.publicUsername|first|upper }}</span>
<span>{{ context.publicDisplayName|default:context.publicUsername }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div id="auth-dropdown-menu" class="hidden absolute right-0 top-full mt-1 w-48 noir-file bg-card py-1 z-50">
<a href="/account" class="block px-4 py-2 text-sm text-foreground hover:bg-accent">My profile</a>
<a href="/account/reviews" class="block px-4 py-2 text-sm text-foreground hover:bg-accent">My reviews</a>
<hr class="my-1 noir-rule"/>
<button hx-post="/api/auth/logout" hx-swap="none" class="block w-full text-left px-4 py-2 text-sm text-destructive hover:bg-accent">Sign out</button>
</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>
</div>
{% else %}
<div data-block-override="noir:auth-status" class="auth-status flex items-center gap-2">
<a href="/login" class="noir-label px-3 py-1.5 opacity-80 hover:opacity-100 hover:text-primary transition-colors">Sign in</a>
<a href="/login?tab=register" class="noir-btn noir-btn-solid text-xs">Register</a>
</div>
{% endif %}

View File

@ -1,21 +0,0 @@
{# noir override — author-bio-hero; preserves author.* fields, showAvatar/showPostCount/showSocial, {% img %}, all social links (website/twitter/linkedin/github). #}
{% if author %}
<section data-block-override="noir:author-bio-hero" class="border-b 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-6 h-24 w-24 overflow-hidden rounded-full bg-muted grayscale ring-2 ring-[hsl(var(--primary))]">{% img author.avatar_url alt=author.display_name class="h-full w-full object-cover" %}</div>{% endif %}
<h1 class="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ author.display_name }}</h1>
{% if author.job_title or author.company %}<p class="noir-label mt-2" style="color:hsl(var(--primary));">{{ author.job_title }}{% if author.job_title and author.company %} · {% endif %}{{ author.company }}</p>{% endif %}
{% if author.bio %}<p class="noir-body mt-5 max-w-2xl leading-relaxed text-muted-foreground">{{ author.bio }}</p>{% endif %}
<div class="noir-label mt-6 flex flex-wrap items-center justify-center gap-4 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-6 flex items-center justify-center gap-4">
{% if author.website_url %}<a href="{{ author.website_url }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="Website"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#globe"></use></svg></a>{% endif %}
{% if author.twitter_handle %}<a href="https://twitter.com/{{ author.twitter_handle|cut:"@" }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="Twitter"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#x"></use></svg></a>{% endif %}
{% if author.linkedin_url %}<a href="{{ author.linkedin_url }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="LinkedIn"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#linkedin"></use></svg></a>{% endif %}
{% if author.github_url %}<a href="{{ author.github_url }}" target="_blank" rel="noopener noreferrer" class="text-muted-foreground transition-colors hover:text-primary" title="GitHub"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/simple-icons.svg#github"></use></svg></a>{% endif %}
</div>{% endif %}
</div>
</section>
{% endif %}

View File

@ -1,14 +0,0 @@
{# noir override — author-bio; preserves post.author_name/author_slug/author_avatar/author_bio, heading/showAvatar, {% img %}. #}
{% if post.author_name %}
<aside data-block-override="noir:author-bio" class="noir-file bg-card p-6 text-card-foreground{% if class %} {{ class }}{% endif %}">
{% if heading %}<p class="noir-label mb-4" style="color:hsl(var(--primary));">{{ 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-muted grayscale ring-1 ring-[hsl(var(--primary)/0.5)]">{% img post.author_avatar alt=post.author_name class="h-full w-full object-cover" %}</div>{% endif %}
<div class="min-w-0">
<h3 class="noir-heading text-base" style="color:hsl(var(--card-foreground));">{% if post.author_slug %}<a href="/author/{{ post.author_slug }}" class="transition-colors hover:text-primary">{{ post.author_name }}</a>{% else %}{{ post.author_name }}{% endif %}</h3>
<span class="noir-rule mt-2 block w-10"></span>
{% if post.author_bio %}<p class="noir-body mt-2 text-sm text-muted-foreground">{{ post.author_bio }}</p>{% endif %}
</div>
</div>
</aside>
{% endif %}

View File

@ -1,24 +0,0 @@
{# noir override — blog-hero; preserves title, subtitle, posts provider loop, post.featured_image_url/.url/.title/.excerpt/.author_name/.published_at, showExcerpt, forloop.First. {% img %} kept verbatim. #}
<section data-block-override="noir:blog-hero" 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="noir-heading text-4xl md:text-5xl" style="color:hsl(var(--foreground));">{{ title }}</h1>{% endif %}
{% if subtitle %}<p class="noir-body mt-3 text-lg" style="color:hsl(var(--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="mb-3 block overflow-hidden bg-muted grayscale{% 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 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 %}">
<span class="noir-rule mb-4 block w-12"></span>
<h2 class="noir-heading {% if forloop.First %}text-2xl{% else %}text-lg{% endif %} leading-snug" style="color:hsl(var(--foreground));"><a href="{{ post.url }}" class="transition-colors hover:text-primary">{{ post.title }}</a></h2>
{% if showExcerpt and post.excerpt %}<p class="noir-body mt-2 text-sm" style="color:hsl(var(--muted-foreground));">{{ post.excerpt }}</p>{% endif %}
<div class="mt-3 flex flex-wrap items-center gap-x-3 noir-label">{% if post.author_name %}<span style="color:hsl(var(--primary));">{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span style="color:hsl(var(--muted-foreground));">{{ post.published_at|date:"M j, Y" }}</span>{% endif %}</div>
</div>
</article>
{% endfor %}
</div>
{% endif %}
</div>
</section>

View File

@ -1,13 +0,0 @@
{# noir override — breadcrumbs; preserves breadcrumbs provider loop, showHome/homeLabel/separator/template/showCurrent, item.is_current/url/label. #}
{% set arrows = template == "arrows" %}
<nav aria-label="Breadcrumb" data-block-override="noir:breadcrumbs" class="py-3{% if class %} {{ class }}{% endif %}">
<ol class="flex flex-wrap items-center gap-1">
{% if showHome %}<li class="flex items-center"><a href="/" class="noir-label text-muted-foreground transition-colors hover:text-primary">{{ 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-3.5 w-3.5" style="color:hsl(var(--primary));" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>{% else %}<span class="mx-2" style="color:hsl(var(--primary));">{{ separator|default:"/" }}</span>{% endif %}{% endif %}
{% if item.is_current %}{% if showCurrent %}<span class="noir-label text-foreground" aria-current="page">{{ item.label }}</span>{% endif %}{% else %}<a href="{{ item.url }}" class="noir-label text-muted-foreground transition-colors hover:text-primary">{{ item.label }}</a>{% endif %}
</li>
{% endfor %}
</ol>
</nav>

View File

@ -1,12 +0,0 @@
{# button override — square case-file button. Fields (new builtin contract, OVERRIDE-CONTRACT §4): link, style, label, size, icon, iconPosition, newTab, align. The builtin {% button %} tag is replaced by a styled <a> so the noir square-outline look applies. #}
{% with align=align|default:"left" %}
<div data-block-override="noir:button" class="bn-button flex {% if align == "center" %}justify-center{% elif align == "right" %}justify-end{% else %}justify-start{% endif %}{% if class %} {{ class }}{% endif %}">
{% if style == "ghost" or style == "link" %}
<a href="{{ link|default:'#' }}"{% if newTab %} target="_blank" rel="noopener"{% endif %} class="noir-link inline-flex items-center gap-2 text-sm">{% if icon and iconPosition != "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}{{ label|default:"Learn more" }}{% if icon and iconPosition == "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</a>
{% elif style == "secondary" or style == "outline" %}
<a href="{{ link|default:'#' }}"{% if newTab %} target="_blank" rel="noopener"{% endif %} class="noir-btn">{% if icon and iconPosition != "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}{{ label|default:"Learn more" }}{% if icon and iconPosition == "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</a>
{% else %}
<a href="{{ link|default:'#' }}"{% if newTab %} target="_blank" rel="noopener"{% endif %} class="noir-btn noir-btn-solid">{% if icon and iconPosition != "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}{{ label|default:"Learn more" }}{% if icon and iconPosition == "right" %}<svg width="16" height="16" aria-hidden="true"><use href="/icons/{{ icon|split:":"|first }}.svg#{{ icon|split:":"|last }}"></use></svg>{% endif %}</a>
{% endif %}
</div>
{% endwith %}

View File

@ -1,4 +0,0 @@
{# card override — manila case-file panel. Fields mirror builtin: layout, link, media, title, text, linkLabel. Media via {% img %}. #}
{% with layout=layout|default:"vertical" %}
<article data-block-override="noir:card" class="bn-card noir-file overflow-hidden transition-colors {% 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-muted grayscale {% 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="noir-heading text-lg" style="color:hsl(var(--foreground));">{{ title }}</h3>{% endif %}{% if text %}<div class="noir-body text-sm leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ text|safe }}</div>{% endif %}{% if linkLabel and link %}<span class="mt-2 inline-flex items-center gap-1 noir-label" style="color:hsl(var(--primary));">{{ linkLabel }}<svg width="16" height="16" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></span>{% endif %}</div>{% if link %}</a>{% endif %}</article>
{% endwith %}

View File

@ -1,18 +0,0 @@
{# noir override — category-list; preserves categories provider loop, style/pills, heading, showCount, cat.url/.name/.post_count. #}
{% set pills = style == "pills" %}
<section data-block-override="noir:category-list" class="bg-background text-foreground{% if class %} {{ class }}{% endif %}">
{% if heading %}<h2 class="noir-label mb-5" style="color:hsl(var(--primary));">{{ heading }}</h2>{% endif %}
{% if pills %}
<div class="flex flex-wrap gap-2">
{% for cat in categories %}
<a href="{{ cat.url }}" class="noir-label inline-flex items-center gap-1.5 rounded-none border border-border bg-card px-4 py-1.5 text-card-foreground transition-colors hover:border-primary hover:text-primary">{{ cat.name }}{% if showCount %}<span class="noir-mono text-xs" style="color:hsl(var(--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="noir-rule flex items-center justify-between px-3 py-2.5 text-sm transition-colors hover:text-primary" style="color:hsl(var(--muted-foreground));"><span class="noir-label">{{ cat.name }}</span>{% if showCount %}<span class="noir-mono rounded-none border border-border px-2 py-0.5 text-xs" style="color:hsl(var(--muted-foreground));">{{ cat.post_count }}</span>{% endif %}</a></li>
{% endfor %}
</ul>
{% endif %}
</section>

View File

@ -1,18 +0,0 @@
{# noir override — contact-card; preserves heading, description, columns, channels[].icon/.label/.value/.link. Icon <svg><use> split markup kept verbatim. #}
<section data-block-override="noir:contact-card" 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="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if description %}<p class="noir-body mt-3 max-w-2xl text-lg" style="color:hsl(var(--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="noir-file flex items-start gap-4 p-5">
{% if channel.icon %}<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center border border-border bg-primary/10 text-primary"><svg width="20" height="20" class="h-5 w-5" aria-hidden="true"><use href="/icons/{{ channel.icon|split:":"|first }}.svg#{{ channel.icon|split:":"|last }}"></use></svg></span>{% endif %}
<div class="min-w-0">
{% if channel.label %}<div class="noir-label" style="color:hsl(var(--muted-foreground));">{{ channel.label }}</div>{% endif %}
{% if channel.value %}<div class="noir-heading mt-0.5 break-words text-base" style="color:hsl(var(--card-foreground));">{% if channel.link %}<a href="{{ channel.link }}" class="transition-colors hover:text-primary">{{ channel.value }}</a>{% else %}{{ channel.value }}{% endif %}</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</section>

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