fix(brutalist): remediate layout/whitespace + mobile responsiveness

The host public CSS is a render-driven JIT (`@import "tailwindcss"
source(none)`); a runtime-installed codeless theme lives in data/plugins,
which is in NO @source glob, so its .ninjatpl utility classes only compile
if a built-in emits the exact string or the class is safelisted. Non-
safelisted families were silently dropping on a fresh provision, causing:
feature-grid rendering full-width instead of 3 columns (lg:grid-cols-3
absent), card/section content crammed against borders (p-* shorthand
absent), and touching grid items (gap-6 absent).

- manifest.yaml: add a "layout guarantee layer" of raw CSS scoped to
  .brutalist-page (compiled unconditionally via input_css_append) defining
  grid-cols/col-span/columns + all sm/md/lg responsive variants, p-*
  shorthand, gap-*, space-y-*, aspect-*, object-*, w/h-full. Deterministic
  columns on desktop AND 1-col collapse on mobile via real media queries.
- manifest.yaml: cut excessive .bru-section padding (6rem->4rem desktop,
  4rem->2.75rem mobile) to remove the oversized dead bands between sections.
- project_ledger: was a bare <section> with no wrap -> rendered full-bleed
  (rows touched the viewport edges). Wrap in bru-section + bru-wrap; add an
  optional heading field (parity with changelog/spec_sheet).
- navbar: move mobile drawer + overlay geometry (fixed/top/right/h-full/
  w-80/max-w-[85vw]/inset-0/z) into the inline <style> so the non-safelisted
  positioning classes can't leave the drawer unpositioned; display stays on
  safelisted flex/md:hidden. Neutral rgba scrim.
- image: guard empty src so absent media collapses instead of an empty
  bordered box (empty-media graceful state).

Persona (concrete, chunky borders, hard offset shadows, candy accents)
unchanged. No .reveal/scroll-hide pattern in this theme. Version not bumped.

make build, ninja plugin verify, and check-safety all exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-05 17:26:15 +08:00
parent 2adea9ef22
commit 50f58205f0
5 changed files with 137 additions and 6 deletions

View File

@ -1,3 +1,6 @@
<section data-block="brutalist:project_ledger"> <section class="bru-section bg-background text-foreground" data-block="brutalist:project_ledger">
<div class="bru-wrap">
{% if heading %}<h2 class="bru-kicker mb-8" style="font-size:clamp(1.5rem,4vw,2.75rem)">{{ heading }}</h2>{% endif %}
{% if not rows %}<p class="brutalist-mono" style="padding: 1rem 0; color: hsl(var(--muted-foreground));">No projects listed.</p>{% else %}<ol style="list-style: none; padding: 0; margin: 0;">{% for row in rows %}<li class="brutalist-ledger-row"><span class="ledger-no">{{ row.no }}</span><span class="ledger-year">{{ row.year }}</span><span class="ledger-client">{% if row.link %}<a href="{{ row.link }}" style="color: inherit; text-decoration: none;">{{ row.client }}</a>{% else %}{{ row.client }}{% endif %}</span><span class="ledger-role">{{ row.role }}</span><span class="ledger-arrow">{% if row.link %}<a href="{{ row.link }}" aria-label="View project" style="color: inherit; text-decoration: none;">→</a>{% endif %}</span></li>{% endfor %}</ol>{% endif %} {% if not rows %}<p class="brutalist-mono" style="padding: 1rem 0; color: hsl(var(--muted-foreground));">No projects listed.</p>{% else %}<ol style="list-style: none; padding: 0; margin: 0;">{% for row in rows %}<li class="brutalist-ledger-row"><span class="ledger-no">{{ row.no }}</span><span class="ledger-year">{{ row.year }}</span><span class="ledger-client">{% if row.link %}<a href="{{ row.link }}" style="color: inherit; text-decoration: none;">{{ row.client }}</a>{% else %}{{ row.client }}{% endif %}</span><span class="ledger-role">{{ row.role }}</span><span class="ledger-arrow">{% if row.link %}<a href="{{ row.link }}" aria-label="View project" style="color: inherit; text-decoration: none;">→</a>{% endif %}</span></li>{% endfor %}</ol>{% endif %}
</div>
</section> </section>

View File

@ -4,6 +4,12 @@
"description": "Ordered project list on a 12-col grid: number, year, client, role, link", "description": "Ordered project list on a 12-col grid: number, year, client, role, link",
"type": "object", "type": "object",
"properties": { "properties": {
"heading": {
"type": "string",
"title": "Heading",
"description": "Optional section heading shown above the ledger",
"x-editor": "text"
},
"rows": { "rows": {
"type": "array", "type": "array",
"title": "Rows", "title": "Rows",

View File

@ -475,8 +475,8 @@ css:
via hsl(var(--token)); all type via var(--font-*). via hsl(var(--token)); all type via var(--font-*).
============================================================ */ ============================================================ */
.bru-section { padding: 4rem 0; } .bru-section { padding: 2.75rem 0; }
@media (min-width: 768px) { .bru-section { padding: 6rem 0; } } @media (min-width: 768px) { .bru-section { padding: 4rem 0; } }
.bru-wrap { width: 100%; max-width: 80rem; margin: 0 auto; padding: 0 1.5rem; } .bru-wrap { width: 100%; max-width: 80rem; margin: 0 auto; padding: 0 1.5rem; }
.bru-wrap-mid { width: 100%; max-width: 64rem; margin: 0 auto; padding: 0 1.5rem; } .bru-wrap-mid { width: 100%; max-width: 64rem; margin: 0 auto; padding: 0 1.5rem; }
.bru-wrap-narrow { width: 100%; max-width: 44rem; margin: 0 auto; padding: 0 1.5rem; } .bru-wrap-narrow { width: 100%; max-width: 44rem; margin: 0 auto; padding: 0 1.5rem; }
@ -647,3 +647,120 @@ css:
.bru-spec-row dt { text-transform: uppercase; color: hsl(var(--muted-foreground)); } .bru-spec-row dt { text-transform: uppercase; color: hsl(var(--muted-foreground)); }
.bru-spec-row dd { margin: 0; text-align: right; color: hsl(var(--foreground)); } .bru-spec-row dd { margin: 0; text-align: right; color: hsl(var(--foreground)); }
/* ============================================================
LAYOUT GUARANTEE LAYER — the host's public CSS is a render-driven
JIT built with `@import "tailwindcss" source(none)`. A codeless
theme installs to data/plugins, which is NOT in any @source glob,
so its .ninjatpl utility classes only reach the compiled CSS if a
built-in template emits the exact string or the class is in the
host safelist. These families are NOT safelisted and were silently
dropping on a fresh provision (collapsed grids, zero padding,
touching items): grid-cols-{1,2,4,5}, every responsive grid
variant, col-span-*, the p-* shorthand, gap-{3..12}/gap-x/gap-y,
space-y-*, columns-*, aspect-*, object-*, w/h-full. Define them
here (input_css_append is compiled UNCONDITIONALLY) so every block
lays out deterministically on desktop AND collapses on mobile.
Scoped to .brutalist-page; structural only — no colours. Values
mirror Tailwind's defaults so any host-compiled copy is identical.
============================================================ */
.brutalist-page .grid { display: grid; }
.brutalist-page .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.brutalist-page .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.brutalist-page .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.brutalist-page .grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.brutalist-page .grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.brutalist-page .col-span-2 { grid-column: span 2 / span 2; }
.brutalist-page .col-span-3 { grid-column: span 3 / span 3; }
.brutalist-page .columns-2 { columns: 2; }
/* Padding shorthand (px-*/py-* are safelisted; p-* is not). */
.brutalist-page .p-0 { padding: 0; }
.brutalist-page .p-1 { padding: 0.25rem; }
.brutalist-page .p-2 { padding: 0.5rem; }
.brutalist-page .p-3 { padding: 0.75rem; }
.brutalist-page .p-4 { padding: 1rem; }
.brutalist-page .p-5 { padding: 1.25rem; }
.brutalist-page .p-6 { padding: 1.5rem; }
.brutalist-page .p-8 { padding: 2rem; }
.brutalist-page .p-16 { padding: 4rem; }
/* Gaps (only gap-1/1.5/2 are safelisted). */
.brutalist-page .gap-0 { gap: 0; }
.brutalist-page .gap-3 { gap: 0.75rem; }
.brutalist-page .gap-4 { gap: 1rem; }
.brutalist-page .gap-5 { gap: 1.25rem; }
.brutalist-page .gap-6 { gap: 1.5rem; }
.brutalist-page .gap-8 { gap: 2rem; }
.brutalist-page .gap-10 { gap: 2.5rem; }
.brutalist-page .gap-12 { gap: 3rem; }
.brutalist-page .gap-x-2 { column-gap: 0.5rem; }
.brutalist-page .gap-x-3 { column-gap: 0.75rem; }
.brutalist-page .gap-x-4 { column-gap: 1rem; }
.brutalist-page .gap-x-10 { column-gap: 2.5rem; }
.brutalist-page .gap-y-1 { row-gap: 0.25rem; }
.brutalist-page .gap-y-2 { row-gap: 0.5rem; }
.brutalist-page .gap-y-6 { row-gap: 1.5rem; }
/* Vertical rhythm (space-y-*). */
.brutalist-page .space-y-0 > * + * { margin-top: 0; }
.brutalist-page .space-y-1 > * + * { margin-top: 0.25rem; }
.brutalist-page .space-y-2 > * + * { margin-top: 0.5rem; }
.brutalist-page .space-y-3 > * + * { margin-top: 0.75rem; }
.brutalist-page .space-y-4 > * + * { margin-top: 1rem; }
.brutalist-page .space-y-5 > * + * { margin-top: 1.25rem; }
.brutalist-page .space-y-6 > * + * { margin-top: 1.5rem; }
.brutalist-page .space-y-8 > * + * { margin-top: 2rem; }
.brutalist-page .space-y-10 > * + * { margin-top: 2.5rem; }
.brutalist-page .space-y-12 > * + * { margin-top: 3rem; }
/* Sizing used for media fill + logos. */
.brutalist-page .w-full { width: 100%; }
.brutalist-page .h-full { height: 100%; }
.brutalist-page .w-auto { width: auto; }
.brutalist-page .h-auto { height: auto; }
/* Media boxes: a fixed aspect ratio so ABSENT/empty media can never
balloon into a tall empty box — it stays a compact bordered tile. */
.brutalist-page .aspect-video { aspect-ratio: 16 / 9; }
.brutalist-page .aspect-square { aspect-ratio: 1 / 1; }
.brutalist-page .aspect-\[3\/4\] { aspect-ratio: 3 / 4; }
.brutalist-page .aspect-\[4\/3\] { aspect-ratio: 4 / 3; }
.brutalist-page .aspect-\[21\/9\] { aspect-ratio: 21 / 9; }
.brutalist-page .object-cover { object-fit: cover; }
.brutalist-page .object-contain { object-fit: contain; }
.brutalist-page .object-fill { object-fit: fill; }
.brutalist-page img { max-width: 100%; }
@media (min-width: 640px) {
.brutalist-page .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.brutalist-page .sm\:col-span-1 { grid-column: span 1 / span 1; }
.brutalist-page .sm\:col-span-2 { grid-column: span 2 / span 2; }
.brutalist-page .sm\:gap-6 { gap: 1.5rem; }
}
@media (min-width: 768px) {
.brutalist-page .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.brutalist-page .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.brutalist-page .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.brutalist-page .md\:col-span-3 { grid-column: span 3 / span 3; }
.brutalist-page .md\:columns-2 { columns: 2; }
.brutalist-page .md\:columns-3 { columns: 3; }
.brutalist-page .md\:columns-4 { columns: 4; }
.brutalist-page .md\:gap-16 { gap: 4rem; }
.brutalist-page .md\:p-6 { padding: 1.5rem; }
.brutalist-page .md\:p-8 { padding: 2rem; }
.brutalist-page .md\:p-14 { padding: 3.5rem; }
.brutalist-page .md\:px-10 { padding-left: 2.5rem; padding-right: 2.5rem; }
.brutalist-page .md\:py-14 { padding-top: 3.5rem; padding-bottom: 3.5rem; }
.brutalist-page .md\:py-16 { padding-top: 4rem; padding-bottom: 4rem; }
}
@media (min-width: 1024px) {
.brutalist-page .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.brutalist-page .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.brutalist-page .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.brutalist-page .lg\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.brutalist-page .lg\:col-span-1 { grid-column: span 1 / span 1; }
.brutalist-page .lg\:col-span-2 { grid-column: span 2 / span 2; }
.brutalist-page .lg\:col-span-3 { grid-column: span 3 / span 3; }
}

View File

@ -1,2 +1,2 @@
{# brutalist image override — hard-bordered figure, mono caption. Fields: src, alt, size, align, aspectRatio, objectFit, caption. Rounding is killed page-wide. #} {# brutalist image override — hard-bordered figure, mono caption. Fields: src, alt, size, align, aspectRatio, objectFit, caption. Rounding is killed page-wide. #}
{% with size=size|default:"full" align=align|default:"center" aspectRatio=aspectRatio|default:"auto" objectFit=objectFit|default:"cover" %}<figure class="flex-1 {% if class %}{{ class }} {% endif %}{% if align == "left" %}flex justify-start{% elif align == "right" %}flex justify-end{% else %}flex justify-center{% endif %}"><div class="bru-slab bru-shadow relative overflow-hidden {% if size == "xs" %}max-w-[200px] w-full{% elif size == "sm" %}max-w-[400px] w-full{% elif size == "md" %}max-w-[600px] w-full{% elif size == "lg" %}max-w-[800px] w-full{% else %}w-full{% endif %}{% if 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 %}{% else %}{% img src alt=alt class="w-full h-full object-cover" layout=size %}{% endif %}</div>{% if caption %}<figcaption class="bru-meta mt-3">{{ caption }}</figcaption>{% endif %}</figure>{% endwith %} {% with size=size|default:"full" align=align|default:"center" aspectRatio=aspectRatio|default:"auto" objectFit=objectFit|default:"cover" %}{% if src %}<figure class="flex-1 {% if class %}{{ class }} {% endif %}{% if align == "left" %}flex justify-start{% elif align == "right" %}flex justify-end{% else %}flex justify-center{% endif %}"><div class="bru-slab bru-shadow relative overflow-hidden {% if size == "xs" %}max-w-[200px] w-full{% elif size == "sm" %}max-w-[400px] w-full{% elif size == "md" %}max-w-[600px] w-full{% elif size == "lg" %}max-w-[800px] w-full{% else %}w-full{% endif %}{% if 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 %}{% else %}{% img src alt=alt class="w-full h-full object-cover" layout=size %}{% endif %}</div>{% if caption %}<figcaption class="bru-meta mt-3">{{ caption }}</figcaption>{% endif %}</figure>{% endif %}{% endwith %}

View File

@ -23,8 +23,13 @@
</nav> </nav>
<style> <style>
.bn-navbar-toggle{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;} .bn-navbar-toggle{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;}
.bn-navbar-drawer{transform:translateX(100%);transition:transform .3s ease;} /* Drawer + overlay geometry lives here, not on Tailwind classes: fixed/top-0/
.bn-navbar-overlay{opacity:0;pointer-events:none;transition:opacity .3s ease;} right-0/h-full/w-80/max-w-[85vw]/inset-0/z-* are not in the host safelist and
a runtime-installed theme's classes aren't JIT-scanned, so relying on them
would leave the mobile drawer unpositioned. display stays on the safelisted
flex/flex-col/md:hidden classes so the drawer is desktop-hidden. */
.bn-navbar-drawer{position:fixed;top:0;right:0;height:100%;width:20rem;max-width:85vw;z-index:50;transform:translateX(100%);transition:transform .3s ease;}
.bn-navbar-overlay{position:fixed;inset:0;z-index:40;background:rgba(0,0,0,0.55);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-drawer{transform:translateX(0);}
.bn-navbar-toggle:checked ~ .bn-navbar-overlay{opacity:1;pointer-events:auto;} .bn-navbar-toggle:checked ~ .bn-navbar-overlay{opacity:1;pointer-events:auto;}
.bn-navbar-drawer details > summary::-webkit-details-marker{display:none;} .bn-navbar-drawer details > summary::-webkit-details-marker{display:none;}