Compare commits

..

4 Commits

Author SHA1 Message Date
Alex Dunmow
0b3013ce81 fix(theme): drive responsive layout from theme-owned CSS
Host Tailwind JIT does not scan theme templates, so responsive grid/hero
utilities (sm:/md:/lg:grid-cols-*, min-h-screen, arbitrary values) are
dropped and multi-column layouts collapse to one column. Ship the
load-bearing responsive layout as compiled theme CSS and point the
override templates at it. Layout only — colours stay on semantic tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:40:19 +08:00
Alex Dunmow
895d76f612 bump to 0.2.1 2026-07-05 16:44:49 +08:00
Alex Dunmow
06436be10f docs: publishing README 2026-07-05 13:43:27 +08:00
Alex Dunmow
1451f06d9f feat(noir): WO-TF-022 Wave A — detective/true-crime dossier rebuild
Re-persona noir from photography to the locked detective/true-crime
direction: case-file panels, typewriter type, redaction bars, evidence
board, film grain. High-key gallery (light) / darkroom black + amber
safelight (dark).

- 49 mandatory builtin overrides (chrome, marketing, primitives,
  commerce, blog family, auth, page-suggestions), re-skinned from the
  frozen builtin defaults with every field/provider/tag/JS preserved;
  legacy button/card field reads reconciled to the new contract.
- 7 page templates (default, full-width, landing, article, blog-index,
  contact, auth) + themed email wrapper.
- 4 persona blocks (case_file, evidence_board, redacted_quote,
  case_dossier); old photography blocks deleted (now overrides).
- 5 dual-mode presets (19 tokens, light+dark tuned).
- Bundled OFL fonts (Source Serif 4, IBM Plex Mono) + licenses;
  RECOMMENDED_FONTS/ICONS updated; required_icon_packs=lucide.
- Master pages + seed demo ("The Cold File": home/about/blog x3/contact
  with case_tips data table + row_inserted email-admins workflow/login).
- noir CSS vocabulary via manifest css.input_css_append; MID motion
  (scroll-reveal + gallery lightbox, reduced-motion honored, no canvas).

Verified: make build exit 0; check-safety 32 checks 19 ok 13 skip -> OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:48:44 +08:00
97 changed files with 2663 additions and 1275 deletions

View File

@ -1,143 +1,65 @@
# Noir — Build Report
Implementation pass: wave-1 scaffold of the `noir` theme plugin.
Wave A pass (WO-TF-022): re-persona from a photography/portfolio theme to the
LOCKED **detective / true-crime dossier** direction. Codeless `.bnp`.
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)
Plugin slug: `noir` · kind: `theme` · version: `0.2.0`
## What landed
## Identity
### 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.
Case-file panels, typewriter type, redaction bars, evidence board and film
grain. High-key gallery by day (ink type, evidence-red accent); darkroom black by
night (bone type, amber safelight accent). Motion tier MID: JS scroll-reveal
(reduced-motion honored, static no-JS fallback) plus the builtin gallery lightbox
showpiece (no canvas).
### 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.
## What shipped
### 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.
- **49 mandatory template overrides** in `templates/overrides/noir/` + a
`template_overrides` entry per key in `manifest.yaml`. Each re-skins the frozen
builtin default, preserving every content field, provider var, custom tag and
`<script>`/`<style>` block verbatim; only styling changes. Legacy button/card
field reads reconciled to the new builtin contract (link/style/label, text/media).
- **7 page templates** (`templates/noir/`): default, full-width, landing, article,
blog-index, contact, auth.
- **4 persona blocks** (`blocks/`): `case_file`, `evidence_board`,
`redacted_quote`, `case_dossier`. The old photography blocks (lightbox_gallery,
contact_sheet, caption_strip, image_pair, footer, case_study) were deleted — the
builtins they duplicated are now overrides.
- **5 presets** (`presets.json`), all 19 tokens, light + dark intentionally tuned
(`mode: both`): Cold Case (default), Evidence Red, Safelight, Interrogation,
Redaction.
- **Bundled fonts** (`fonts.json`, `assets/fonts/`, OFL): Source Serif 4 (body) and
IBM Plex Mono (typewriter heading/mono). Fallback stacks name Special Elite /
Courier Prime first for admins. Licenses in `assets/fonts/LICENSES.md`.
- **Themed email wrapper** (`templates/email/noir.ninjatpl`): table-based, inline
styles, dossier masthead.
- **Master pages** (`master_pages.json`) + **seed demo** (`seed/seed.json`,
`seed/workflows.json`): "The Cold File" true-crime site — home, about, blog (3
case write-ups), contact (form → `case_tips` data table → `row_inserted`
email-admins workflow), login.
- **noir CSS vocabulary** injected via `manifest.yaml` `css.input_css_append`:
`.noir-heading/-body/-mono/-label`, `.noir-file`, `.noir-btn/-btn-solid/-link`,
`.noir-rule/-hairline`, `.noir-stamp`, `.noir-redact`, `.noir-pin`, `.noir-tape`,
`.noir-grain`, `.noir-spotlight`, `.noir-reveal`. Kebab-case tokens only; all
font-family via `var(--font-heading|body|mono, <fallback>)`.
All registered with `Source: "noir"` and the appropriate `blocks.Category*` constant.
## Verification
### 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.
- `cd themes/noir && make build``Built noir@0.2.0 (codeless) → noir-0.2.0.bnp`,
exit 0.
- `cd check-safety && go run . themes/noir``32 checks: 19 ok 13 skip -> OK`,
exit 0.
### 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.
Visual quality is UNVERIFIED until Wave B screenshots. `preview.png` is a stale
photography-era image and will be regenerated in Wave B.
### 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.
## Known platform limits (per OVERRIDE-CONTRACT §11/§12, not theme bugs)
### 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).
- `auth-form`, `auth-status`, `password-reset-form`, `page-suggestions` are
compiled builtins whose overrides do not yet dispatch — the stock builtin HTML
renders until the cms fix lands. Authored faithfully regardless.
- Provider-backed overrides (navbar/footer menus, featured/related/popular posts,
category-list, breadcrumbs, blog-hero, sidebar-nav, author-bio-hero) render
themed chrome but empty live lists under the codeless loader until the cms fix
lands. Authored faithfully to the builtin provider var names.

24
README.md Normal file
View File

@ -0,0 +1,24 @@
# Noir
A detective's dossier laid out on the desk. Case-file cards, typewriter type, and black redaction bars set the tone, with a faint film grain over the whole surface. By day it reads like a high-key gallery wall. At night it drops to darkroom black. The mood is investigative and a little tense, and the type stays legible throughout.
## Good for
We built Noir for storytelling with an edge. Treat these as starting points, not limits. If the mood fits your brand, use it:
- True-crime podcasts and newsletters
- Investigative journalism and long-form reporting
- Mystery authors and book launches
- Escape rooms and immersive events
## What you get
- Five color presets, each with a tuned light mode and a tuned dark mode: Cold Case, Evidence Red, Safelight, Interrogation, and Redaction.
- Source Serif 4 for reading and IBM Plex Mono for labels and stamps, both bundled. Fonts stay admin-controlled, so you can swap them from the typography settings.
- Four persona blocks: a case dossier, a stamped case-file card, an evidence board with pinned notes, and a redacted quote with blacked-out lines.
- A demo site that installs when you activate the theme: a home page, an about page, a short blog, and a contact page whose form emails your admins.
- Every core block restyled to match, plus a themed login screen and a themed 404 page.
## Install
Install from the BlockNinja registry, then pick a preset and assign your fonts in the admin. Activate the demo content if you want a fully built starting point.

View File

@ -1,46 +1,48 @@
# 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.
Noir is a detective / true-crime dossier. The typographic voice is a typewriter
for the file itself and a quiet reading serif for the case write-up. The theme
**bundles** two OFL faces so it renders on its intended stack with no admin setup
(`fonts.json`, woff2 in `assets/fonts/`, licenses in
`assets/fonts/LICENSES.md`):
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.
- **IBM Plex Mono** (400, 500) — the shipped typewriter face. Serves both the
heading (typed case-file headers) and mono (evidence labels, case numbers,
stamps) slots by default.
- **Source Serif 4** (400, 400 italic) — the shipped reading body for case prose.
## Heading
Every family flows through `var(--font-heading|body|mono, <fallback>)`; nothing
is hardcoded, so an admin can override any slot from the picker. The fallback
stacks name the *ideal* typewriter faces first, so assigning them lifts the look
further:
- **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.
## Heading — typed case-file display
## Body
- **Ideal**: `google:Special Elite` (Apache 2.0) — worn typewriter, the classic
true-crime header face.
- **Shipped default**: IBM Plex Mono 500 (bundled, OFL).
- **How**: Site Settings → Typography → Heading → Google Fonts → search
"Special Elite" → Add → assign to the Heading slot.
- **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.
## Body — case write-up
## Mono
- **Ideal & shipped**: Source Serif 4 (bundled, OFL) — a calm high-legibility
serif for long-form reading.
- **Alternative**: `google:Source Serif 4` for the full weight range, or
`google:Lora`.
- **How**: it is already assigned via the bundled manifest; to change, Site
Settings → Typography → Body → pick a serif → assign to the Body slot.
- **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.
## Mono — evidence labels, case numbers, stamps
## Notes for wave-2
- **Ideal**: `google:Courier Prime` (OFL) — clean courier, the report-typewriter
face.
- **Shipped default**: IBM Plex Mono 400 (bundled, OFL).
- **How**: Site Settings → Typography → Mono → Google Fonts → search
"Courier Prime" → Add → assign to the Mono slot.
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`.
## Licensing
All bundled woff2 files are SIL OFL 1.1 latin subsets. See
`assets/fonts/LICENSES.md`. Special Elite is Apache 2.0 and is *not* bundled — it
is only named as an admin-assignable first choice in the fallback stack.

26
RECOMMENDED_ICONS.md Normal file
View File

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

View File

21
assets/fonts/LICENSES.md Normal file
View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -1,42 +1,34 @@
# Noir theme block definitions (codeless .bnp, WO-WZ-021).
# Noir theme block definitions (codeless .bnp, WO-TF-022).
# Detective / true-crime persona furniture ONLY. Everything that duplicates a
# builtin (galleries, footers, dividers, captions, image pairs) is shipped as a
# template OVERRIDE of the builtin key instead (see templates/overrides/noir/).
#
# Keys are fully qualified (noir:<key>) because the codeless loader registers
# definitions verbatim — there is no PluginBlockRegistry prefixing in the
# codeless path (unlike the wasm/templ path). The qualified keys match the
# hardcoded data-block attributes and the master_pages.json references.
# codeless path. The qualified keys match the data-block attributes, the
# master_pages.json references and the seed.
blocks:
- key: noir:lightbox_gallery
title: Lightbox Gallery
description: Grid of photographs that expand into a full-viewport lightbox overlay on click.
- key: noir:case_file
title: Case File
description: A dossier card — case number, status stamp, subject and a set of field/value evidence rows.
category: content
schema: lightbox_gallery.schema.json
template: lightbox_gallery.ninjatpl
- key: noir:contact_sheet
title: Contact Sheet
description: Numbered photograph frames with sprocket motif evoking a darkroom contact sheet.
schema: case_file.schema.json
template: case_file.ninjatpl
- key: noir:evidence_board
title: Evidence Board
description: A pinned board of evidence — photographs and notes with push-pins and case tags.
category: content
schema: contact_sheet.schema.json
template: contact_sheet.ninjatpl
- key: noir:case_study
title: Project Case Study
description: Long-form project spread with sticky meta rail and image-led prose.
schema: evidence_board.schema.json
template: evidence_board.ninjatpl
- key: noir:redacted_quote
title: Redacted Quote
description: A transcript pull quote with a redaction bar over the withheld words that lifts on hover or focus.
category: content
schema: redacted_quote.schema.json
template: redacted_quote.ninjatpl
- key: noir:case_dossier
title: Case Dossier
description: Long-form case spread with a sticky evidence rail and typed prose.
category: layout
schema: case_study.schema.json
template: case_study.ninjatpl
- key: noir:caption_strip
title: Caption Strip
description: Full-width 10px mono caption strip with a label on the left and supporting text on the right.
category: navigation
schema: caption_strip.schema.json
template: caption_strip.ninjatpl
- key: noir:image_pair
title: Image Pair
description: 50/50 photograph diptych with a shared caption beneath both images.
category: content
schema: image_pair.schema.json
template: image_pair.ninjatpl
- key: noir:footer
title: Noir Footer
description: Dissolved bottom rail with optional colophon and social links.
category: layout
schema: footer.schema.json
template: footer.ninjatpl
schema: case_dossier.schema.json
template: case_dossier.ninjatpl

View File

@ -1 +0,0 @@
<div data-block="noir:caption_strip" class="noir-caption-strip"><span class="tracked-mono">{{ label }}</span><span class="tracked-mono">{{ right }}</span></div>

View File

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

View File

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

24
blocks/case_file.ninjatpl Normal file
View File

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

View File

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

View File

@ -1 +0,0 @@
<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 client %}<div><div style="color: hsl(var(--mutedForeground));">Client</div><div class="mt-1" style="color: hsl(var(--foreground));">{{ client }}</div></div>{% endif %}{% if year|integer > 0 %}<div><div style="color: hsl(var(--mutedForeground));">Year</div><div class="mt-1" style="color: hsl(var(--foreground));">{{ year|integer }}</div></div>{% endif %}{% if credits %}<div><div style="color: hsl(var(--mutedForeground));">Credits</div><ul class="mt-1 space-y-1" style="color: hsl(var(--foreground));">{% for credit in credits %}<li>{{ credit }}</li>{% endfor %}</ul></div>{% endif %}</aside><div class="min-w-0">{% if title %}<h1 class="noir-display mb-10" style="font-size: clamp(2.5rem, 5vw, 4rem); line-height: 1.05; color: hsl(var(--foreground));">{{ title }}</h1>{% endif %}{% if images %}<div class="space-y-8">{% for img in images %}{% if img %}<figure class="noir-figure"><img src="{{ img }}" alt="{{ title }}" loading="lazy" class="w-full h-auto block"></figure>{% endif %}{% endfor %}</div>{% else %}<div class="hairline p-12 text-center tracked-mono" style="color: hsl(var(--mutedForeground));">Add photographs to the case study spread.</div>{% endif %}</div></div></section>

View File

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

@ -1 +0,0 @@
<section data-block="noir:contact_sheet" class="py-12"><div class="max-w-6xl mx-auto px-6">{% if not items %}<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 item in items %}<figure class="noir-sprocket noir-figure" data-frame-number="{% if item.frame %}{{ item.frame }}{% else %}{% if forloop.Counter < 10 %}0{% endif %}{{ forloop.Counter }}{% endif %}"><span class="noir-frame-number tracked-mono" style="color: hsl(var(--mutedForeground));">{% if item.frame %}{{ item.frame }}{% else %}{% if forloop.Counter < 10 %}0{% endif %}{{ forloop.Counter }}{% endif %}</span>{% if item.image %}<img src="{{ item.image }}" alt="{{ item.label }}" loading="lazy" class="w-full h-auto block mt-2">{% else %}<div class="aspect-square hairline" style="background-color: hsl(var(--muted));"></div>{% endif %}{% if item.label %}<figcaption class="tracked-mono mt-2" style="color: hsl(var(--mutedForeground));">{{ item.label }}</figcaption>{% endif %}</figure>{% endfor %}</div>{% endif %}</div></section>

View File

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

View File

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

View File

@ -1 +0,0 @@
<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 showColophon != "false" and showColophon != false %}<p class="tracked-mono" style="color: hsl(var(--mutedForeground));">Designed and printed in the darkroom.</p>{% else %}<span></span>{% endif %}{% if social %}<ul class="flex items-center gap-4 list-none p-0 m-0">{% for link in social %}<li><a href="{{ link.url }}" rel="noopener noreferrer" target="_blank" class="tracked-mono" style="color: hsl(var(--mutedForeground)); text-decoration: none;">{{ link.text }}</a></li>{% endfor %}</ul>{% endif %}</div></div>

View File

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

@ -1 +0,0 @@
<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 left %}<figure class="noir-figure"><img src="{{ left }}" alt="{{ caption }}" loading="lazy" class="w-full h-auto block"></figure>{% else %}<div class="hairline aspect-[4/5]" style="background-color: hsl(var(--muted));"></div>{% endif %}{% if right %}<figure class="noir-figure"><img src="{{ right }}" alt="{{ caption }}" loading="lazy" class="w-full h-auto block"></figure>{% else %}<div class="hairline aspect-[4/5]" style="background-color: hsl(var(--muted));"></div>{% endif %}</div>{% if caption %}<p class="tracked-mono mt-3 text-center" style="color: hsl(var(--mutedForeground));">{{ caption }}</p>{% endif %}</div></section>

View File

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

@ -1 +0,0 @@
<section data-block="noir:lightbox_gallery" class="py-12"><div class="max-w-6xl mx-auto px-6">{% if not items %}<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 {% if columns|integer == 2 %}grid-cols-1 md:grid-cols-2{% elif columns|integer == 4 %}grid-cols-1 md:grid-cols-2 lg:grid-cols-4{% else %}grid-cols-1 md:grid-cols-2 lg:grid-cols-3{% endif %}">{% for item in 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="{% if item.caption %}Open photograph: {{ item.caption }}{% else %}Open photograph {{ forloop.Counter }}{% endif %}"><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>{% endif %}</figure>{% endfor %}</div>{% endif %}</div></section>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
# Noir theme — codeless .bnp manifest (WO-WZ-021 Phase B1).
# Synthesized into manifest.pb by `ninja plugin build` (no plugin.wasm).
# Noir theme — codeless .bnp manifest (WO-TF-022, Wave A).
# Detective / true-crime dossier. Synthesized into manifest.pb by
# `ninja plugin build` (no plugin.wasm).
theme_presets: presets.json
bundled_fonts: fonts.json
master_pages: master_pages.json
@ -7,36 +8,102 @@ master_pages: master_pages.json
system_templates:
- key: noir
title: Noir
description: "Silver-on-black photography theme with full-bleed imagery, micro mono labels, lightbox galleries and contact-sheet layouts."
description: "Detective and true-crime dossier theme: case-file cards, typewriter type, redaction bars, evidence board and film grain. High-key gallery by day, darkroom black by night."
page_templates:
- system: noir
key: default
title: Default
description: "Centred gallery page with a thin masthead and dissolved footer."
description: "Case-file page with a typed masthead and a dissolved footer rule."
slots: [header, main, footer]
- system: noir
key: full-width
title: Full Width
description: "Edge-to-edge dossier layout for evidence boards and lightbox grids."
slots: [header, main, footer]
- system: noir
key: landing
title: Landing
description: "Edge-to-edge hero image, micro caption strip, minimal CTA."
description: "Spotlit case hero over film grain, a caption rule and a closing CTA."
slots: [hero, main, cta, footer]
- system: noir
key: article
title: Article / Project
description: "Long-form case study with sticky caption rail and image-led prose."
title: Case File
description: "Long-form case write-up with a sticky evidence rail and typed prose."
slots: [header, main, aside, footer]
- system: noir
key: full-width
title: Full Width
description: "Contact-sheet or lightbox grid, no horizontal padding."
slots: [header, main, footer]
key: blog-index
title: Case Index
description: "Blog index framed as a case log, with featured and post-grid slots."
slots: [header, featured, main, footer]
- system: noir
key: contact
title: Tip Line
description: "Contact layout with an optional aside for hours and location."
slots: [header, main, aside, footer]
- system: noir
key: auth
title: Access
description: "Centred access shell with a typed wordmark over a darkroom ground."
slots: [main, footer]
template_overrides:
# Chrome (4)
- { template: noir, block: navbar }
- { template: noir, block: footer }
- { template: noir, block: utility-bar }
- { template: noir, block: announcement-bar }
# Marketing (12)
- { template: noir, block: hero }
- { template: noir, block: feature-grid }
- { template: noir, block: rich-section }
- { template: noir, block: stats }
- { template: noir, block: testimonial }
- { template: noir, block: logo-strip }
- { template: noir, block: pricing }
- { template: noir, block: team }
- { template: noir, block: faq }
- { template: noir, block: timeline }
- { template: noir, block: cta }
- { template: noir, block: countdown }
# Primitives (11)
- { template: noir, block: heading }
- { template: noir, block: text }
- { template: noir, block: rich-text }
- { template: noir, block: image }
- { template: noir, block: quote }
- { template: noir, block: divider }
- { template: noir, block: button }
- { template: noir, block: card }
- { template: noir, block: gallery }
- { template: noir, block: video-embed }
- { template: noir, block: audio-embed }
# Commerce / local (6)
- { template: noir, block: menu-list }
- { template: noir, block: event-list }
- { template: noir, block: contact-form }
- { template: noir, block: contact-card }
- { template: noir, block: hours-location }
- { template: noir, block: social-links }
# Blog family (12)
- { template: noir, block: blog-hero }
- { template: noir, block: featured-posts }
- { template: noir, block: related-posts }
- { template: noir, block: popular-posts }
- { template: noir, block: category-list }
- { template: noir, block: post-hero }
- { template: noir, block: post-metadata }
- { template: noir, block: author-bio }
- { template: noir, block: author-bio-hero }
- { template: noir, block: newsletter-signup }
- { template: noir, block: breadcrumbs }
- { template: noir, block: sidebar-nav }
# Auth surface (3)
- { template: noir, block: auth-form }
- { template: noir, block: password-reset-form }
- { template: noir, block: auth-status }
# System (1)
- { template: noir, block: page-suggestions }
email_wrappers:
- noir
@ -44,236 +111,279 @@ email_wrappers:
css:
input_css_append: |
/* ============================================================
Noir Theme — Silver-on-black photography
Custom utility CSS injected via CSSManifest.InputCSSAppend.
Noir Theme — Detective / true-crime dossier utility CSS.
Injected via CSSManifest.InputCSSAppend.
Colour is ALWAYS consumed via hsl(var(--token)) with kebab-case
tokens (the CMS emits --muted-foreground, --card-foreground, ...
from the camelCase preset keys). Font families ALWAYS flow through
var(--font-heading|body|mono, <fallback>) — never a hardcoded face.
============================================================ */
/* --- 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. */
@layer utilities {
/* --- Typography slots -------------------------------------- */
/* Fallback stacks name the ideal typewriter faces first
(Special Elite / Courier Prime, admin-assignable via Google
Fonts) then the shipped OFL defaults (IBM Plex Mono, Source
Serif 4). */
.noir-heading {
font-family: var(--font-heading, "Special Elite", "IBM Plex Mono", "Courier New", monospace);
letter-spacing: 0.01em;
}
.noir-body {
font-family: var(--font-body, "Source Serif 4", Georgia, "Times New Roman", serif);
}
.noir-mono {
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
}
.noir-page {
font-family: var(--font-body, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
-webkit-font-smoothing: antialiased;
/* .noir-label — typed evidence label / eyebrow. */
.noir-label {
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.22em;
font-size: 0.7rem;
line-height: 1.4;
}
/* --- Hairline rules ---------------------------------------- */
.noir-rule { border: none; border-top: 1px solid hsl(var(--border)); margin: 0; }
.noir-rule-b { border-bottom: 1px solid hsl(var(--border)); }
.noir-hairline { border: 1px solid hsl(var(--border)); }
/* --- .noir-file — manila case-file card -------------------- */
/* Square-cornered paper panel with a hairline border and a top
file-tab drawn as a border accent. */
.noir-file {
position: relative;
background-color: hsl(var(--card));
color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border));
border-top: 2px solid hsl(var(--primary));
}
/* .noir-tab — folder-tab caption bar sitting above a file. */
.noir-tab {
display: inline-block;
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.65rem;
padding: 0.2rem 0.7rem;
}
/* --- .noir-stamp — rotated case-status stamp --------------- */
.noir-stamp {
display: inline-block;
border: 2px solid currentColor;
padding: 0.2rem 0.6rem;
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.7rem;
transform: rotate(-7deg);
opacity: 0.9;
}
/* --- .noir-redact — redaction bar over text ---------------- */
/* Solid ink bar; reveals on hover/focus. Reduced-motion safe:
the reveal is a colour swap with no transform. */
.noir-redact {
background-color: hsl(var(--foreground));
color: transparent;
border-radius: 1px;
padding: 0 0.15em;
transition: background-color 0.18s ease, color 0.18s ease;
cursor: help;
}
.noir-redact:hover,
.noir-redact:focus-visible {
background-color: transparent;
color: hsl(var(--foreground));
outline: none;
}
/* --- .noir-pin — evidence-board push pin ------------------- */
.noir-pin { position: relative; }
.noir-pin::before {
content: "";
position: absolute;
top: -7px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 12px;
border-radius: 9999px;
background: hsl(var(--primary));
box-shadow: 0 1px 2px hsl(var(--foreground) / 0.5);
z-index: 2;
}
/* --- .noir-tape — masking-tape corner strip ---------------- */
.noir-tape { position: relative; }
.noir-tape::before {
content: "";
position: absolute;
top: -0.5rem;
left: 50%;
transform: translateX(-50%) rotate(-3deg);
width: 4.5rem;
height: 1.1rem;
background: hsl(var(--muted-foreground) / 0.28);
border-left: 1px dashed hsl(var(--muted-foreground) / 0.4);
border-right: 1px dashed hsl(var(--muted-foreground) / 0.4);
z-index: 2;
}
/* --- .noir-grain — 3.5% film-grain overlay ----------------- */
.noir-grain { position: relative; isolation: isolate; }
.noir-grain::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0.035;
background-image:
repeating-radial-gradient(circle at 19% 27%, hsl(var(--foreground)) 0 1px, transparent 1px 3px),
repeating-radial-gradient(circle at 71% 63%, hsl(var(--foreground)) 0 1px, transparent 1px 3px);
mix-blend-mode: overlay;
}
/* --- .noir-spotlight — darkroom vignette ------------------- */
.noir-spotlight { position: relative; }
.noir-spotlight::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: radial-gradient(120% 90% at 50% 35%, transparent 40%, hsl(var(--background) / 0.85) 100%);
}
/* --- .noir-btn — square outline button --------------------- */
.noir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background-color: transparent;
color: hsl(var(--foreground));
border: 1px solid hsl(var(--foreground));
padding: 0.7rem 1.5rem;
font-family: var(--font-mono, "Courier Prime", "IBM Plex Mono", "Courier New", monospace);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.72rem;
text-decoration: none;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
cursor: pointer;
}
.noir-btn:hover,
.noir-btn:focus-visible {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.noir-btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
/* .noir-btn-solid — filled evidence-accent variant. */
.noir-btn-solid {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.noir-btn-solid:hover,
.noir-btn-solid:focus-visible {
background-color: transparent;
color: hsl(var(--foreground));
border-color: hsl(var(--foreground));
}
/* --- .noir-link — evidence-accent underline link ----------- */
.noir-link {
color: hsl(var(--primary));
text-decoration: none;
border-bottom: 1px solid hsl(var(--primary));
transition: color 0.18s ease, border-color 0.18s ease;
}
.noir-link:hover,
.noir-link:focus-visible {
color: hsl(var(--accent));
border-bottom-color: hsl(var(--accent));
}
/* --- Scroll reveal (MID motion, progressive enhancement) --- */
/* No-JS / no class: content is fully visible. The page template
adds `.noir-js` to <html> only when JS runs. */
.noir-js .noir-reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.6s ease, transform 0.6s ease;
will-change: opacity, transform;
}
.noir-js .noir-reveal.noir-in {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.noir-js .noir-reveal {
opacity: 1;
transform: none;
transition: none;
}
.noir-redact { transition: none; }
}
}
.noir-display {
font-family: var(--font-heading, "Tenor Sans", Georgia, serif);
letter-spacing: 0.01em;
text-decoration: none;
/* ============================================================
Explicit responsive layout (authored CSS — NOT purged).
The host Tailwind JIT does not reliably scan a theme's override
templates, so sm:/md:/lg: grid variants declared there can be
dropped from the build (a grid then never expands on desktop
and never collapses on mobile). These media-query rules own the
column counts deterministically and always collapse to a single
column on phones.
============================================================ */
.noir-cols { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
@media (min-width: 640px) {
.noir-cols-2, .noir-cols-3, .noir-cols-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* --- .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;
@media (min-width: 1024px) {
.noir-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.noir-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.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;
/* Grids that stay two-up on phones (galleries, stat rows, footer links). */
.noir-cols-2up { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; }
@media (min-width: 768px) {
.noir-cols-2up-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.noir-cols-2up-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* Media/text split — one column on phones, two from the tablet width. */
.noir-split { display: grid; grid-template-columns: 1fr; gap: 2.5rem; }
@media (min-width: 768px) { .noir-split { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Gap modifiers (used alongside any grid class above). */
.noir-gap-sm { gap: 0.75rem; }
.noir-gap-lg { gap: 2rem; }
/* --- .bleed -------------------------------------------------- */
/* Edge-to-edge utility — breaks out of a max-width container. */
/* Hero vertical rhythm — replaces the unreliable min-h-[75vh] arbitrary
class so the masthead has bounded, predictable breathing room instead
of collapsing to nothing or ballooning to 75vh of empty space. */
.noir-hero-pad { padding-top: 3.5rem; padding-bottom: 3.5rem; }
@media (min-width: 768px) { .noir-hero-pad { padding-top: 5rem; padding-bottom: 5rem; } }
@media (min-width: 1024px) { .noir-hero-pad { padding-top: 6.5rem; padding-bottom: 6.5rem; } }
.bleed {
width: 100vw;
margin-left: calc((100vw - 100%) / 2 * -1);
margin-right: calc((100vw - 100%) / 2 * -1);
}
/* Empty-media graceful state — the demo seed ships no images, so any
media frame that renders an empty <img> must not balloon into a grey
box or show broken-image chrome. Images never exceed their column. */
.noir-file img, .bn-card img, .bn-gallery img, figure img { max-width: 100%; height: auto; }
img[src=""], img:not([src]) { display: none; }
/* --- .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; }
}
/* Overflow safety — long typed/mono labels wrap rather than push the
page wider than a phone viewport (uppercase + wide tracking is the risk). */
.noir-label, .noir-mono, .noir-heading { overflow-wrap: break-word; }
/* Never let a block force a horizontal scrollbar on small screens. */
[data-block-override], [data-block] { max-width: 100%; }

View File

@ -2,23 +2,23 @@
{
"key": "noir:default-master",
"title": "Noir Default Master",
"page_templates": ["default", "article"],
"page_templates": ["default", "article", "blog-index", "contact"],
"blocks": [
{ "block_key": "navbar", "title": "Masthead", "content": { "menuName": "main", "variant": "minimal" }, "slot": "header", "sort_order": 0 },
{ "block_key": "slot", "title": "Main Slot", "content": { "slotName": "main", "placeholder": "Photographs go here" }, "slot": "main", "sort_order": 0 },
{ "block_key": "noir:caption_strip", "title": "Caption Strip", "content": { "label": "INDEX", "right": "© studio" }, "slot": "footer", "sort_order": 0 },
{ "block_key": "noir:footer", "title": "Footer", "content": { "showColophon": "true" }, "slot": "footer", "sort_order": 1 }
{ "block_key": "navbar", "title": "Case-File Masthead", "content": { "menuName": "main", "logoType": "text", "logoText": { "text": "The Cold File" } }, "slot": "header", "sort_order": 0 },
{ "block_key": "divider", "title": "Header Rule", "content": { "variant": "line", "weight": "thin", "width": "full" }, "slot": "header", "sort_order": 1 },
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main", "placeholder": "Page content" }, "slot": "main", "sort_order": 0 },
{ "block_key": "footer", "title": "Site Footer", "content": { "layout": "columns", "companyName": "The Cold File", "tagline": "Cases that were never closed." }, "slot": "footer", "sort_order": 0 }
]
},
{
"key": "noir:gallery-master",
"title": "Noir Gallery Master",
"key": "noir:case-master",
"title": "Noir Case Master",
"page_templates": ["landing", "full-width"],
"blocks": [
{ "block_key": "navbar", "title": "Dissolved Masthead", "content": { "menuName": "main", "variant": "dissolved" }, "slot": "header", "sort_order": 0 },
{ "block_key": "slot", "title": "Main Slot", "content": { "slotName": "main", "placeholder": "Photographs go here" }, "slot": "main", "sort_order": 0 },
{ "block_key": "noir:contact_sheet_footer", "title": "Contact Sheet Footer", "content": { "items": [] }, "slot": "footer", "sort_order": 0 },
{ "block_key": "noir:footer", "title": "Footer", "content": { "showColophon": "true" }, "slot": "footer", "sort_order": 1 }
{ "block_key": "navbar", "title": "Case-File Masthead", "content": { "menuName": "main", "logoType": "text", "logoText": { "text": "The Cold File" }, "variant": "transparent-over-hero" }, "slot": "hero", "sort_order": 0 },
{ "block_key": "slot", "title": "Main Content", "content": { "slotName": "main" }, "slot": "main", "sort_order": 0 },
{ "block_key": "divider", "title": "CTA Rule", "content": { "variant": "line", "weight": "thick", "width": "full" }, "slot": "cta", "sort_order": 0 },
{ "block_key": "footer", "title": "Site Footer", "content": { "layout": "columns", "companyName": "The Cold File", "tagline": "Cases that were never closed." }, "slot": "footer", "sort_order": 0 }
]
}
]

View File

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

View File

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

219
seed/seed.json Normal file
View File

@ -0,0 +1,219 @@
{
"demo": true,
"settings": {
"merge": {
"companyName": "The Cold File",
"tagline": "Cases that were never closed."
}
},
"data_tables": [
{
"key": "case_tips",
"name": "Case Tips",
"description": "Seeded tip-line submissions for The Cold File demo. A submission becomes a row here and emails the site admins.",
"columns": [
{ "key": "name", "label": "Name", "type": "text", "required": false },
{ "key": "email", "label": "Email", "type": "email", "required": true },
{ "key": "subject", "label": "Case or subject", "type": "text", "required": false },
{ "key": "message", "label": "Message", "type": "text", "required": true }
]
}
],
"pages": [
{
"slug": "/",
"title": "The Cold File",
"template_key": "landing",
"blocks": [
{
"block_key": "hero",
"title": "Hero",
"slot": "main",
"sort_order": 1,
"content": {
"template": "centered",
"eyebrow": "A true-crime file",
"headline": "The Cold File",
"subheadline": "Cases that were never closed.",
"description": "We read the record, walk the ground, and lay out what is known. One case at a time. You decide what it adds up to.",
"ctaPrimary": { "text": "Read the files", "url": "/blog" },
"ctaSecondary": { "text": "Submit a tip", "url": "/contact" }
}
},
{
"block_key": "noir:case_file",
"title": "Featured case",
"slot": "main",
"sort_order": 2,
"content": {
"caseNumber": "CF-0451",
"subject": "The Harbour Light",
"status": "cold",
"summary": "A night watchman clocked out at two and never reached the gate. His lamp was still burning at dawn. The tide had turned twice.",
"fields": [
{ "label": "Filed", "value": "March 1998" },
{ "label": "Location", "value": "Pier 9, East Dock" },
{ "label": "Last contact", "value": "02:04, radio check" },
{ "label": "Status", "value": "Open, unsolved" }
]
}
},
{
"block_key": "feature-grid",
"title": "How we work",
"slot": "main",
"sort_order": 3,
"content": {
"eyebrow": "Method",
"heading": "How we work a file",
"intro": "No theory before the record. We build the case from what can be checked.",
"columns": 3,
"layout": "card",
"items": [
{ "icon": "lucide:file-text", "title": "Read the record", "text": "Reports, transcripts, and the file as it was left. We start where the paper starts." },
{ "icon": "lucide:map-pin", "title": "Walk the ground", "text": "We stand where it happened and check the file against the place." },
{ "icon": "lucide:search", "title": "Follow the thread", "text": "One fact at a time. We show our work and mark what stays unknown." }
]
}
},
{
"block_key": "noir:redacted_quote",
"title": "Transcript",
"slot": "main",
"sort_order": 4,
"content": {
"label": "Interview 3, page 12",
"quote": "I told them what I saw at the water. They wrote it down and the name came back",
"redacted": "withheld pending review",
"attribution": "Witness statement",
"source": "Case CF-0451, sealed appendix"
}
},
{
"block_key": "cta",
"title": "Closing CTA",
"slot": "main",
"sort_order": 5,
"content": {
"background": "band",
"heading": "Know something we don't?",
"text": "Tips are read in confidence. Names stay off the page unless you ask otherwise.",
"primaryLabel": "Submit a tip",
"primaryUrl": "/contact",
"secondaryLabel": "Read the files",
"secondaryUrl": "/blog"
}
}
]
},
{
"slug": "/about",
"title": "About the File",
"template_key": "default",
"blocks": [
{ "block_key": "heading", "title": "Heading", "slot": "main", "sort_order": 1, "content": { "text": "About the File", "level": 1 } },
{ "block_key": "rich-text", "title": "Story", "slot": "main", "sort_order": 2, "content": { "html": "<p>The Cold File is a true-crime publication. We take one unsolved case at a time and set it out plainly, from the record up.</p><p>We do not name the guilty. We do not sell a theory. We show what is known, mark what is not, and leave the reader to weigh it. When new facts arrive, the file is updated in the open.</p>" } },
{ "block_key": "divider", "title": "Divider", "slot": "main", "sort_order": 3, "content": { "variant": "line", "weight": "thick", "width": "narrow" } },
{ "block_key": "quote", "title": "Quote", "slot": "main", "sort_order": 4, "content": { "style": "pull", "align": "center", "quote": "A cold case is not a closed one. It is a file waiting for one more fact.", "attribution": "The Cold File" } }
]
},
{
"slug": "/blog",
"title": "The Case Files",
"template_key": "blog-index",
"blocks": [
{ "block_key": "card", "title": "Case: Harbour Light", "slot": "main", "sort_order": 1, "content": { "layout": "vertical", "title": "The Harbour Light", "text": "<p>A watchman clocked out and never reached the gate. The lamp burned until dawn.</p>", "link": "/blog/the-harbour-light", "linkLabel": "Open the file" } },
{ "block_key": "card", "title": "Case: Room 14", "slot": "main", "sort_order": 2, "content": { "layout": "vertical", "title": "Room 14", "text": "<p>A guest checked in under a false name and left one suitcase behind. Nobody saw the door open.</p>", "link": "/blog/room-14", "linkLabel": "Open the file" } },
{ "block_key": "card", "title": "Case: The Last Bus", "slot": "main", "sort_order": 3, "content": { "layout": "vertical", "title": "The Last Bus", "text": "<p>The driver logged the final stop on time. The bus reached the depot empty and locked from inside.</p>", "link": "/blog/the-last-bus", "linkLabel": "Open the file" } }
]
},
{
"slug": "/blog/the-harbour-light",
"title": "The Harbour Light",
"template_key": "article",
"parent_slug": "/blog",
"blocks": [
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "The Harbour Light", "level": 1 } },
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 2, "content": { "html": "<p>The watchman made his last radio check at four minutes past two. He was due at the gate by half past. He never signed out.</p><p>The lamp on Pier 9 was still lit at dawn. The tide had turned twice. No boat was reported missing. No body was found. The file was opened, worked for a season, and set aside.</p><p>We walked the pier at the same hour. The record and the place do not agree on one point. We are still checking which one is wrong.</p>" } }
]
},
{
"slug": "/blog/room-14",
"title": "Room 14",
"template_key": "article",
"parent_slug": "/blog",
"blocks": [
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "Room 14", "level": 1 } },
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 2, "content": { "html": "<p>The guest signed the register in a hand that did not match the name. He paid a week in advance and asked for the quiet end of the hall.</p><p>On the fourth night the room went dark and stayed that way. The suitcase was still there. The bed was made. The window was latched from the inside.</p><p>The file lists three people who saw him arrive and none who saw him leave. We are trying to reconcile that.</p>" } }
]
},
{
"slug": "/blog/the-last-bus",
"title": "The Last Bus",
"template_key": "article",
"parent_slug": "/blog",
"blocks": [
{ "block_key": "heading", "title": "Title", "slot": "main", "sort_order": 1, "content": { "text": "The Last Bus", "level": 1 } },
{ "block_key": "rich-text", "title": "Body", "slot": "main", "sort_order": 2, "content": { "html": "<p>The driver logged the final stop on schedule. Twelve minutes later the bus rolled into the depot. It was empty and the doors were locked from inside.</p><p>The fare box held the right count for a full run. The seats were cold. The last passenger on the ticket record never reached home.</p><p>We have the route, the timing, and the log. What we do not have is the twelve minutes.</p>" } }
]
},
{
"slug": "/contact",
"title": "Tip Line",
"template_key": "contact",
"blocks": [
{
"block_key": "contact-form",
"title": "Tip Line",
"slot": "main",
"sort_order": 1,
"content": {
"heading": "Submit a tip",
"description": "Tips are read in confidence. Tell us the case and what you know. Leave contact details only if you want a reply.",
"submitLabel": "Send tip",
"successMessage": "Thank you. Your tip has been logged. We read every one.",
"fields": [
{ "name": "name", "label": "Your name", "type": "text", "required": false, "width": "half" },
{ "name": "email", "label": "Email", "type": "email", "required": true, "width": "half" },
{ "name": "subject", "label": "Case or subject", "type": "text", "required": false, "width": "full" },
{ "name": "message", "label": "What do you know?", "type": "textarea", "required": true, "width": "full" }
],
"formConfig": { "enabled": true, "captchaEnabled": false, "targetTable": "case_tips" }
}
},
{
"block_key": "hours-location",
"title": "Where to find us",
"slot": "aside",
"sort_order": 1,
"content": {
"heading": "The Desk",
"hours": [
{ "day": "Monday to Friday", "hours": "9am to 6pm" },
{ "day": "Saturday", "hours": "By appointment" },
{ "day": "Sunday", "closed": true }
],
"specialHoursNote": "Tips are read outside these hours. Replies are not.",
"address": "Room 14, The Old Exchange\nEast Dock",
"phone": "+1 555 0199"
}
}
]
},
{
"slug": "/login",
"title": "Access",
"template_key": "auth",
"blocks": [
{ "block_key": "auth-form", "title": "Sign in", "slot": "main", "sort_order": 1, "content": { "default_tab": "login", "show_social": false } },
{ "block_key": "text", "title": "Foot", "slot": "footer", "sort_order": 1, "content": { "text": "<p>Registered readers get new files first and the sealed appendices as they clear review.</p>" } }
]
}
],
"menu_items": [
{ "menu": "main", "label": "Home", "page_slug": "/", "sort_order": 1 },
{ "menu": "main", "label": "The Case Files", "page_slug": "/blog", "sort_order": 2 },
{ "menu": "main", "label": "About the File", "page_slug": "/about", "sort_order": 3 },
{ "menu": "main", "label": "Tip Line", "page_slug": "/contact", "sort_order": 4 }
]
}

19
seed/workflows.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,24 @@
{# noir override — blog-hero; preserves title, subtitle, posts provider loop, post.featured_image_url/.url/.title/.excerpt/.author_name/.published_at, showExcerpt, forloop.First. {% img %} kept verbatim. #}
<section data-block-override="noir:blog-hero" class="bg-background py-12 text-foreground md:py-16{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
<div class="max-w-2xl">
{% if title %}<h1 class="noir-heading text-4xl md:text-5xl" style="color:hsl(var(--foreground));">{{ title }}</h1>{% endif %}
{% if subtitle %}<p class="noir-body mt-3 text-lg" style="color:hsl(var(--muted-foreground));">{{ subtitle }}</p>{% endif %}
</div>
{% if posts %}
<div class="mt-10 noir-cols noir-cols-3">
{% for post in posts %}
<article class="group flex flex-col{% if forloop.First %} md:col-span-3 md:flex-row md:items-stretch md:gap-6{% endif %}">
{% if post.featured_image_url %}<a href="{{ post.url }}" class="mb-3 block overflow-hidden bg-muted grayscale{% if forloop.First %} md:mb-0 md:w-1/2{% endif %}">{% img post.featured_image_url alt=post.title class="aspect-video h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
<div class="flex flex-col justify-center{% if forloop.First %} md:w-1/2{% endif %}">
<span class="noir-rule mb-4 block w-12"></span>
<h2 class="noir-heading {% if forloop.First %}text-2xl{% else %}text-lg{% endif %} leading-snug" style="color:hsl(var(--foreground));"><a href="{{ post.url }}" class="transition-colors hover:text-primary">{{ post.title }}</a></h2>
{% if showExcerpt and post.excerpt %}<p class="noir-body mt-2 text-sm" style="color:hsl(var(--muted-foreground));">{{ post.excerpt }}</p>{% endif %}
<div class="mt-3 flex flex-wrap items-center gap-x-3 noir-label">{% if post.author_name %}<span style="color:hsl(var(--primary));">{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span style="color:hsl(var(--muted-foreground));">{{ post.published_at|date:"M j, Y" }}</span>{% endif %}</div>
</div>
</article>
{% endfor %}
</div>
{% endif %}
</div>
</section>

View File

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

View File

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

View File

@ -1 +1,4 @@
<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>{% endif %}{% if body %}<div style="color: hsl(var(--foreground)); line-height: 1.6;">{{ body|safe }}</div>{% elif text %}<div style="color: hsl(var(--foreground)); line-height: 1.6;">{{ text|safe }}</div>{% endif %}</div>
{# card override — manila case-file panel. Fields mirror builtin: layout, link, media, title, text, linkLabel. Media via {% img %}. #}
{% with layout=layout|default:"vertical" %}
<article data-block-override="noir:card" class="bn-card noir-file overflow-hidden transition-colors {% if layout == "horizontal" %}flex flex-col sm:flex-row{% endif %}{% if class %} {{ class }}{% endif %}">{% if link %}<a href="{{ link }}" class="contents">{% endif %}{% if media %}<div class="overflow-hidden bg-muted grayscale {% if layout == "horizontal" %}sm:w-2/5 sm:shrink-0{% endif %}">{% if layout == "horizontal" %}{% img media alt=title|default:"" class="h-full w-full object-cover" %}{% else %}{% img media alt=title|default:"" class="aspect-video h-full w-full object-cover" %}{% endif %}</div>{% endif %}<div class="flex flex-col gap-2 p-5 md:p-6 {% if layout == "horizontal" %}justify-center{% endif %}">{% if title %}<h3 class="noir-heading text-lg" style="color:hsl(var(--foreground));">{{ title }}</h3>{% endif %}{% if text %}<div class="noir-body text-sm leading-relaxed" style="color:hsl(var(--muted-foreground));">{{ text|safe }}</div>{% endif %}{% if linkLabel and link %}<span class="mt-2 inline-flex items-center gap-1 noir-label" style="color:hsl(var(--primary));">{{ linkLabel }}::lucide:arrow-right:sm::</span>{% endif %}</div>{% if link %}</a>{% endif %}</article>
{% endwith %}

View File

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

View File

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

View File

@ -0,0 +1,40 @@
{# noir override — contact-form; ALL form wiring preserved verbatim from builtin: bid, #contact-form-{{ bid }}, form.submitted/errors/message, {% form %} action/hx attrs, field loop (textarea/select/input), honeypot website input, submit target. Only inputs restyled to noir. #}
{% with bid=_block_id|default:context.blockId %}
<section data-block-override="noir:contact-form" id="contact-form-{{ bid }}" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-2xl px-4">
{% if form.submitted %}
<div class="noir-file p-10 text-center">
<div class="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full" style="box-shadow:inset 0 0 0 1px hsl(var(--primary));color:hsl(var(--primary));"><svg class="h-6 w-6" aria-hidden="true"><use href="/icons/lucide.svg#check"></use></svg></div>
<p class="noir-body text-lg" style="color:hsl(var(--card-foreground));">{{ form.message|default:successMessage|default:"Thanks — your message has been sent." }}</p>
</div>
{% else %}
{% if heading %}<h2 class="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if description %}<p class="noir-body mt-3 text-lg" style="color:hsl(var(--muted-foreground));">{{ description }}</p>{% endif %}
{% if form.errors._form %}<div class="mt-6 border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">{{ form.errors._form }}</div>{% endif %}
{% with formAction="/api/blocks/"|add:bid|add:"/submit" formTarget="#contact-form-"|add:bid %}
{% form action=formAction hx_target=formTarget hx_swap="outerHTML" class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2" %}
{% for field in fields %}
<div class="{% if field.width == "half" %}sm:col-span-1{% else %}sm:col-span-2{% endif %}">
{% if field.label %}<label for="cf-{{ bid }}-{{ field.name }}" class="noir-label mb-1.5 block" style="color:hsl(var(--primary));">{{ field.label }}{% if field.required %} <span class="text-destructive">*</span>{% endif %}</label>{% endif %}
{% if field.type == "textarea" %}
<textarea id="cf-{{ bid }}-{{ field.name }}" name="{{ field.name }}" rows="5"{% if field.required %} required{% endif %}{% if field.placeholder %} placeholder="{{ field.placeholder }}"{% endif %} class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring"></textarea>
{% elif field.type == "select" %}
<select id="cf-{{ bid }}-{{ field.name }}" name="{{ field.name }}"{% if field.required %} required{% endif %} class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring">
{% if field.placeholder %}<option value="">{{ field.placeholder }}</option>{% endif %}
{% for opt in field.options %}<option value="{{ opt.value }}">{{ opt.label|default:opt.value }}</option>{% endfor %}
</select>
{% else %}
<input id="cf-{{ bid }}-{{ field.name }}" name="{{ field.name }}" type="{{ field.type|default:"text" }}"{% if field.required %} required{% endif %}{% if field.placeholder %} placeholder="{{ field.placeholder }}"{% endif %} class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring">
{% endif %}
</div>
{% endfor %}
<div class="hidden" aria-hidden="true"><label>Leave this field empty<input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>
<div class="sm:col-span-2">
<button type="submit" class="noir-btn noir-btn-solid inline-flex items-center justify-center">{{ submitLabel|default:"Send message" }}</button>
</div>
{% endform %}
{% endwith %}
{% endif %}
</div>
</section>
{% endwith %}

View File

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

View File

@ -0,0 +1,15 @@
{# noir override — cta; preserves heading, text, background (band/card/split), primaryUrl/primaryLabel, secondaryUrl/secondaryLabel. Arrow svg markup verbatim; band adds .noir-grain. #}
<section data-block-override="noir:cta" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
<div class="{% if background == "band" %}noir-file noir-grain bg-primary px-6 py-14 text-center text-primary-foreground md:px-12{% elif background == "card" %}noir-file px-6 py-14 text-center text-card-foreground md:px-12{% else %}noir-file flex flex-col items-center gap-8 bg-muted px-6 py-12 text-foreground md:flex-row md:justify-between md:px-12 md:text-left{% endif %}">
<div class="{% if background != "split" %}mx-auto max-w-2xl{% endif %}">
{% if heading %}<h2 class="noir-heading text-3xl md:text-4xl">{{ heading }}</h2>{% endif %}
{% if text %}<p class="noir-body mt-3 text-lg {% if background == "band" %}text-primary-foreground/80{% else %}text-muted-foreground{% endif %}">{{ text }}</p>{% endif %}
</div>
<div class="flex flex-wrap items-center justify-center gap-3 {% if background != "split" %}mt-8{% else %}shrink-0{% endif %}">
{% if primaryUrl and primaryLabel %}<a href="{{ primaryUrl }}" class="noir-btn noir-btn-solid">{{ primaryLabel }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
{% if secondaryUrl and secondaryLabel %}<a href="{{ secondaryUrl }}" class="noir-link inline-flex items-center">{{ secondaryLabel }}</a>{% endif %}
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,10 @@
{# noir override — divider; preserves variant (line/ornament/spacer-rule), weight, width, icon. Ornament icon via ::{{ icon }}:sm::. #}
{% with variant=variant|default:"line" weight=weight|default:"thin" width=width|default:"full" %}
{% if variant == "ornament" %}
<div data-block-override="noir:divider" class="bn-divider mx-auto my-8 flex items-center gap-4 {% if width == "narrow" %}max-w-[200px]{% elif width == "wide" %}max-w-3xl{% else %}w-full{% endif %}{% if class %} {{ class }}{% endif %}" role="separator"><span class="noir-rule flex-1"></span><span class="shrink-0" style="color:hsl(var(--primary));">::{{ icon|default:"lucide:asterisk" }}:sm::</span><span class="noir-rule flex-1"></span></div>
{% elif variant == "spacer-rule" %}
<div data-block-override="noir:divider" class="bn-divider mx-auto py-10 flex justify-center {% if width == "narrow" %}max-w-[200px]{% elif width == "wide" %}max-w-3xl{% else %}w-full{% endif %}{% if class %} {{ class }}{% endif %}" role="separator"><hr class="mx-auto w-16 border-0 border-t {% if weight == "thick" %}border-t-2{% elif weight == "medium" %}border-t-2{% endif %} border-border"></div>
{% else %}
<hr data-block-override="noir:divider" class="bn-divider mx-auto my-8 border-0 border-t {% if weight == "thick" %}border-t-2{% elif weight == "medium" %}border-t-2{% endif %} border-border {% if width == "narrow" %}max-w-[200px]{% elif width == "wide" %}max-w-3xl{% else %}w-full{% endif %}{% if class %} {{ class }}{% endif %}" role="separator">
{% endif %}
{% endwith %}

View File

@ -0,0 +1,41 @@
{# noir override — event-list; preserves heading, intro, layout, events[].past/.media/.datetime/.start/.end/.timezone/.title/.venue/.description/.link/.linkLabel, hidePast. Icon <svg><use> markup kept verbatim. #}
<section data-block-override="noir:event-list" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="noir-body mt-3 max-w-2xl text-lg" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
{% if layout == "cards" %}
<div class="mt-10 noir-cols noir-cols-3">
{% for event in events %}{% if not (hidePast and event.past) %}
<article class="noir-file flex flex-col overflow-hidden{% if event.past %} opacity-60{% endif %}">
{% if event.media %}<div class="aspect-video overflow-hidden bg-muted grayscale">{% img event.media alt=event.title class="h-full w-full object-cover" %}</div>{% endif %}
<div class="flex flex-1 flex-col gap-2 p-5">
<div class="noir-label"><time{% if event.datetime %} datetime="{{ event.datetime }}"{% endif %} style="color:hsl(var(--primary));">{{ event.start }}{% if event.end %} &ndash; {{ event.end }}{% endif %}</time>{% if event.timezone %} <span style="color:hsl(var(--muted-foreground));">{{ event.timezone }}</span>{% endif %}</div>
<h3 class="noir-heading text-lg" style="color:hsl(var(--card-foreground));">{{ event.title }}</h3>
{% if event.venue %}<p class="noir-label" style="color:hsl(var(--muted-foreground));">{{ event.venue }}</p>{% endif %}
{% if event.description %}<p class="noir-body text-sm" style="color:hsl(var(--muted-foreground));">{{ event.description }}</p>{% endif %}
{% if event.link %}<a href="{{ event.link }}" class="noir-link mt-auto inline-flex items-center gap-1 pt-2 text-sm">{{ event.linkLabel|default:"Details" }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
</div>
</article>
{% endif %}{% endfor %}
</div>
{% else %}
<ul class="mt-10 divide-y divide-border border-y border-border">
{% for event in events %}{% if not (hidePast and event.past) %}
<li class="flex flex-col gap-3 py-5 sm:flex-row sm:items-center sm:gap-6{% if event.past %} opacity-60{% endif %}">
<div class="shrink-0 sm:w-52">
<div class="noir-label"><time{% if event.datetime %} datetime="{{ event.datetime }}"{% endif %} style="color:hsl(var(--primary));">{{ event.start }}</time></div>
{% if event.end %}<div class="noir-mono text-xs" style="color:hsl(var(--muted-foreground));">to {{ event.end }}</div>{% endif %}
{% if event.timezone %}<div class="noir-mono text-xs" style="color:hsl(var(--muted-foreground));">{{ event.timezone }}</div>{% endif %}
</div>
<div class="min-w-0 flex-1">
<h3 class="noir-heading text-base" style="color:hsl(var(--foreground));">{{ event.title }}</h3>
{% if event.venue %}<p class="noir-label" style="color:hsl(var(--muted-foreground));">{{ event.venue }}</p>{% endif %}
{% if event.description %}<p class="noir-body mt-1 text-sm" style="color:hsl(var(--muted-foreground));">{{ event.description }}</p>{% endif %}
</div>
{% if event.link %}<a href="{{ event.link }}" class="noir-btn inline-flex shrink-0 items-center">{{ event.linkLabel|default:"Details" }}</a>{% endif %}
</li>
{% endif %}{% endfor %}
</ul>
{% endif %}
</div>
</section>

View File

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

View File

@ -0,0 +1,18 @@
{# noir override — feature-grid; preserves eyebrow, heading, intro, columns, layout, items[].icon/.title/.text/.linkUrl/.linkLabel, icon split markup verbatim. #}
<section data-block-override="noir:feature-grid" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if eyebrow %}<p class="text-center noir-label" style="color:hsl(var(--primary));">{{ eyebrow }}</p>{% endif %}
{% if heading %}<h2 class="noir-heading mt-2 text-center text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="noir-body mx-auto mt-4 max-w-2xl text-center text-lg" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
<div class="mt-10 noir-cols {% if columns == 2 %}noir-cols-2{% elif columns == 4 %}noir-cols-4{% else %}noir-cols-3{% endif %}">
{% for item in items %}
<div class="{% if layout == "card" %}noir-file p-6 text-card-foreground transition-colors{% elif layout == "centered" %}text-center{% endif %}">
{% if item.icon %}<div class="mb-3 text-primary{% if layout == "centered" %} flex justify-center{% endif %}"><svg class="h-7 w-7" aria-hidden="true"><use href="/icons/{{ item.icon|split:":"|first }}.svg#{{ item.icon|split:":"|last }}"></use></svg></div>{% endif %}
<h3 class="noir-heading text-lg" style="color:hsl(var(--foreground));">{% if item.linkUrl %}<a href="{{ item.linkUrl }}" class="transition-colors hover:text-primary">{{ item.title }}</a>{% else %}{{ item.title }}{% endif %}</h3>
{% if item.text %}<p class="noir-body mt-2" style="color:hsl(var(--muted-foreground));">{{ item.text }}</p>{% endif %}
{% if item.linkUrl and item.linkLabel %}<a href="{{ item.linkUrl }}" class="noir-link mt-3 inline-flex items-center gap-1 text-sm">{{ item.linkLabel }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>

View File

@ -0,0 +1,22 @@
{# noir override — featured-posts; preserves heading, columns, posts provider loop, post.featured_image_url/.url/.title/.excerpt/.author_name/.published_at/.reading_time, showFeaturedImage/showExcerpt/showAuthor/showDate. {% img %} kept verbatim. #}
<section data-block-override="noir:featured-posts" class="py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="noir-heading mb-8 text-2xl md:text-3xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
<div class="noir-cols {% if columns == 2 %}noir-cols-2{% elif columns == 4 %}noir-cols-4{% else %}noir-cols-3{% endif %}">
{% for post in posts %}
<article class="noir-file group flex flex-col overflow-hidden">
{% if showFeaturedImage and post.featured_image_url %}<a href="{{ post.url }}" class="block aspect-video overflow-hidden bg-muted grayscale">{% img post.featured_image_url alt=post.title class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
<div class="flex flex-1 flex-col p-5">
<h3 class="noir-heading text-lg leading-snug" style="color:hsl(var(--card-foreground));"><a href="{{ post.url }}" class="transition-colors hover:text-primary">{{ post.title }}</a></h3>
{% if showExcerpt and post.excerpt %}<p class="noir-body mt-2 flex-1 text-sm" style="color:hsl(var(--muted-foreground));">{{ post.excerpt }}</p>{% endif %}
<div class="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 noir-label">
{% if showAuthor and post.author_name %}<span style="color:hsl(var(--primary));">{{ post.author_name }}</span>{% endif %}
{% if showDate and post.published_at %}<span style="color:hsl(var(--muted-foreground));">{{ post.published_at|date:"M j, Y" }}</span>{% endif %}
{% if post.reading_time %}<span style="color:hsl(var(--muted-foreground));">{{ post.reading_time }} min read</span>{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</section>

View File

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

View File

@ -0,0 +1,52 @@
{# noir override — gallery; preserves layout, gap, columns, lightbox, images[].src/.alt/.caption, data-bn-gallery, data-lightbox, data-bn-gallery-item, data-caption, {% img %} calls, and the full lightbox IIFE verbatim. Only grid tiles restyled (square hairline frames, grayscale). #}
{% with layout=layout|default:"grid" gap=gap|default:"md" cols=columns|default:3 %}<div data-block-override="noir:gallery" class="bn-gallery my-8{% if class %} {{ class }}{% endif %}" data-bn-gallery data-lightbox="{% if lightbox %}true{% else %}false{% endif %}"><div class="{% if layout == "carousel" %}flex snap-x snap-mandatory overflow-x-auto pb-2 {% if gap == "sm" %}gap-2{% elif gap == "lg" %}gap-6{% else %}gap-4{% endif %}{% elif layout == "masonry" %}columns-2 {% if cols == 4 %}md:columns-4{% elif cols == 2 %}md:columns-2{% else %}md:columns-3{% endif %} {% if gap == "sm" %}gap-2{% elif gap == "lg" %}gap-6{% else %}gap-4{% endif %}{% else %}noir-cols-2up {% if cols == 4 %}noir-cols-2up-4{% elif cols == 2 %}{% else %}noir-cols-2up-3{% endif %} {% if gap == "sm" %}noir-gap-sm{% elif gap == "lg" %}noir-gap-lg{% endif %}{% endif %}">{% for image in images %}<a href="{{ image.src|mediaURL }}" data-bn-gallery-item data-caption="{{ image.caption|default:"" }}" class="noir-hairline group relative block overflow-hidden bg-muted grayscale transition hover:grayscale-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {% if layout == "carousel" %}w-64 shrink-0 snap-start sm:w-80{% elif layout == "masonry" %}mb-4 w-full break-inside-avoid{% endif %}">{% if layout == "grid" %}{% img image.src alt=image.alt|default:"" class="aspect-square w-full object-cover" %}{% else %}{% img image.src alt=image.alt|default:"" class="h-auto w-full" %}{% endif %}{% if image.caption %}<span class="sr-only">{{ image.caption }}</span>{% endif %}</a>{% endfor %}</div></div>{% if lightbox %}<script>
(function(){
if(window.__bnLightbox)return;window.__bnLightbox=true;
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var ov,imgEl,capEl,items=[],idx=0,lastFocus=null;
function build(){
ov=document.createElement('div');
ov.setAttribute('role','dialog');ov.setAttribute('aria-modal','true');ov.setAttribute('aria-label','Image viewer');ov.tabIndex=-1;
ov.style.cssText='position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.9);padding:2rem;';
var btn='position:absolute;width:2.75rem;height:2.75rem;display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;border:none;background:hsl(var(--background));color:hsl(var(--foreground));cursor:pointer;';
ov.innerHTML=
'<button type="button" data-lb-close aria-label="Close" style="'+btn+'top:1rem;right:1rem;"><svg style="width:1.25rem;height:1.25rem" aria-hidden="true"><use href="/icons/lucide.svg#x"/></svg></button>'+
'<button type="button" data-lb-prev aria-label="Previous image" style="'+btn+'left:1rem;top:50%;transform:translateY(-50%);"><svg style="width:1.25rem;height:1.25rem" aria-hidden="true"><use href="/icons/lucide.svg#chevron-left"/></svg></button>'+
'<figure style="margin:0;max-width:92vw;max-height:92vh;display:flex;flex-direction:column;align-items:center;gap:0.75rem;"><img data-lb-img alt="" style="max-width:92vw;max-height:82vh;object-fit:contain;border-radius:0.5rem;'+(reduce?'':'transition:opacity .2s ease;')+'"><figcaption data-lb-cap style="color:hsl(var(--background));opacity:.85;font-size:.875rem;text-align:center;max-width:60ch;"></figcaption></figure>'+
'<button type="button" data-lb-next aria-label="Next image" style="'+btn+'right:1rem;top:50%;transform:translateY(-50%);"><svg style="width:1.25rem;height:1.25rem" aria-hidden="true"><use href="/icons/lucide.svg#chevron-right"/></svg></button>';
document.body.appendChild(ov);
imgEl=ov.querySelector('[data-lb-img]');capEl=ov.querySelector('[data-lb-cap]');
ov.addEventListener('click',function(e){if(e.target===ov||e.target.closest('[data-lb-close]'))close();});
ov.querySelector('[data-lb-prev]').addEventListener('click',function(e){e.stopPropagation();show(idx-1);});
ov.querySelector('[data-lb-next]').addEventListener('click',function(e){e.stopPropagation();show(idx+1);});
}
function show(i){
if(!items.length)return;
idx=(i+items.length)%items.length;
var a=items[idx];var im=a.querySelector('img');
imgEl.src=(im&&(im.currentSrc||im.src))||a.getAttribute('href');
imgEl.alt=im?im.alt:'';
var cap=a.getAttribute('data-caption')||'';capEl.textContent=cap;capEl.style.display=cap?'block':'none';
}
function open(g,link){
if(!ov)build();
items=Array.prototype.slice.call(g.querySelectorAll('[data-bn-gallery-item]'));
lastFocus=document.activeElement;ov.style.display='flex';
var multi=items.length>1;
ov.querySelector('[data-lb-prev]').style.display=multi?'inline-flex':'none';
ov.querySelector('[data-lb-next]').style.display=multi?'inline-flex':'none';
show(items.indexOf(link));ov.focus();
}
function close(){if(ov)ov.style.display='none';if(lastFocus&&lastFocus.focus)lastFocus.focus();}
document.addEventListener('click',function(e){
var link=e.target.closest('[data-bn-gallery][data-lightbox="true"] [data-bn-gallery-item]');
if(!link)return;e.preventDefault();open(link.closest('[data-bn-gallery]'),link);
});
document.addEventListener('keydown',function(e){
if(!ov||ov.style.display==='none')return;
if(e.key==='Escape'){e.preventDefault();close();}
else if(e.key==='ArrowLeft')show(idx-1);
else if(e.key==='ArrowRight')show(idx+1);
});
})();
</script>{% endif %}{% endwith %}

View File

@ -1 +1,7 @@
{% if level|integer == 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>{% elif level|integer == 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>{% elif level|integer == 4 %}<h4 class="noir-display {{ textClass }}" style="font-size: 1.5rem; color: hsl(var(--foreground));">{{ text }}</h4>{% elif level|integer == 5 %}<h5 class="noir-display {{ textClass }}" style="font-size: 1.25rem; color: hsl(var(--foreground));">{{ text }}</h5>{% elif level|integer == 6 %}<h6 class="noir-display {{ textClass }}" style="font-size: 1.125rem; color: hsl(var(--foreground));">{{ text }}</h6>{% else %}<h2 class="noir-display {{ textClass }}" style="font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; color: hsl(var(--foreground));">{{ text }}</h2>{% endif %}
{# heading override — typed case-file header. Fields mirror builtin: text, level, textClass. #}
{% if level == 1 %}<h1 data-block-override="noir:heading" class="noir-heading text-4xl md:text-5xl flex-1 {{ textClass }}" style="color:hsl(var(--foreground));">{{ text }}</h1>
{% elif level == 3 %}<h3 data-block-override="noir:heading" class="noir-heading text-2xl md:text-3xl flex-1 {{ textClass }}" style="color:hsl(var(--foreground));">{{ text }}</h3>
{% elif level == 4 %}<h4 data-block-override="noir:heading" class="noir-heading text-xl md:text-2xl flex-1 {{ textClass }}" style="color:hsl(var(--foreground));">{{ text }}</h4>
{% elif level == 5 %}<h5 data-block-override="noir:heading" class="noir-heading text-lg md:text-xl flex-1 {{ textClass }}" style="color:hsl(var(--foreground));">{{ text }}</h5>
{% elif level == 6 %}<h6 data-block-override="noir:heading" class="noir-heading text-base md:text-lg flex-1 {{ textClass }}" style="color:hsl(var(--foreground));">{{ text }}</h6>
{% else %}<h2 data-block-override="noir:heading" class="noir-heading text-3xl md:text-4xl flex-1 {{ textClass }}" style="color:hsl(var(--foreground));">{{ text }}</h2>{% endif %}

View File

@ -0,0 +1,39 @@
{# noir override — hero; preserves styled-text {% set %} lines, herocontent macro, split/centered branches, {% img %} background calls + overlay conditionals, trustLogos loop verbatim. #}
{% set eyebrowText = eyebrow.text|default:eyebrow %}
{% set headlineText = headline.text|default:headline %}
{% set subText = subheadline.text|default:subheadline %}
{% set descText = description.text|default:description %}
{% set pText = ctaPrimary.text %}
{% set pUrl = ctaPrimary.url %}
{% set sText = ctaSecondary.text %}
{% set sUrl = ctaSecondary.url %}
{% set variant = template|default:"centered" %}
{% set hasImage = backgroundImage %}
{% set showOverlay = hasImage and overlayColor != "none" and overlayColor != "" %}
{% macro herocontent(align) %}
{% if eyebrowText %}<div class="mb-5 flex items-center gap-4 {% if align == "center" %}justify-center{% endif %}"><span class="noir-rule w-10"></span><span class="noir-label" style="color:hsl(var(--primary));">{{ eyebrowText }}</span><span class="noir-rule w-10"></span></div>{% endif %}
{% if headlineText %}<h1 class="noir-heading text-4xl sm:text-5xl lg:text-6xl leading-tight" style="color:hsl(var(--foreground));">{{ headlineText }}</h1>{% endif %}
{% if subText %}<h2 class="noir-body mt-5 text-xl sm:text-2xl max-w-2xl{% if align == "center" %} mx-auto{% endif %}" style="color:hsl(var(--muted-foreground));">{{ subText }}</h2>{% endif %}
{% if descText %}<p class="noir-body mt-4 max-w-xl leading-relaxed{% if align == "center" %} mx-auto{% endif %}" style="color:hsl(var(--muted-foreground));">{{ descText }}</p>{% endif %}
{% if pText or sText %}<div class="mt-8 flex flex-col sm:flex-row gap-4{% if align == "center" %} justify-center{% endif %}">
{% if pText and pUrl %}<a href="{{ pUrl }}" class="noir-btn noir-btn-solid inline-flex items-center justify-center gap-2">{{ pText }}</a>{% endif %}
{% if sText and sUrl %}<a href="{{ sUrl }}" class="noir-link inline-flex items-center justify-center">{{ sText }}</a>{% endif %}
</div>{% endif %}
{% if trustLogos %}<div class="mt-10">
<p class="noir-label mb-3" style="color:hsl(var(--muted-foreground));">Trusted by</p>
<div class="flex flex-wrap items-center gap-6{% if align == "center" %} justify-center{% endif %}">{% for logo in trustLogos %}{% if logo.url %}<a href="{{ logo.url }}" target="_blank" rel="noopener noreferrer" class="opacity-60 transition-opacity hover:opacity-100">{% img logo.image alt=logo.alt class="h-8 w-auto" %}</a>{% else %}{% img logo.image alt=logo.alt class="h-8 w-auto opacity-60" %}{% endif %}{% endfor %}</div>
</div>{% endif %}
{% endmacro %}
{% if variant == "split" %}
<section data-block-override="noir:hero" class="hero {% if hasImage %}noir-split{% endif %} overflow-hidden bg-background text-foreground noir-grain{% if class %} {{ class }}{% endif %}">
{% if hasImage %}<div class="relative bg-muted grayscale" style="min-height:20rem;">{% img backgroundImage alt=headlineText class="absolute inset-0 h-full w-full object-cover" layout="hero" %}</div>{% endif %}
<div class="flex flex-col justify-center noir-hero-pad px-6 lg:px-12"><div class="max-w-lg text-left">{{ herocontent("left") }}</div></div>
</section>
{% else %}
{% set align = "center" %}
{% if variant == "left-aligned" %}{% set align = "left" %}{% endif %}
<section data-block-override="noir:hero" class="hero relative flex flex-col overflow-hidden bg-background text-foreground noir-grain {% if minHeight == "screen" %}min-h-screen justify-center{% else %}noir-hero-pad{% endif %}{% if class %} {{ class }}{% endif %}">
{% if hasImage %}<div class="absolute inset-0 z-0 noir-spotlight grayscale">{% img backgroundImage alt=headlineText class="h-full w-full object-cover" layout="hero" %}{% if showOverlay %}<div class="absolute inset-0 {% if overlayColor == "light" %}bg-background/60{% elif overlayColor == "primary" %}bg-primary/50{% elif overlayOpacity <= 30 %}bg-black/30{% elif overlayOpacity <= 50 %}bg-black/50{% elif overlayOpacity <= 70 %}bg-black/70{% else %}bg-black/80{% endif %}"></div>{% endif %}</div>{% endif %}
<div class="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 lg:px-8 {% if variant == "left-aligned" %}text-left{% else %}text-center{% endif %}">{{ herocontent(align) }}</div>
</section>
{% endif %}

View File

@ -0,0 +1,31 @@
{# noir override — hours-location; preserves heading, hours[].day/.hours/.closed, specialHoursNote, address, phone, directionsUrl, mapEmbedUrl, mapButtonLabel. Map facade <button onclick> kept VERBATIM. #}
<section data-block-override="noir:hours-location" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-5xl px-4">
{% if heading %}<h2 class="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
<div class="mt-10 noir-split">
<div class="noir-file p-6 md:p-8">
{% if hours %}
<table class="w-full text-sm">
<tbody class="divide-y divide-border">
{% for row in hours %}
<tr>
<th scope="row" class="noir-label py-2 pr-4 text-left" style="color:hsl(var(--card-foreground));">{{ row.day }}</th>
<td class="noir-mono py-2 text-right tabular-nums {% if row.closed %}italic{% endif %}" style="{% if row.closed %}color:hsl(var(--muted-foreground));{% else %}color:hsl(var(--primary));{% endif %}">{% if row.closed %}Closed{% else %}{{ row.hours }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if specialHoursNote %}<p class="noir-body mt-4 border-t border-border pt-3 text-sm" style="color:hsl(var(--muted-foreground));">{{ specialHoursNote }}</p>{% endif %}
{% if address %}<div class="mt-6"><div class="noir-label" style="color:hsl(var(--primary));">Address</div><address class="noir-body mt-1 whitespace-pre-line not-italic" style="color:hsl(var(--card-foreground));">{{ address }}</address></div>{% endif %}
{% if phone %}<div class="mt-4"><div class="noir-label" style="color:hsl(var(--primary));">Phone</div><a href="tel:{{ phone }}" class="noir-link mt-1 inline-block">{{ phone }}</a></div>{% endif %}
{% if directionsUrl %}<a href="{{ directionsUrl }}" target="_blank" rel="noopener noreferrer" class="noir-btn noir-btn-solid mt-6 inline-flex items-center gap-2"><svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#navigation"></use></svg>Get directions</a>{% endif %}
</div>
{% if mapEmbedUrl %}
<div class="aspect-square w-full overflow-hidden noir-file md:aspect-auto">
<button type="button" data-map-src="{{ mapEmbedUrl }}" onclick="var f=document.createElement('iframe');f.src=this.getAttribute('data-map-src');f.setAttribute('class','h-full w-full border-0');f.setAttribute('loading','lazy');f.setAttribute('referrerpolicy','no-referrer-when-downgrade');f.setAttribute('title','Map');this.parentNode.replaceChild(f,this);" class="noir-label flex h-full min-h-[16rem] w-full items-center justify-center gap-2 transition-colors hover:bg-accent hover:text-accent-foreground" style="color:hsl(var(--primary));"><svg class="h-5 w-5" aria-hidden="true"><use href="/icons/lucide.svg#map-pin"></use></svg>{{ mapButtonLabel|default:"Load map" }}</button>
</div>
{% endif %}
</div>
</div>
</section>

View File

@ -1 +1,11 @@
<figure class="noir-figure bleed">{% if src %}<img src="{{ src }}" alt="{{ alt }}" loading="lazy" class="w-full h-auto block">{% endif %}{% if caption %}<figcaption class="tracked-mono mt-2 px-6" style="color: hsl(var(--mutedForeground));">{{ caption }}</figcaption>{% elif title %}<figcaption class="tracked-mono mt-2 px-6" style="color: hsl(var(--mutedForeground));">{{ title }}</figcaption>{% endif %}</figure>
{# image override — square evidence frame with a typed mono caption. Fields mirror builtin: src, alt, caption, size, align, aspectRatio, objectFit. Media resolved via {% img %} (the media filter is not registered in the block render path). #}
{% with size=size|default:"full" align=align|default:"center" objectFit=objectFit|default:"cover" %}
<figure data-block-override="noir:image" class="my-8 flex-1 {% if align == "left" %}flex justify-start{% elif align == "right" %}flex justify-end{% else %}flex justify-center{% endif %}{% if class %} {{ class }}{% endif %}">
<div class="noir-file p-2 {% if size == "xs" %}max-w-[200px] w-full{% elif size == "sm" %}max-w-[400px] w-full{% elif size == "md" %}max-w-[600px] w-full{% elif size == "lg" %}max-w-[800px] w-full{% else %}w-full{% endif %}">
<div class="relative overflow-hidden bg-muted {% if aspectRatio == "square" %}aspect-square{% elif aspectRatio == "video" %}aspect-video{% elif aspectRatio == "portrait" %}aspect-[3/4]{% elif aspectRatio == "wide" %}aspect-[21/9]{% endif %}">
{% if objectFit == "contain" %}{% img src alt=alt class="w-full h-full object-contain" layout=size %}{% elif objectFit == "fill" %}{% img src alt=alt class="w-full h-full object-fill" layout=size %}{% elif objectFit == "none" %}{% img src alt=alt class="w-full h-full object-none" layout=size %}{% else %}{% img src alt=alt class="w-full h-full object-cover" layout=size %}{% endif %}
</div>
</div>
{% if caption %}<figcaption class="mt-3 text-center noir-mono text-sm" style="color:hsl(var(--muted-foreground));">{{ caption }}</figcaption>{% endif %}
</figure>
{% endwith %}

View File

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

View File

@ -0,0 +1,29 @@
{# noir override — menu-list; preserves heading, intro, columns, sections[].title/.note/.items[].name/.badges/.description/.price, currency, footnote. #}
<section data-block-override="noir:menu-list" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-5xl px-4">
{% if heading %}<h2 class="noir-heading text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="noir-body mt-3 max-w-2xl text-lg" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
<div class="mt-10 noir-cols noir-gap-lg {% if columns == 2 %}noir-cols-2{% endif %}">
{% for section in sections %}
<div class="menu-section">
{% if section.title %}<h3 class="noir-label noir-rule pb-2 text-sm" style="color:hsl(var(--primary));">{{ section.title }}</h3>{% endif %}
{% if section.note %}<p class="noir-body mt-2 text-sm" style="color:hsl(var(--muted-foreground));">{{ section.note }}</p>{% endif %}
<ul class="mt-4 space-y-5">
{% for item in section.items %}
<li>
<div class="flex items-baseline gap-3">
<span class="noir-heading shrink-0" style="color:hsl(var(--foreground));">{{ item.name }}</span>
<span class="min-w-6 flex-1 translate-y-[-2px] border-b border-dotted border-[hsl(var(--border))]" aria-hidden="true"></span>
{% if item.price %}<span class="noir-mono shrink-0 tabular-nums" style="color:hsl(var(--primary));">{% if currency %}{{ currency }}{% endif %}{{ item.price }}</span>{% endif %}
</div>
{% if item.badges %}<div class="mt-1 flex flex-wrap gap-1.5">{% for badge in item.badges %}{% if badge.label %}<span class="noir-label inline-block border border-border px-2 py-0.5 text-[0.65rem]" style="color:hsl(var(--muted-foreground));">{{ badge.label }}</span>{% endif %}{% endfor %}</div>{% endif %}
{% if item.description %}<p class="noir-body mt-1 text-sm" style="color:hsl(var(--muted-foreground));">{{ item.description }}</p>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% if footnote %}<p class="noir-mono mt-10 text-xs" style="color:hsl(var(--muted-foreground));">{{ footnote }}</p>{% endif %}
</div>
</section>

View File

@ -0,0 +1,104 @@
{# noir override — navbar; preserves variant/stickyBehavior/megaMenu/logoType/logoText/menu.items/cta vars, fixed drawer ids (bn-navbar-toggle/-drawer/-overlay), all data-* attrs, <style> + focus-trap <script> verbatim. #}
{% set nv = variant %}
{% if not nv %}{% if template == "centered" %}{% set nv = "centered-logo" %}{% elif template == "transparent" %}{% set nv = "transparent-over-hero" %}{% else %}{% set nv = "standard" %}{% endif %}{% endif %}
{% set sb = stickyBehavior %}
{% if not sb %}{% if sticky %}{% set sb = "sticky" %}{% else %}{% set sb = "none" %}{% endif %}{% endif %}
{% set use_mega = megaMenu or template == "mega" %}
{% set is_transparent = nv == "transparent-over-hero" %}
{% set cta_text = cta.text|default:ctaLabel %}
{% set cta_url = cta.url|default:ctaUrl %}
{% set bg = backgroundColor|default:"bg-background" %}
{% macro navlink(item) %}<a href="{{ item.url|default:'#' }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %}{% if item.title %} title="{{ item.title }}"{% endif %} class="noir-label px-3 py-2 text-foreground/80 hover:text-primary transition-colors {{ item.css_class }}">{{ item.label }}</a>{% endmacro %}
{% macro dropdown(item) %}<div class="relative group">
<button type="button" class="noir-label px-3 py-2 text-foreground/80 hover:text-primary transition-colors inline-flex items-center gap-1">{{ item.label }}<svg class="h-4 w-4 transition-transform group-hover:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg></button>
{% if use_mega %}<div class="absolute left-1/2 -translate-x-1/2 mt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50"><div class="noir-file p-6 grid gap-8" style="grid-template-columns:repeat({{ item.children|length|default:1 }},minmax(10rem,1fr));min-width:min(90vw,42rem)">{% for child in item.children %}<div class="space-y-1">{% if child.item_type == "header" %}<div class="noir-label px-2 py-1 border-b border-border mb-2" style="color:hsl(var(--primary));">{{ child.label }}</div>{% else %}<a href="{{ child.url|default:'#' }}"{% if child.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="block px-2 py-2 hover:bg-accent transition-colors {{ child.css_class }}">{% if child.icon %}<span class="mr-1.5 text-primary">::{{ child.icon }}::</span>{% endif %}<span class="noir-heading text-sm text-foreground">{{ child.label }}</span>{% if child.description %}<span class="noir-body block text-xs text-muted-foreground mt-0.5">{{ child.description }}</span>{% endif %}</a>{% endif %}</div>{% endfor %}</div></div>
{% else %}<div class="absolute left-0 mt-1 w-56 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50"><div class="noir-file py-2">{% for child in item.children %}{% if child.item_type == "header" %}<div class="noir-label px-4 py-2" style="color:hsl(var(--primary));">{{ child.label }}</div>{% else %}<a href="{{ child.url|default:'#' }}"{% if child.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="noir-body block px-4 py-2 text-sm text-foreground hover:bg-accent transition-colors {{ child.css_class }}">{{ child.label }}</a>{% endif %}{% endfor %}</div></div>{% endif %}
</div>{% endmacro %}
{% macro brand(size) %}<a href="/" class="flex-shrink-0 flex items-center">{% if logoType == "text" and logoText.text %}<span class="noir-heading text-xl {{ logoText.color }}" style="color:hsl(var(--foreground));">{{ logoText.text }}</span>{% elif logo %}<img src="{{ logo }}" alt="{{ alt|default:companyName }}" class="{{ size }} w-auto"{% if logoMaxWidth or logoMaxHeight %} style="{% if logoMaxWidth %}max-width:{{ logoMaxWidth }};{% endif %}{% if logoMaxHeight %}max-height:{{ logoMaxHeight }};{% endif %}"{% endif %}/>{% elif companyName %}<span class="noir-heading text-xl" style="color:hsl(var(--foreground));">{{ companyName }}</span>{% endif %}</a>{% endmacro %}
{% macro ctabtn(extra) %}{% if cta_text %}<a href="{{ cta_url|default:'#' }}" class="noir-btn noir-btn-solid inline-flex items-center {{ extra }}">{{ cta_text }}</a>{% endif %}{% endmacro %}
<nav data-block-override="noir:navbar" data-bn-navbar data-bn-nav-variant="{{ nv }}" data-bn-sticky="{{ sb }}"{% if is_transparent %} data-bn-transparent="1"{% endif %} class="bn-navbar w-full {% if sb == "sticky" or sb == "shrink-on-scroll" %}sticky top-0 z-40 {% endif %}{% if is_transparent %}absolute top-0 left-0 z-40 text-white{% else %}{{ bg }}{% if borderBottom %} border-b border-border{% endif %}{% endif %} {{ class }}">
{% if showUtilityBar %}<div class="w-full text-xs {% if not showOnMobile %}hidden sm:block{% endif %} bg-muted/60 text-muted-foreground border-b border-border/60"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between h-8"><div class="noir-mono">{{ utilityBarText|safe }}</div><div class="flex items-center gap-4">{% if utilityBarPhone %}<a href="tel:{{ utilityBarPhone }}" class="noir-mono hover:text-foreground transition-colors">{{ utilityBarPhone }}</a>{% endif %}{% if utilityBarEmail %}<a href="mailto:{{ utilityBarEmail }}" class="noir-mono hover:text-foreground transition-colors">{{ utilityBarEmail }}</a>{% endif %}</div></div></div>{% endif %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bn-navbar-row flex items-center justify-between h-16">
{% if nv == "centered-logo" %}
<div class="hidden md:flex items-center gap-1 flex-1">{% for item in menu.items %}{% if item.children %}{{ dropdown(item) }}{% else %}{{ navlink(item) }}{% endif %}{% endfor %}</div>
<div class="flex items-center justify-center">{{ brand("h-9") }}</div>
<div class="hidden md:flex items-center gap-3 flex-1 justify-end">{{ ctabtn("") }}</div>
{% elif nv == "split" %}
<div class="hidden md:flex items-center gap-1 flex-1">{% for item in menu.items %}{% if forloop.Counter0 * 2 < menu.items|length %}{% if item.children %}{{ dropdown(item) }}{% else %}{{ navlink(item) }}{% endif %}{% endif %}{% endfor %}</div>
<div class="flex items-center justify-center">{{ brand("h-9") }}</div>
<div class="hidden md:flex items-center gap-1 flex-1 justify-end">{% for item in menu.items %}{% if forloop.Counter0 * 2 >= menu.items|length %}{% if item.children %}{{ dropdown(item) }}{% else %}{{ navlink(item) }}{% endif %}{% endif %}{% endfor %}{{ ctabtn("ml-3") }}</div>
{% else %}
<div class="flex items-center">{{ brand("h-8") }}</div>
<div class="hidden md:flex items-center gap-1">{% for item in menu.items %}{% if item.children %}{{ dropdown(item) }}{% else %}{{ navlink(item) }}{% endif %}{% endfor %}{{ ctabtn("ml-3") }}</div>
{% endif %}
<div class="flex items-center md:hidden">
<label for="bn-navbar-toggle" data-bn-nav-open aria-controls="bn-navbar-drawer" aria-expanded="false" class="inline-flex items-center justify-center p-2 rounded-none cursor-pointer hover:bg-accent/50 transition-colors"><span class="sr-only">Open menu</span><svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></label>
</div>
</div>
</div>
{# --- Mobile off-canvas drawer: pure-CSS checkbox baseline (works with no JS) --- #}
<input type="checkbox" id="bn-navbar-toggle" class="bn-navbar-toggle" aria-hidden="true"/>
<label for="bn-navbar-toggle" class="bn-navbar-overlay fixed inset-0 bg-black/50 z-40 md:hidden" aria-hidden="true"></label>
<div id="bn-navbar-drawer" class="bn-navbar-drawer fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-background text-foreground z-50 shadow-xl md:hidden flex flex-col border-l border-border" role="dialog" aria-modal="true" aria-label="Site menu">
<div class="flex items-center justify-between p-4 border-b border-border">{{ brand("h-8") }}<label for="bn-navbar-toggle" data-bn-nav-close class="p-2 rounded-none cursor-pointer text-muted-foreground hover:text-primary hover:bg-accent"><span class="sr-only">Close menu</span><svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></label></div>
<nav class="flex-1 overflow-y-auto py-4" aria-label="Mobile">{% for item in menu.items %}{% if item.children %}<details class="group/acc"><summary class="noir-label flex items-center justify-between px-6 py-3 cursor-pointer hover:bg-accent transition-colors list-none">{{ item.label }}<svg class="h-4 w-4 transition-transform group-open/acc:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg></summary><div class="pl-8 pb-2">{% for child in item.children %}{% if child.item_type != "header" %}<a href="{{ child.url|default:'#' }}"{% if child.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="noir-body block px-2 py-2 text-sm text-muted-foreground hover:text-primary transition-colors">{{ child.label }}</a>{% endif %}{% endfor %}</div></details>{% else %}<a href="{{ item.url|default:'#' }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %} class="noir-label block px-6 py-3 hover:bg-accent transition-colors">{{ item.label }}</a>{% endif %}{% endfor %}</nav>
{% if cta_text %}<div class="p-4 border-t border-border"><a href="{{ cta_url|default:'#' }}" class="noir-btn noir-btn-solid block w-full text-center">{{ cta_text }}</a></div>{% endif %}
</div>
</nav>
<style>
.bn-navbar-toggle{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;}
.bn-navbar-drawer{transform:translateX(100%);transition:transform .3s ease;}
.bn-navbar-overlay{opacity:0;pointer-events:none;transition:opacity .3s ease;}
.bn-navbar-toggle:checked ~ .bn-navbar-drawer{transform:translateX(0);}
.bn-navbar-toggle:checked ~ .bn-navbar-overlay{opacity:1;pointer-events:auto;}
.bn-navbar-drawer details > summary::-webkit-details-marker{display:none;}
[data-bn-sticky="shrink-on-scroll"].bn-navbar-shrunk .bn-navbar-row{height:3rem;}
[data-bn-sticky="shrink-on-scroll"] .bn-navbar-row{transition:height .2s ease;}
[data-bn-transparent].bn-navbar-solid{position:sticky;color:inherit;background:hsl(var(--background));border-bottom:1px solid hsl(var(--border));}
</style>
<script>
(function(){
if(window.__bnNavInit)return;window.__bnNavInit=1;
function ready(fn){if(document.readyState!='loading')fn();else document.addEventListener('DOMContentLoaded',fn);}
ready(function(){
document.querySelectorAll('[data-bn-navbar]').forEach(function(nav){
var toggle=nav.querySelector('.bn-navbar-toggle');
var drawer=nav.querySelector('.bn-navbar-drawer');
var opener=nav.querySelector('[data-bn-nav-open]');
if(toggle&&drawer){
function focusables(){return drawer.querySelectorAll('a[href],button,label[for],input,summary,[tabindex]:not([tabindex="-1"])');}
toggle.addEventListener('change',function(){
var open=toggle.checked;
document.documentElement.style.overflow=open?'hidden':'';
document.body.style.overflow=open?'hidden':'';
if(opener)opener.setAttribute('aria-expanded',open?'true':'false');
if(open){var f=focusables();if(f.length)f[0].focus();}
else if(opener&&opener.focus)opener.focus();
});
drawer.addEventListener('keydown',function(e){
if(e.key==='Escape'){toggle.checked=false;toggle.dispatchEvent(new Event('change'));return;}
if(e.key!=='Tab')return;
var f=focusables();if(!f.length)return;
var first=f[0],last=f[f.length-1];
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
});
drawer.querySelectorAll('a[href]').forEach(function(a){a.addEventListener('click',function(){toggle.checked=false;toggle.dispatchEvent(new Event('change'));});});
}
var sb=nav.getAttribute('data-bn-sticky');
var transparent=nav.getAttribute('data-bn-transparent')==='1';
if(sb==='shrink-on-scroll'||transparent){
var overHero=false;
if(transparent){var next=nav.nextElementSibling;overHero=!!next&&next.getBoundingClientRect().height>=240;if(!overHero)nav.classList.add('bn-navbar-solid');}
var onScroll=function(){
var y=window.scrollY||window.pageYOffset;
if(sb==='shrink-on-scroll')nav.classList.toggle('bn-navbar-shrunk',y>40);
if(transparent&&overHero)nav.classList.toggle('bn-navbar-solid',y>40);
};
window.addEventListener('scroll',onScroll,{passive:true});onScroll();
}
});
});
})();
</script>

View File

@ -0,0 +1,10 @@
{# noir override — newsletter-signup; preserves {% signup_form %} wiring verbatim (list/source/submit/success_message/placeholder), layout/title/description. #}
<section data-block-override="noir:newsletter-signup" class="py-12 bg-background text-foreground md:py-16{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-3xl px-4">
<div class="noir-file {% if layout == "inline" %}noir-grain px-6 py-12 md:px-12{% else %}bg-card px-6 py-12 text-card-foreground md:px-10{% endif %} text-center"{% if layout == "inline" %} style="background-color:hsl(var(--primary));color:hsl(var(--primary-foreground));"{% endif %}>
{% if title %}<h2 class="noir-heading text-2xl md:text-3xl">{{ title }}</h2>{% endif %}
{% if description %}<p class="noir-body mx-auto mt-4 max-w-xl {% if layout == "inline" %}text-primary-foreground/80{% else %}text-muted-foreground{% endif %}">{{ description }}</p>{% endif %}
<div class="mx-auto mt-6 max-w-md">{% signup_form list=listSlug source=source submit=submitText success_message=successMessage placeholder=placeholder %}</div>
</div>
</div>
</section>

View File

@ -0,0 +1,21 @@
{# noir override — page-suggestions; preserves suggestions[].slug/.title/.excerpt, title/showDescription/emptyMessage. #}
{% if suggestions %}
<div data-block-override="noir:page-suggestions" class="page-suggestions py-10">
{% if title %}<h2 class="noir-label mx-auto w-fit text-center" style="color:hsl(var(--foreground));">{{ title }}</h2>{% endif %}
<ul class="mt-6 space-y-3 max-w-lg mx-auto">
{% for suggestion in suggestions %}
<li class="flex items-start gap-3">
<span class="mt-0.5 shrink-0" style="color:hsl(var(--primary));">::lucide:chevron-right:sm::</span>
<div>
<a href="{{ suggestion.slug }}" class="noir-link font-medium">{{ suggestion.title }}</a>
{% if showDescription and suggestion.excerpt %}<p class="noir-body mt-1 text-sm" style="color:hsl(var(--muted-foreground));">{{ suggestion.excerpt }}</p>{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
{% elif emptyMessage %}
<div data-block-override="noir:page-suggestions" class="page-suggestions py-10 text-center">
<p class="noir-body" style="color:hsl(var(--muted-foreground));">{{ emptyMessage }}</p>
</div>
{% endif %}

View File

@ -0,0 +1,13 @@
{# noir override — password-reset-form; preserves #reset-message, /api/auth/request-password-reset (email) HTMX wiring, #request-reset-panel, hx-disabled-elt, back-to-sign-in link. #}
<div data-block-override="noir:password-reset-form" class="password-reset-form noir-file mx-auto max-w-md bg-card p-8">
<div id="reset-message"></div>
<div id="request-reset-panel" class="text-center">
<h2 class="noir-heading text-2xl" style="color:hsl(var(--foreground));">Reset password</h2>
<p class="noir-body mt-3 text-sm" style="color:hsl(var(--muted-foreground));">Enter your email and we will send a link to reset your password.</p>
<form hx-post="/api/auth/request-password-reset" hx-target="#reset-message" hx-swap="innerHTML" class="mt-6 text-left">
<div class="mb-4"><label class="noir-label block mb-1" style="color:hsl(var(--primary));">Email</label><input type="email" name="email" required class="w-full rounded-none border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:border-[hsl(var(--primary))] focus:ring-1 focus:ring-ring" /></div>
<button type="submit" class="noir-btn noir-btn-solid w-full" hx-disabled-elt="this">Send reset link</button>
</form>
<p class="text-center mt-4 text-sm"><a href="/login" class="noir-link">Back to sign in</a></p>
</div>
</div>

View File

@ -0,0 +1,16 @@
{# noir override — popular-posts; preserves posts provider loop, heading, showRank/showThumbnail, forloop.Counter, post.featured_image_url/.url/.title/.published_at. {% img %} kept verbatim. #}
<section data-block-override="noir:popular-posts" class="bg-background text-foreground{% if class %} {{ class }}{% endif %}">
{% if heading %}<h2 class="noir-label mb-5" style="color:hsl(var(--primary));">{{ heading }}</h2>{% endif %}
<ol class="space-y-4">
{% for post in posts %}
<li class="noir-rule flex items-start gap-3 pt-4">
{% if showRank %}<span class="noir-heading mt-0.5 text-lg tabular-nums" style="color:hsl(var(--primary));">{{ forloop.Counter }}</span>{% endif %}
{% if showThumbnail and post.featured_image_url %}<a href="{{ post.url }}" class="h-12 w-12 flex-shrink-0 overflow-hidden bg-muted grayscale">{% img post.featured_image_url alt=post.title class="h-full w-full object-cover" %}</a>{% endif %}
<div class="min-w-0">
<h3 class="noir-heading text-sm leading-snug" style="color:hsl(var(--foreground));"><a href="{{ post.url }}" class="transition-colors hover:text-primary">{{ post.title }}</a></h3>
{% if post.published_at %}<p class="noir-label mt-1" style="color:hsl(var(--muted-foreground));">{{ post.published_at|date:"M j, Y" }}</p>{% endif %}
</div>
</li>
{% endfor %}
</ol>
</section>

View File

@ -0,0 +1,20 @@
{# noir override — post-hero; preserves post.* reads, {% img %}, showExcerpt/showMeta/align, featured-image + no-image branches. #}
{% set center = align != "left" %}
{% if post.featured_image_url %}
<section data-block-override="noir:post-hero" class="hero relative flex min-h-[50vh] flex-col justify-end overflow-hidden bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="absolute inset-0 z-0">{% img post.featured_image_url alt=post.featured_image_alt class="h-full w-full object-cover grayscale" layout="hero" %}<div class="absolute inset-0 bg-black/70"></div><div class="noir-grain absolute inset-0"></div></div>
<div class="relative z-10 mx-auto w-full max-w-4xl px-4 py-14 sm:px-6 lg:px-8 {% if center %}text-center{% endif %}">
<h1 class="noir-heading text-3xl leading-tight text-white sm:text-4xl lg:text-5xl">{{ post.title }}</h1>
{% if showExcerpt and post.excerpt %}<p class="noir-body mt-5 max-w-2xl text-lg text-white/80{% if center %} mx-auto{% endif %}">{{ post.excerpt }}</p>{% endif %}
{% if showMeta %}<div class="noir-label mt-6 flex flex-wrap items-center gap-x-3 gap-y-1 text-white/80{% if center %} justify-center{% endif %}">{% if post.author_name %}<span class="text-white">{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span>{{ post.published_at|date:"F j, Y" }}</span>{% endif %}{% if post.reading_time %}<span>{{ post.reading_time }} min read</span>{% endif %}</div>{% endif %}
</div>
</section>
{% else %}
<section data-block-override="noir:post-hero" class="bg-background py-16 text-foreground md:py-20{% if class %} {{ class }}{% endif %}">
<div class="mx-auto w-full max-w-3xl px-4 {% if center %}text-center{% endif %}">
<h1 class="noir-heading text-3xl leading-tight sm:text-4xl lg:text-5xl" style="color:hsl(var(--foreground));">{{ post.title }}</h1>
{% if showExcerpt and post.excerpt %}<p class="noir-body mt-5 text-lg text-muted-foreground{% if center %} mx-auto max-w-2xl{% endif %}">{{ post.excerpt }}</p>{% endif %}
{% if showMeta %}<div class="noir-label mt-6 flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground{% if center %} justify-center{% endif %}">{% if post.author_name %}<span style="color:hsl(var(--primary));">{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span>{{ post.published_at|date:"F j, Y" }}</span>{% endif %}{% if post.reading_time %}<span>{{ post.reading_time }} min read</span>{% endif %}</div>{% endif %}
</div>
</section>
{% endif %}

View File

@ -0,0 +1,9 @@
{# noir override — post-metadata; preserves post.author_name/author_avatar/published_at/reading_time/categories, showAuthor/showAvatar/showDate/showReadingTime, {% img %}. #}
{% if post %}
<div data-block-override="noir:post-metadata" class="noir-label flex flex-wrap items-center gap-x-4 gap-y-2 text-muted-foreground{% if class %} {{ class }}{% endif %}">
{% if showAuthor and post.author_name %}<span class="flex items-center gap-2">{% if showAvatar and post.author_avatar %}{% img post.author_avatar alt=post.author_name class="h-7 w-7 rounded-full object-cover grayscale" %}{% endif %}<span style="color:hsl(var(--primary));">{{ post.author_name }}</span></span>{% endif %}
{% if showDate and post.published_at %}<time datetime="{{ post.published_at|date:"Y-m-d" }}">{{ post.published_at|date:"F j, Y" }}</time>{% endif %}
{% if showReadingTime and post.reading_time %}<span>{{ post.reading_time }} min read</span>{% endif %}
{% if post.categories %}<span class="flex flex-wrap gap-1.5">{% for cat in post.categories %}<a href="{{ cat.url|default:'#' }}" class="noir-label rounded-none border border-border px-2 py-0.5 text-muted-foreground transition-colors hover:text-primary">{{ cat.name }}</a>{% endfor %}</span>{% endif %}
</div>
{% endif %}

View File

@ -0,0 +1,26 @@
{# noir override — pricing; preserves heading, intro, tiers[].highlighted/.badge/.name/.price/.period/.description/.features/.ctaUrl/.ctaLabel, currency, billingNote. Check icon markup verbatim. #}
<section data-block-override="noir:pricing" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="noir-heading text-center text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="noir-body mx-auto mt-4 max-w-2xl text-center text-lg" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
<div class="mt-10 noir-cols noir-cols-3">
{% for tier in tiers %}
<div class="relative flex flex-col p-6 {% if tier.highlighted %}noir-file ring-1 ring-[hsl(var(--primary))]{% else %}noir-file{% endif %}">
{% if tier.badge %}<span class="noir-label absolute -top-3 left-1/2 -translate-x-1/2 bg-primary px-3 py-1 text-primary-foreground">{{ tier.badge }}</span>{% endif %}
<h3 class="noir-heading text-center text-lg" style="color:hsl(var(--foreground));">{{ tier.name }}</h3>
<div class="mx-auto my-3 flex items-center justify-center"><span class="noir-rule w-12"></span></div>
<p class="flex items-baseline justify-center gap-1">
<span class="noir-mono text-4xl font-bold tracking-tight" style="color:hsl(var(--primary));">{% if currency %}{{ currency }}{% else %}${% endif %}{{ tier.price }}</span>
{% if tier.period %}<span class="noir-mono text-sm" style="color:hsl(var(--muted-foreground));">{{ tier.period }}</span>{% endif %}
</p>
{% if tier.description %}<p class="noir-body mt-3 text-center text-sm" style="color:hsl(var(--muted-foreground));">{{ tier.description }}</p>{% endif %}
{% if tier.features %}<ul class="noir-body mt-6 flex-1 space-y-3 text-sm">
{% for feature in tier.features %}<li class="flex items-start gap-2"><svg class="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true"><use href="/icons/lucide.svg#check"></use></svg><span>{{ feature }}</span></li>{% endfor %}
</ul>{% endif %}
{% if tier.ctaUrl and tier.ctaLabel %}{% if tier.highlighted %}<a href="{{ tier.ctaUrl }}" class="noir-btn noir-btn-solid mt-8 w-full">{{ tier.ctaLabel }}</a>{% else %}<a href="{{ tier.ctaUrl }}" class="noir-btn mt-8 w-full">{{ tier.ctaLabel }}</a>{% endif %}{% endif %}
</div>
{% endfor %}
</div>
{% if billingNote %}<p class="noir-body mt-8 text-center text-sm" style="color:hsl(var(--muted-foreground));">{{ billingNote }}</p>{% endif %}
</div>
</section>

View File

@ -0,0 +1,2 @@
{# noir override — quote; preserves quote (quote|safe), style (block/pull/inline-card), align, cite, attribution, role. #}
{% with style=style|default:"block" align=align|default:"left" %}<figure data-block-override="noir:quote" class="bn-quote my-8 {% if style == "inline-card" %}noir-file p-6 md:p-8{% elif style == "pull" %}border-l-4 border-[hsl(var(--primary))] pl-6 md:pl-8 py-2{% else %}border-l-2 border-border pl-5 py-1{% endif %}{% if align == "center" %} text-center{% endif %}{% if class %} {{ class }}{% endif %}"><blockquote{% if cite %} cite="{{ cite }}"{% endif %} class="noir-body {% if style == "pull" %}text-2xl md:text-3xl leading-snug{% else %}text-lg md:text-xl leading-relaxed{% endif %}" style="color:hsl(var(--foreground));">{{ quote|safe }}</blockquote>{% if attribution or role or cite %}<figcaption class="mt-4 text-sm{% if align == "center" %} flex flex-col items-center{% endif %}" style="color:hsl(var(--muted-foreground));">{% if attribution %}<span class="noir-label" style="color:hsl(var(--primary));">{{ attribution }}</span>{% endif %}{% if role %}<span class="noir-body {% if attribution %}block {% endif %}" style="color:hsl(var(--muted-foreground));">{{ role }}</span>{% endif %}{% if cite %}<a href="{{ cite }}" class="noir-link {% if attribution or role %}mt-1 {% endif %}inline-block text-xs">Source</a>{% endif %}</figcaption>{% endif %}</figure>{% endwith %}

View File

@ -0,0 +1,17 @@
{# noir override — related-posts; preserves posts provider loop, heading, columns, post.featured_image_url/.url/.title/.published_at. {% img %} kept verbatim. #}
{% if posts %}
<section data-block-override="noir:related-posts" class="noir-rule py-12 md:py-16 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
<h2 class="noir-heading mb-8 text-2xl md:text-3xl" style="color:hsl(var(--foreground));">{{ heading|default:"Related posts" }}</h2>
<div class="noir-cols {% if columns == 2 %}noir-cols-2{% else %}noir-cols-3{% endif %}">
{% for post in posts %}
<article class="group flex flex-col">
{% if post.featured_image_url %}<a href="{{ post.url }}" class="mb-3 block aspect-video overflow-hidden bg-muted grayscale">{% img post.featured_image_url alt=post.title class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
<h3 class="noir-heading text-base leading-snug" style="color:hsl(var(--foreground));"><a href="{{ post.url }}" class="transition-colors hover:text-primary">{{ post.title }}</a></h3>
{% if post.published_at %}<p class="noir-label mt-1" style="color:hsl(var(--muted-foreground));">{{ post.published_at|date:"M j, Y" }}</p>{% endif %}
</article>
{% endfor %}
</div>
</div>
</section>
{% endif %}

View File

@ -0,0 +1,14 @@
{# noir override — rich-section; preserves background, mediaPosition, eyebrow, heading, body, ctaUrl/ctaLabel, media/mediaAlt, {% img %} + icon markup verbatim. #}
<section data-block-override="noir:rich-section" class="py-16 md:py-24 {% if background == "muted" %}bg-muted text-foreground{% elif background == "card" %}bg-card text-card-foreground{% else %}bg-background text-foreground{% endif %}{% if class %} {{ class }}{% endif %}">
<div class="mx-auto noir-split items-center max-w-6xl px-4">
<div class="{% if mediaPosition == "left" %}md:order-2{% else %}md:order-1{% endif %}">
{% if eyebrow %}<div class="mb-3 flex items-center gap-4"><span class="noir-rule w-12"></span><p class="noir-label" style="color:hsl(var(--primary));">{{ eyebrow }}</p></div>{% endif %}
{% if heading %}<h2 class="noir-heading mt-2 text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if body %}<div class="noir-body mt-4 space-y-4 text-lg text-muted-foreground [&_a]:text-primary [&_a]:underline [&_strong]:text-foreground">{{ body|safe }}</div>{% endif %}
{% if ctaUrl and ctaLabel %}<a href="{{ ctaUrl }}" class="noir-btn noir-btn-solid mt-6 inline-flex items-center gap-2">{{ ctaLabel }}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#arrow-right"></use></svg></a>{% endif %}
</div>
<div class="{% if mediaPosition == "left" %}md:order-1{% else %}md:order-2{% endif %}">
{% if media %}<div class="noir-file p-2 overflow-hidden"><div class="overflow-hidden bg-muted grayscale">{% img media alt=mediaAlt class="w-full h-auto object-cover" %}</div></div>{% endif %}
</div>
</div>
</section>

View File

@ -0,0 +1,2 @@
{# noir override — rich-text; preserves field html (html|safe). Source Serif prose column with evidence-red [&_a] links. #}
{% if html %}<article data-block-override="noir:rich-text" class="rich-text noir-body prose prose-lg max-w-none [&_a]:text-primary [&_a]:no-underline [&_a]:border-b [&_a]:border-[hsl(var(--primary))] [&_a:hover]:text-accent [&_strong]:text-foreground" style="color:hsl(var(--foreground));line-height:1.75;">{{ html|safe }}</article>{% endif %}

View File

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

View File

@ -0,0 +1,14 @@
{# noir override — social-links; preserves heading, style, links[].url/.label/.network. Icon <svg><use> split markup kept verbatim. #}
<div data-block-override="noir:social-links" class="social-links{% if class %} {{ class }}{% endif %}">
{% if heading %}<p class="noir-label mb-4" style="color:hsl(var(--primary));">{{ heading }}</p>{% endif %}
<ul class="flex {% if style == "stack" %}flex-col gap-2{% else %}flex-wrap items-center gap-3{% endif %}">
{% for link in links %}{% if link.url %}
<li>
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" aria-label="{{ link.label|default:link.network }}" class="noir-file inline-flex items-center gap-2 text-foreground transition-colors hover:bg-primary/10 hover:text-primary {% if style == "stack" %}w-full px-4 py-2.5{% else %}p-2.5{% endif %}">
{% if link.network %}<svg class="h-5 w-5 text-primary" aria-hidden="true"><use href="/icons/{{ link.network|split:":"|first }}.svg#{{ link.network|split:":"|last }}"></use></svg>{% endif %}
{% if style == "stack" and link.label %}<span class="noir-label">{{ link.label }}</span>{% endif %}
</a>
</li>
{% endif %}{% endfor %}
</ul>
</div>

View File

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

View File

@ -0,0 +1,22 @@
{# noir override — team; preserves heading, intro, layout, columns, members[].photo/.name/.role/.bio/.socials[].url/.icon. Avatars keep rounded-full; social svg use markup verbatim. #}
<section data-block-override="noir:team" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-6xl px-4">
{% if heading %}<h2 class="noir-heading text-center text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="noir-body mx-auto mt-4 max-w-2xl text-center text-lg" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
<div class="mt-10 {% if layout == "list" %}mx-auto max-w-3xl space-y-6{% elif layout == "single" %}mx-auto max-w-xl{% else %}noir-cols noir-gap-lg {% if columns == 2 %}noir-cols-2{% elif columns == 4 %}noir-cols-4{% else %}noir-cols-3{% endif %}{% endif %}">
{% for member in members %}
<div class="{% if layout == "list" %}noir-file flex items-start gap-5 p-5{% else %}text-center{% endif %}">
{% if member.photo %}<span class="{% if layout == "list" %}h-20 w-20{% else %}mx-auto h-28 w-28{% endif %} block shrink-0 overflow-hidden rounded-full bg-muted grayscale">{% img member.photo alt=member.name class="h-full w-full object-cover" %}</span>{% endif %}
<div class="{% if layout != "list" %}mt-4{% endif %}">
{% if member.name %}<h3 class="noir-heading text-lg" style="color:hsl(var(--foreground));">{{ member.name }}</h3>{% endif %}
{% if member.role %}<p class="noir-label" style="color:hsl(var(--primary));">{{ member.role }}</p>{% endif %}
{% if member.bio %}<p class="noir-body mt-2 text-sm" style="color:hsl(var(--muted-foreground));">{{ member.bio }}</p>{% endif %}
{% if member.socials %}<div class="mt-3 flex gap-3 {% if layout != "list" %}justify-center{% endif %}">
{% for social in member.socials %}{% if social.url %}<a href="{{ social.url }}" class="text-muted-foreground transition-colors hover:text-primary" aria-label="Social link"><svg class="h-5 w-5" aria-hidden="true"><use href="/icons/{{ social.icon|split:":"|first }}.svg#{{ social.icon|split:":"|last }}"></use></svg></a>{% endif %}{% endfor %}
</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</section>

View File

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

View File

@ -1 +1,2 @@
<div class="noir-text {{ class }}" style="line-height: 1.7; color: hsl(var(--foreground)); max-width: 65ch;">{{ text|safe }}</div>
{# text override — Source Serif body prose with evidence-red links. Fields: text, proseClass (mirror builtin). #}
<div data-block-override="noir:text" class="noir-body prose max-w-none flex-1 [&_a]:text-primary [&_a]:no-underline [&_a]:border-b [&_a]:border-[hsl(var(--primary))] [&_a:hover]:text-accent [&_strong]:text-foreground {{ proseClass }}" style="color:hsl(var(--foreground));line-height:1.75;">{{ text|safe }}</div>

View File

@ -0,0 +1,22 @@
{# noir override — timeline; preserves heading, intro, orientation, marker, steps[].label/.title/.text/.media. Marker rank-circles keep rounded-full; flag svg + {% img %} verbatim. #}
<section data-block-override="noir:timeline" class="py-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
<div class="mx-auto max-w-4xl px-4">
{% if heading %}<h2 class="noir-heading text-center text-3xl md:text-4xl" style="color:hsl(var(--foreground));">{{ heading }}</h2>{% endif %}
{% if intro %}<p class="noir-body mx-auto mt-4 max-w-2xl text-center text-lg" style="color:hsl(var(--muted-foreground));">{{ intro }}</p>{% endif %}
<ol class="mt-12 {% if orientation == "horizontal" %}noir-cols noir-gap-lg noir-cols-4{% else %}relative space-y-8 before:absolute before:left-4 before:top-2 before:h-[calc(100%-1rem)] before:w-px before:bg-border{% endif %}">
{% for step in steps %}
<li class="{% if orientation == "horizontal" %}text-center{% else %}relative pl-14{% endif %}">
<span class="noir-mono flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground {% if orientation == "horizontal" %}mx-auto{% else %}absolute left-0 top-0 ring-4 ring-background{% endif %}">
{% if marker == "numbered" %}{{ forloop.counter }}{% elif marker == "dot" %}<span class="h-2 w-2 rounded-full bg-primary-foreground"></span>{% else %}<svg class="h-4 w-4" aria-hidden="true"><use href="/icons/lucide.svg#flag"></use></svg>{% endif %}
</span>
<div class="{% if orientation == "horizontal" %}mt-4{% else %}pt-1{% endif %}">
{% if step.label %}<p class="noir-label" style="color:hsl(var(--primary));">{{ step.label }}</p>{% endif %}
{% if step.title %}<h3 class="noir-heading mt-1 text-lg" style="color:hsl(var(--foreground));">{{ step.title }}</h3>{% endif %}
{% if step.text %}<p class="noir-body mt-1" style="color:hsl(var(--muted-foreground));">{{ step.text }}</p>{% endif %}
{% if step.media %}<div class="mt-3 overflow-hidden noir-hairline grayscale">{% img step.media alt=step.title class="w-full h-auto object-cover" %}</div>{% endif %}
</div>
</li>
{% endfor %}
</ol>
</div>
</section>

View File

@ -0,0 +1,10 @@
{# noir override — utility-bar; preserves variant, showOnMobile, leftItems/rightItems {icon,label,link}, ubitem macro + ::pack:name:: icon shorthand. #}
{% set ubcls = "bg-muted text-muted-foreground border-b border-border" %}
{% if variant == "primary" %}{% set ubcls = "bg-primary text-primary-foreground" %}{% elif variant == "dark" %}{% set ubcls = "bg-foreground text-background" %}{% endif %}
{% macro ubitem(it) %}{% if it.link %}<a href="{{ it.link }}" class="noir-label inline-flex items-center gap-1.5 hover:text-foreground transition-colors">{% if it.icon %}::{{ it.icon }}::{% endif %}{% if it.label %}<span>{{ it.label }}</span>{% endif %}</a>{% else %}<span class="noir-label inline-flex items-center gap-1.5">{% if it.icon %}::{{ it.icon }}::{% endif %}{% if it.label %}<span>{{ it.label }}</span>{% endif %}</span>{% endif %}{% endmacro %}
<div data-block-override="noir:utility-bar" class="w-full text-[0.7rem] {% if not showOnMobile %}hidden sm:block{% endif %} {{ ubcls }} {{ class }}">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-9 flex items-center justify-between gap-4">
<div class="flex items-center gap-5 flex-wrap">{% for it in leftItems %}{{ ubitem(it) }}{% endfor %}</div>
<div class="flex items-center gap-5 flex-wrap justify-end">{% for it in rightItems %}{{ ubitem(it) }}{% endfor %}</div>
</div>
</div>

View File

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