initial: theme plugin noir

Bootstrapped during the 2026-06-06 BlockNinja consolidation. Was previously
an unversioned directory inside ~/src/blockninja-themes/noir.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-06-06 14:11:40 +08:00
commit 1bebbea5ad
46 changed files with 4725 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.so
*.test
tmp/
.idea/
.vscode/

143
BUILD_REPORT.md Normal file
View File

@ -0,0 +1,143 @@
# Noir — Build Report
Implementation pass: wave-1 scaffold of the `noir` theme plugin.
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)
## What landed
### Metadata & build glue
- `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"`.
- `go.mod` — pins `block/core v0.11.1` and `templ v0.3.1020`, mirrors gotham's indirect set, no `replace` directives.
- `Makefile` — local-only targets (`all`, `templ`, `clean`). No `rebuild` target. Default builds `noir.so` via `CGO_ENABLED=1 go build -buildmode=plugin`.
- `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.
### 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.
### Blocks (6 theme-specific)
- `noir:lightbox_gallery` — grid + vanilla keyboard-aware lightbox overlay (Esc + Enter/Space, click-outside dismiss, ARIA-modal).
- `noir:contact_sheet` — sprocket-framed numbered grid; `data-frame-number` attribute on each frame for UAT §13.9.
- `noir:case_study` — sticky meta rail (client / year / credits) + image stack.
- `noir:caption_strip` — 10px mono full-width strip.
- `noir:image_pair` — 50/50 diptych with shared caption.
- `noir:footer` — dissolved-rail footer with optional social links.
All registered with `Source: "noir"` and the appropriate `blocks.Category*` constant.
### Schemas (6, draft-07)
- Property names exactly match the Go `content["…"]` reads in each block.
- Every `x-editor` value comes from the allowed set:
`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.
### Template overrides (5)
- `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.
### 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.
### Master pages (2)
- `noir:default-master``default`, `article` templates. Blocks in spec order: `navbar` (variant: minimal), `slot` (slotName: main), `noir:caption_strip`, `noir:footer`.
- `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.)
### Presets (3, all 19 tokens)
- `pure-noir` — mode `dark`, single `darkColors` block. Pure black ground, white primary, silver accent.
- `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).

31
Makefile Normal file
View File

@ -0,0 +1,31 @@
# Noir — build helpers (.so plugin workflow)
#
# Local single-shot build:
# make # produces noir.so via CGO go build -buildmode=plugin
# make templ # regenerate *_templ.go files
# make clean # remove build artefacts
.PHONY: all clean templ help
PLUGIN_NAME := noir
# Default target: build the .so locally.
all: $(PLUGIN_NAME).so
# Local plugin build (no container). Compiles to .so.
$(PLUGIN_NAME).so: $(wildcard *.go) plugin.mod go.mod
CGO_ENABLED=1 go build -buildmode=plugin -ldflags="-s -w" -o $(PLUGIN_NAME).so .
# Regenerate templ Go files locally.
templ:
templ generate
# Remove build artefacts.
clean:
rm -f $(PLUGIN_NAME).so
help:
@echo "Targets:"
@echo " all Build $(PLUGIN_NAME).so (default)"
@echo " templ Regenerate *_templ.go files"
@echo " clean Remove build artefacts"

46
RECOMMENDED_FONTS.md Normal file
View File

@ -0,0 +1,46 @@
# Noir — Recommended Fonts
Noir ships with `fonts.json = []` per the wave-1 fonts policy
(`themes/docs/FONTS.md`). No woff2s are bundled in this implementation
pass. The site admin assigns fonts via the typography settings panel.
The fallback stacks in `assets/style.css` already approximate the
intended Noir aesthetic (Tenor Sans display, Inter body, JetBrains
Mono captions) using widely available system faces. The picks below
match the spec exactly and are recommended once an admin opens the
font picker.
## Heading
- **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.
## Body
- **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.
## Mono
- **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.
## Notes for wave-2
Once licensed woff2s are commissioned (e.g. a darkroom-grade display
face), drop them into `assets/fonts/web/` and declare them in
`fonts.json`. The CMS will emit `@font-face` blocks automatically; no
`CSSManifest.InputCSSAppend` changes required. Add the licence to a
new `LICENSES.md` at the theme root and remove this `RECOMMENDED_FONTS.md`.

0
assets/.gitkeep Normal file
View File

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; }
}

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),
}
}

1
fonts.json Normal file
View File

@ -0,0 +1 @@
[]

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.11.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
)

42
go.sum Normal file
View File

@ -0,0 +1,42 @@
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.11.1 h1:5b3Ps9CLor2FGyxw/Qovt27AGZKR5Xi1JZGi/TfliTA=
git.dev.alexdunmow.com/block/core v0.11.1/go.mod h1:ZwzEOxRDLDfrhQGqo6hLw01/C1z/aS4Dm9ljQMl0Bg4=
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

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

12
plugin.mod Normal file
View File

@ -0,0 +1,12 @@
[plugin]
name = "noir"
display_name = "Noir"
scope = "@themes"
version = "0.1.0"
description = "Silver-on-black photography theme with full-bleed imagery, micro mono labels, lightbox galleries and contact-sheet layouts."
kind = "theme"
categories = ["templates", "media"]
tags = ["noir", "monochrome", "photography", "portfolio", "gallery", "filmmaker", "fashion", "darkroom"]
[compatibility]
block_core = ">=0.11.0 <0.12.0"

110
presets.json Normal file
View File

@ -0,0 +1,110 @@
[
{
"id": "pure-noir",
"name": "Pure Noir",
"description": "Pure black ground, silver mids, one white reserved for captions and highlight edges.",
"theme": {
"mode": "dark",
"darkColors": {
"background": "0 0% 0%",
"foreground": "0 0% 96%",
"card": "0 0% 4%",
"cardForeground": "0 0% 96%",
"popover": "0 0% 6%",
"popoverForeground": "0 0% 96%",
"primary": "0 0% 100%",
"primaryForeground": "0 0% 0%",
"secondary": "0 0% 10%",
"secondaryForeground": "0 0% 80%",
"muted": "0 0% 8%",
"mutedForeground": "0 0% 55%",
"accent": "0 0% 75%",
"accentForeground": "0 0% 0%",
"destructive": "0 70% 45%",
"destructiveForeground": "0 0% 98%",
"border": "0 0% 14%",
"input": "0 0% 12%",
"ring": "0 0% 70%"
}
}
},
{
"id": "silver-print",
"name": "Silver Print",
"description": "Inverted gallery-wall preset: bone white, charcoal type.",
"theme": {
"mode": "light",
"lightColors": {
"background": "0 0% 98%",
"foreground": "0 0% 8%",
"card": "0 0% 100%",
"cardForeground": "0 0% 8%",
"popover": "0 0% 100%",
"popoverForeground": "0 0% 8%",
"primary": "0 0% 10%",
"primaryForeground": "0 0% 98%",
"secondary": "0 0% 94%",
"secondaryForeground": "0 0% 12%",
"muted": "0 0% 96%",
"mutedForeground": "0 0% 42%",
"accent": "0 0% 65%",
"accentForeground": "0 0% 100%",
"destructive": "0 75% 50%",
"destructiveForeground": "0 0% 100%",
"border": "0 0% 88%",
"input": "0 0% 90%",
"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": {
"background": "30 6% 4%",
"foreground": "30 6% 92%",
"card": "30 6% 7%",
"cardForeground": "30 6% 92%",
"popover": "30 6% 9%",
"popoverForeground": "30 6% 92%",
"primary": "30 6% 96%",
"primaryForeground": "30 8% 6%",
"secondary": "30 6% 12%",
"secondaryForeground": "30 6% 88%",
"muted": "30 6% 10%",
"mutedForeground": "30 4% 58%",
"accent": "30 8% 70%",
"accentForeground": "30 8% 6%",
"destructive": "0 70% 45%",
"destructiveForeground": "0 0% 98%",
"border": "30 6% 16%",
"input": "30 6% 14%",
"ring": "30 8% 72%"
}
}
}
]

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
}
}
}

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