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>
This commit is contained in:
parent
2c0428dce9
commit
1451f06d9f
184
BUILD_REPORT.md
184
BUILD_REPORT.md
@ -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.
|
||||
|
||||
@ -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 10–11px, 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
26
RECOMMENDED_ICONS.md
Normal 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.
|
||||
21
assets/fonts/LICENSES.md
Normal file
21
assets/fonts/LICENSES.md
Normal 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>)`.
|
||||
24
assets/fonts/OFL-SourceSerif4.txt
Normal file
24
assets/fonts/OFL-SourceSerif4.txt
Normal 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.
|
||||
BIN
assets/fonts/ibm-plex-mono-400.woff2
Normal file
BIN
assets/fonts/ibm-plex-mono-400.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/ibm-plex-mono-500.woff2
Normal file
BIN
assets/fonts/ibm-plex-mono-500.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/source-serif-4-400-italic.woff2
Normal file
BIN
assets/fonts/source-serif-4-400-italic.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/source-serif-4-400.woff2
Normal file
BIN
assets/fonts/source-serif-4-400.woff2
Normal file
Binary file not shown.
234
assets/style.css
234
assets/style.css
@ -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; }
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
blocks/case_dossier.ninjatpl
Normal file
26
blocks/case_dossier.ninjatpl
Normal 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>
|
||||
60
blocks/case_dossier.schema.json
Normal file
60
blocks/case_dossier.schema.json
Normal 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
24
blocks/case_file.ninjatpl
Normal 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>
|
||||
49
blocks/case_file.schema.json
Normal file
49
blocks/case_file.schema.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
blocks/evidence_board.ninjatpl
Normal file
19
blocks/evidence_board.ninjatpl
Normal 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>
|
||||
36
blocks/evidence_board.schema.json
Normal file
36
blocks/evidence_board.schema.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
14
blocks/redacted_quote.ninjatpl
Normal file
14
blocks/redacted_quote.ninjatpl
Normal 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>
|
||||
38
blocks/redacted_quote.schema.json
Normal file
38
blocks/redacted_quote.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
fonts.json
19
fonts.json
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
541
manifest.yaml
541
manifest.yaml
@ -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,230 @@ 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-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; }
|
||||
/* .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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@ -3,7 +3,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."
|
||||
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"]
|
||||
required_icon_packs = ["lucide"]
|
||||
|
||||
314
presets.json
314
presets.json
@ -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
219
seed/seed.json
Normal 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
19
seed/workflows.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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, &#39;Segoe UI&#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: &#39;Tenor Sans&#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: &#39;JetBrains Mono&#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: &#39;JetBrains Mono&#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 }};"> </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>
|
||||
|
||||
@ -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>
|
||||
|
||||
18
templates/noir/auth.ninjatpl
Normal file
18
templates/noir/auth.ninjatpl
Normal 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>
|
||||
35
templates/noir/blog-index.ninjatpl
Normal file
35
templates/noir/blog-index.ninjatpl
Normal 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>
|
||||
33
templates/noir/contact.ninjatpl
Normal file
33
templates/noir/contact.ninjatpl
Normal 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>
|
||||
@ -1,65 +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="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.12});
|
||||
els.forEach(function(el){io.observe(el);});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,64 +1,27 @@
|
||||
<!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-5xl mx-auto px-4 py-16 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.12});
|
||||
els.forEach(function(el){io.observe(el);});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
21
templates/overrides/noir/announcement-bar.ninjatpl
Normal file
21
templates/overrides/noir/announcement-bar.ninjatpl
Normal 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 %}
|
||||
16
templates/overrides/noir/audio-embed.ninjatpl
Normal file
16
templates/overrides/noir/audio-embed.ninjatpl
Normal 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>
|
||||
33
templates/overrides/noir/auth-form.ninjatpl
Normal file
33
templates/overrides/noir/auth-form.ninjatpl
Normal 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 %}
|
||||
31
templates/overrides/noir/auth-status.ninjatpl
Normal file
31
templates/overrides/noir/auth-status.ninjatpl
Normal 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 %}
|
||||
21
templates/overrides/noir/author-bio-hero.ninjatpl
Normal file
21
templates/overrides/noir/author-bio-hero.ninjatpl
Normal 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 %}
|
||||
14
templates/overrides/noir/author-bio.ninjatpl
Normal file
14
templates/overrides/noir/author-bio.ninjatpl
Normal 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 %}
|
||||
24
templates/overrides/noir/blog-hero.ninjatpl
Normal file
24
templates/overrides/noir/blog-hero.ninjatpl
Normal 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 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{% for post in posts %}
|
||||
<article class="group flex flex-col{% if forloop.First %} md:col-span-3 md:flex-row md:items-stretch md:gap-6{% endif %}">
|
||||
{% if post.featured_image_url %}<a href="{{ post.url }}" class="mb-3 block overflow-hidden bg-muted grayscale{% if forloop.First %} md:mb-0 md:w-1/2{% endif %}">{% img post.featured_image_url alt=post.title class="aspect-video h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" %}</a>{% endif %}
|
||||
<div class="flex flex-col justify-center{% if forloop.First %} md:w-1/2{% endif %}">
|
||||
<span class="noir-rule mb-4 block w-12"></span>
|
||||
<h2 class="noir-heading {% if forloop.First %}text-2xl{% else %}text-lg{% endif %} leading-snug" style="color:hsl(var(--foreground));"><a href="{{ post.url }}" class="transition-colors hover:text-primary">{{ post.title }}</a></h2>
|
||||
{% if showExcerpt and post.excerpt %}<p class="noir-body mt-2 text-sm" style="color:hsl(var(--muted-foreground));">{{ post.excerpt }}</p>{% endif %}
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-3 noir-label">{% if post.author_name %}<span style="color:hsl(var(--primary));">{{ post.author_name }}</span>{% endif %}{% if post.published_at %}<span style="color:hsl(var(--muted-foreground));">{{ post.published_at|date:"M j, Y" }}</span>{% endif %}</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
13
templates/overrides/noir/breadcrumbs.ninjatpl
Normal file
13
templates/overrides/noir/breadcrumbs.ninjatpl
Normal 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>
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
18
templates/overrides/noir/category-list.ninjatpl
Normal file
18
templates/overrides/noir/category-list.ninjatpl
Normal 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>
|
||||
18
templates/overrides/noir/contact-card.ninjatpl
Normal file
18
templates/overrides/noir/contact-card.ninjatpl
Normal 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 grid grid-cols-1 gap-4 {% if columns == 3 %}sm:grid-cols-2 lg:grid-cols-3{% elif columns == 1 %}{% else %}sm:grid-cols-2{% endif %}">
|
||||
{% for channel in channels %}
|
||||
<div class="noir-file flex items-start gap-4 p-5">
|
||||
{% if channel.icon %}<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center border border-border bg-primary/10 text-primary"><svg 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>
|
||||
40
templates/overrides/noir/contact-form.ninjatpl
Normal file
40
templates/overrides/noir/contact-form.ninjatpl
Normal 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 %}
|
||||
49
templates/overrides/noir/countdown.ninjatpl
Normal file
49
templates/overrides/noir/countdown.ninjatpl
Normal 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>
|
||||
15
templates/overrides/noir/cta.ninjatpl
Normal file
15
templates/overrides/noir/cta.ninjatpl
Normal 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-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
|
||||
<div class="mx-auto max-w-6xl px-4">
|
||||
<div class="{% if background == "band" %}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>
|
||||
10
templates/overrides/noir/divider.ninjatpl
Normal file
10
templates/overrides/noir/divider.ninjatpl
Normal 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 %}
|
||||
41
templates/overrides/noir/event-list.ninjatpl
Normal file
41
templates/overrides/noir/event-list.ninjatpl
Normal 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 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{% for event in events %}{% if not (hidePast and event.past) %}
|
||||
<article class="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 %} – {{ 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>
|
||||
18
templates/overrides/noir/faq.ninjatpl
Normal file
18
templates/overrides/noir/faq.ninjatpl
Normal 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>
|
||||
18
templates/overrides/noir/feature-grid.ninjatpl
Normal file
18
templates/overrides/noir/feature-grid.ninjatpl
Normal 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-16 md:py-24 bg-background text-foreground{% if class %} {{ class }}{% endif %}">
|
||||
<div class="mx-auto max-w-6xl px-4">
|
||||
{% if eyebrow %}<p class="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-12 grid grid-cols-1 gap-6 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% elif columns == 4 %}lg:grid-cols-4{% else %}lg:grid-cols-3{% endif %}">
|
||||
{% for item in items %}
|
||||
<div class="{% if layout == "card" %}noir-file p-6 text-card-foreground transition-colors{% elif layout == "centered" %}text-center{% endif %}">
|
||||
{% if item.icon %}<div class="mb-4 inline-flex h-11 w-11 items-center justify-center border border-border bg-primary/10 text-primary{% if layout == "centered" %} mx-auto{% endif %}"><svg class="h-6 w-6" aria-hidden="true"><use href="/icons/{{ item.icon|split:":"|first }}.svg#{{ item.icon|split:":"|last }}"></use></svg></div>{% endif %}
|
||||
<h3 class="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>
|
||||
22
templates/overrides/noir/featured-posts.ninjatpl
Normal file
22
templates/overrides/noir/featured-posts.ninjatpl
Normal 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="grid grid-cols-1 gap-6 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% elif columns == 4 %}lg:grid-cols-4{% else %}lg:grid-cols-3{% endif %}">
|
||||
{% for post in posts %}
|
||||
<article class="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>
|
||||
40
templates/overrides/noir/footer.ninjatpl
Normal file
40
templates/overrides/noir/footer.ninjatpl
Normal 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 %}© {{ copyright }}{% else %}© {{ 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>
|
||||
52
templates/overrides/noir/gallery.ninjatpl
Normal file
52
templates/overrides/noir/gallery.ninjatpl
Normal 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 %}grid grid-cols-2 {% if cols == 4 %}md:grid-cols-4{% elif cols == 2 %}md:grid-cols-2{% else %}md:grid-cols-3{% endif %} {% if gap == "sm" %}gap-2{% elif gap == "lg" %}gap-6{% else %}gap-4{% endif %}{% endif %}">{% for image in images %}<a href="{{ image.src|mediaURL }}" data-bn-gallery-item data-caption="{{ image.caption|default:"" }}" class="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 %}
|
||||
@ -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 %}
|
||||
|
||||
39
templates/overrides/noir/hero.ninjatpl
Normal file
39
templates/overrides/noir/hero.ninjatpl
Normal 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 grid grid-cols-1 lg:grid-cols-2 overflow-hidden bg-background text-foreground noir-grain{% if class %} {{ class }}{% endif %}">
|
||||
<div class="relative min-h-[320px] bg-muted grayscale {% if mediaPosition == "left" %}order-1 lg:order-1{% else %}order-1 lg:order-2{% endif %}">{% if hasImage %}{% img backgroundImage alt=headlineText class="absolute inset-0 h-full w-full object-cover" layout="hero" %}{% endif %}</div>
|
||||
<div class="flex flex-col justify-center px-6 py-16 lg:px-12 {% if mediaPosition == "left" %}order-2 lg:order-2{% else %}order-2 lg:order-1{% endif %}"><div class="max-w-lg text-left">{{ herocontent("left") }}</div></div>
|
||||
</section>
|
||||
{% else %}
|
||||
{% set align = "center" %}
|
||||
{% if variant == "left-aligned" %}{% set align = "left" %}{% endif %}
|
||||
<section 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{% elif minHeight == "50vh" %}min-h-[50vh]{% elif minHeight == "auto" %}min-h-[400px]{% else %}min-h-[75vh]{% endif %} {% if verticalAlign == "top" %}justify-start pt-20{% elif verticalAlign == "bottom" %}justify-end pb-20{% else %}justify-center{% endif %}{% if class %} {{ class }}{% endif %}">
|
||||
{% if hasImage %}<div class="absolute inset-0 z-0 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 %}
|
||||
31
templates/overrides/noir/hours-location.ninjatpl
Normal file
31
templates/overrides/noir/hours-location.ninjatpl
Normal 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 grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||
<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>
|
||||
@ -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 %}
|
||||
|
||||
16
templates/overrides/noir/logo-strip.ninjatpl
Normal file
16
templates/overrides/noir/logo-strip.ninjatpl
Normal 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 %}
|
||||
29
templates/overrides/noir/menu-list.ninjatpl
Normal file
29
templates/overrides/noir/menu-list.ninjatpl
Normal 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 grid grid-cols-1 gap-x-12 gap-y-10 {% if columns == 2 %}md:grid-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>
|
||||
104
templates/overrides/noir/navbar.ninjatpl
Normal file
104
templates/overrides/noir/navbar.ninjatpl
Normal 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>
|
||||
10
templates/overrides/noir/newsletter-signup.ninjatpl
Normal file
10
templates/overrides/noir/newsletter-signup.ninjatpl
Normal 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>
|
||||
21
templates/overrides/noir/page-suggestions.ninjatpl
Normal file
21
templates/overrides/noir/page-suggestions.ninjatpl
Normal 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 %}
|
||||
13
templates/overrides/noir/password-reset-form.ninjatpl
Normal file
13
templates/overrides/noir/password-reset-form.ninjatpl
Normal 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>
|
||||
16
templates/overrides/noir/popular-posts.ninjatpl
Normal file
16
templates/overrides/noir/popular-posts.ninjatpl
Normal 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>
|
||||
20
templates/overrides/noir/post-hero.ninjatpl
Normal file
20
templates/overrides/noir/post-hero.ninjatpl
Normal 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 %}
|
||||
9
templates/overrides/noir/post-metadata.ninjatpl
Normal file
9
templates/overrides/noir/post-metadata.ninjatpl
Normal 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 %}
|
||||
26
templates/overrides/noir/pricing.ninjatpl
Normal file
26
templates/overrides/noir/pricing.ninjatpl
Normal 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-12 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-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>
|
||||
2
templates/overrides/noir/quote.ninjatpl
Normal file
2
templates/overrides/noir/quote.ninjatpl
Normal 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 %}
|
||||
17
templates/overrides/noir/related-posts.ninjatpl
Normal file
17
templates/overrides/noir/related-posts.ninjatpl
Normal 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="grid grid-cols-1 gap-6 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% else %}lg:grid-cols-3{% endif %}">
|
||||
{% for post in posts %}
|
||||
<article class="group flex flex-col">
|
||||
{% if post.featured_image_url %}<a href="{{ post.url }}" class="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 %}
|
||||
14
templates/overrides/noir/rich-section.ninjatpl
Normal file
14
templates/overrides/noir/rich-section.ninjatpl
Normal 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 grid max-w-6xl items-center gap-10 px-4 md:grid-cols-2 md:gap-16">
|
||||
<div class="{% if mediaPosition == "left" %}md:order-2{% else %}md:order-1{% endif %}">
|
||||
{% if eyebrow %}<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>
|
||||
2
templates/overrides/noir/rich-text.ninjatpl
Normal file
2
templates/overrides/noir/rich-text.ninjatpl
Normal 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 %}
|
||||
13
templates/overrides/noir/sidebar-nav.ninjatpl
Normal file
13
templates/overrides/noir/sidebar-nav.ninjatpl
Normal 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 %}
|
||||
14
templates/overrides/noir/social-links.ninjatpl
Normal file
14
templates/overrides/noir/social-links.ninjatpl
Normal 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>
|
||||
49
templates/overrides/noir/stats.ninjatpl
Normal file
49
templates/overrides/noir/stats.ninjatpl
Normal 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-12 grid grid-cols-2 gap-8 divide-x divide-border {% if columns == 2 %}md:grid-cols-2{% elif columns == 3 %}md:grid-cols-3{% else %}md:grid-cols-4{% endif %}">
|
||||
{% for item in items %}
|
||||
<div class="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 %}
|
||||
22
templates/overrides/noir/team.ninjatpl
Normal file
22
templates/overrides/noir/team.ninjatpl
Normal 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-12 {% if layout == "list" %}mx-auto max-w-3xl space-y-6{% elif layout == "single" %}mx-auto max-w-xl{% else %}grid grid-cols-1 gap-8 sm:grid-cols-2 {% if columns == 2 %}lg:grid-cols-2{% elif columns == 4 %}lg:grid-cols-4{% else %}lg:grid-cols-3{% endif %}{% endif %}">
|
||||
{% for member in members %}
|
||||
<div class="{% if layout == "list" %}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>
|
||||
28
templates/overrides/noir/testimonial.ninjatpl
Normal file
28
templates/overrides/noir/testimonial.ninjatpl
Normal 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 %}grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-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 %}“{{ q.quote }}”{% 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>
|
||||
@ -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>
|
||||
|
||||
22
templates/overrides/noir/timeline.ninjatpl
Normal file
22
templates/overrides/noir/timeline.ninjatpl
Normal 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" %}grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4{% else %}relative space-y-8 before:absolute before:left-4 before:top-2 before:h-[calc(100%-1rem)] before:w-px before:bg-border{% endif %}">
|
||||
{% for step in steps %}
|
||||
<li class="{% if orientation == "horizontal" %}text-center{% else %}relative pl-14{% endif %}">
|
||||
<span class="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>
|
||||
10
templates/overrides/noir/utility-bar.ninjatpl
Normal file
10
templates/overrides/noir/utility-bar.ninjatpl
Normal 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>
|
||||
23
templates/overrides/noir/video-embed.ninjatpl
Normal file
23
templates/overrides/noir/video-embed.ninjatpl
Normal 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 %}
|
||||
Loading…
x
Reference in New Issue
Block a user