Add Sass build pipeline and production deploy script

- Move hand-authored CSS into src/scss/styles.scss, compiled to the
  tracked styles.css via `npm run build` (sass, no source maps).
- Add package.json/package-lock.json with dev tooling: sass, and a
  `dev` script running php + sass --watch + browser-sync together.
- Add scripts/build-dist.js (npm run dist): assembles an allowlisted,
  production-only copy of the site into dist/ (gitignored) for pushing
  to hosting — excludes node_modules, src/, package files, and the
  live db/fitz.sqlite while including config.php.
- .gitignore: ignore node_modules/, styles.css.map, and dist/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
David DyerandClaude Sonnet 5 committed 2026-09-01 18:21:01 -04:00
1 parent cd4c6c1969
commit 2145094e55
6 files changed
+4822 -106

No files matched your search

+7
View File
@@ -16,3 +16,10 @@ db/fitz.sqlite-shm
.hintrc .hintrc
dev.sh dev.sh
.claude/ .claude/
# Node development dependencies and Sass source maps
node_modules/
styles.css.map
# Generated deploy folder (npm run dist) — includes config.php, never commit
dist/
+2430
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "theoldfitz",
"version": "1.0.0",
"description": "The Old Fitz Public House website",
"main": "main.js",
"scripts": {
"dev": "concurrently --kill-others-on-fail \"npm:dev:php\" \"npm:dev:sass\" \"npm:dev:browser\"",
"dev:php": "php -S localhost:8000",
"dev:sass": "sass --watch --source-map src/scss/styles.scss:styles.css",
"dev:browser": "browser-sync start --proxy http://localhost:8000 --files \"*.php,admin/**/*.php,inc/**/*.php,main.js,styles.css\" --no-open",
"build": "sass --no-source-map src/scss/styles.scss:styles.css",
"dist": "npm run build && node scripts/build-dist.js"
},
"repository": {
"type": "git",
"url": "https://gitea.home.daviddyer.me/david/the-old-fitz.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"browser-sync": "^3.0.4",
"concurrently": "^10.0.5",
"sass": "^1.103.1"
}
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
'use strict';
/**
* Assembles a clean, production-only copy of the site into dist/.
*
* This is an allowlist by design: only the files and folders listed below
* are copied. Dev-only stuff (node_modules/, src/, package*.json, .git/,
* .claude/, docker-compose.yaml, dev.sh, .hintrc, config.example.php, the
* live db/fitz.sqlite, ...) never ends up in dist/ — including anything
* added later, since it has to be added here explicitly first.
*
* Run with: npm run dist
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const FILES = [
'index.php',
'menu.php',
'main.js',
'styles.css',
'robots.txt',
'config.php', // holds the live FB_PAGE_ACCESS_TOKEN — see README before sharing dist/ anywhere but the server
];
const DIRS = ['admin', 'assets', 'inc'];
function copyDir(src, dest) {
fs.cpSync(src, dest, {
recursive: true,
filter: (source) => path.basename(source) !== '.DS_Store',
});
}
// Fresh start every run so a file removed from the source tree doesn't
// linger in dist/ from a previous build.
fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(DIST, { recursive: true });
const missing = [];
for (const file of FILES) {
const src = path.join(ROOT, file);
if (!fs.existsSync(src)) {
missing.push(file);
continue;
}
fs.copyFileSync(src, path.join(DIST, file));
}
for (const dir of DIRS) {
const src = path.join(ROOT, dir);
if (!fs.existsSync(src)) {
missing.push(dir + '/');
continue;
}
copyDir(src, path.join(DIST, dir));
}
// db/ ships with just .htaccess (denies direct web access to the sqlite
// file) — never the live fitz.sqlite itself. inc/db.php creates the
// schema on first run if the file isn't there, so shipping the dev
// database would just overwrite whatever is already live on the server.
const dbHtaccess = path.join(ROOT, 'db', '.htaccess');
if (fs.existsSync(dbHtaccess)) {
fs.mkdirSync(path.join(DIST, 'db'), { recursive: true });
fs.copyFileSync(dbHtaccess, path.join(DIST, 'db', '.htaccess'));
} else {
missing.push('db/.htaccess');
}
if (missing.length) {
console.warn('Warning: expected but not found, skipped:\n ' + missing.join('\n '));
}
console.log(`Deploy folder built at ${path.relative(ROOT, DIST)}/`);
+2178
View File
File diff suppressed because it is too large. Load diff
+99 -106
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
:root { :root {
--ground: #171310; --ground: #171310;
--ground-2: #0f0c0a; --ground-2: #0f0c0a;
@@ -11,11 +12,9 @@
--ink: #f1e8d8; --ink: #f1e8d8;
--ink-muted: #a89a83; --ink-muted: #a89a83;
--rule: #42362590; --rule: #42362590;
--font-serif: 'Fraunces', Georgia, 'Times New Roman', serif; --font-serif: 'Fraunces', Georgia, 'Times New Roman', serif;
--font-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace; --font-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--font-sans: 'Karla', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Karla', ui-sans-serif, system-ui, sans-serif;
--danger-bg: rgba(180, 50, 40, 0.15); --danger-bg: rgba(180, 50, 40, 0.15);
--danger-text: #ff9d9d; --danger-text: #ff9d9d;
} }
@@ -33,7 +32,6 @@
scroll-behavior: auto; scroll-behavior: auto;
} }
} }
/* ---------- wrap-aware separators (JS-toggled, see main.js) ---------- /* ---------- wrap-aware separators (JS-toggled, see main.js) ----------
Shared hook used by the announcement banner and hero tagline row: a Shared hook used by the announcement banner and hero tagline row: a
"·" between two pieces of text that can independently wrap onto "·" between two pieces of text that can independently wrap onto
@@ -117,7 +115,7 @@
border-radius: 0.125rem; border-radius: 0.125rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.85rem; font-size: 0.85rem;
transition: top .15s ease; transition: top 0.15s ease;
z-index: 100; z-index: 100;
} }
@@ -340,7 +338,7 @@
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--ink-muted); color: var(--ink-muted);
padding: 0.4rem 0.2rem; padding: 0.4rem 0.2rem;
transition: color .15s ease; transition: color 0.15s ease;
} }
nav.links a:hover { nav.links a:hover {
@@ -387,11 +385,11 @@
display: none; display: none;
} }
.nav-toggle[aria-expanded="true"] .icon-menu { .nav-toggle[aria-expanded=true] .icon-menu {
display: none; display: none;
} }
.nav-toggle[aria-expanded="true"] .icon-close { .nav-toggle[aria-expanded=true] .icon-close {
display: block; display: block;
} }
@@ -399,11 +397,9 @@
.nav-toggle { .nav-toggle {
display: inline-flex; display: inline-flex;
} }
.nav-cta { .nav-cta {
display: none; display: none;
} }
nav.links { nav.links {
display: none; display: none;
position: absolute; position: absolute;
@@ -417,16 +413,13 @@
border-bottom: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
padding: 0.5rem 0; padding: 0.5rem 0;
} }
nav.links.is-open { nav.links.is-open {
display: flex; display: flex;
} }
nav.links a { nav.links a {
padding: 0.9rem 1.5rem; padding: 0.9rem 1.5rem;
} }
} }
/* ---------- hero ---------- */ /* ---------- hero ---------- */
.hero { .hero {
position: relative; position: relative;
@@ -447,8 +440,7 @@
inset: 0; inset: 0;
z-index: 1; z-index: 1;
opacity: 0.6; opacity: 0.6;
background: background: linear-gradient(to bottom, rgba(0, 0, 0, 0.12), transparent 22%, transparent 78%, rgba(0, 0, 0, 0.18));
linear-gradient(to bottom, rgba(0, 0, 0, 0.12), transparent 22%, transparent 78%, rgba(0, 0, 0, 0.18));
pointer-events: none; pointer-events: none;
} }
@@ -457,16 +449,14 @@
inset: 0; inset: 0;
z-index: 0; z-index: 0;
overflow: hidden; overflow: hidden;
background-image: url('./assets/brick-texture.png'); background-image: url("./assets/brick-texture.png");
background-repeat: repeat; background-repeat: repeat;
} }
.hero-scrim { .hero-scrim {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background: linear-gradient(180deg, rgba(15, 12, 10, 0.35) 0%, rgba(15, 12, 10, 0.2) 45%, rgba(15, 12, 10, 0.6) 100%), radial-gradient(ellipse 60% 48% at 50% 18%, rgba(201, 163, 92, 0.16), transparent 58%);
linear-gradient(180deg, rgba(15, 12, 10, 0.35) 0%, rgba(15, 12, 10, 0.2) 45%, rgba(15, 12, 10, 0.6) 100%),
radial-gradient(ellipse 60% 48% at 50% 18%, rgba(201, 163, 92, 0.16), transparent 58%);
} }
.hero-inner { .hero-inner {
@@ -545,7 +535,7 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-decoration: none; text-decoration: none;
border-radius: 0.125rem; border-radius: 0.125rem;
transition: transform .15s ease, background .15s ease, border-color .15s ease; transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease;
} }
.btn:hover { .btn:hover {
@@ -688,7 +678,6 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* hours / visit band */ /* hours / visit band */
.visit { .visit {
background: var(--ground-2); background: var(--ground-2);
@@ -805,7 +794,7 @@
border-radius: 0.125rem; border-radius: 0.125rem;
color: var(--ink-muted); color: var(--ink-muted);
text-decoration: none; text-decoration: none;
transition: border-color .15s ease, color .15s ease; transition: border-color 0.15s ease, color 0.15s ease;
} }
.social-row a:hover { .social-row a:hover {
@@ -823,7 +812,6 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* live music band */ /* live music band */
.music { .music {
background: linear-gradient(180deg, var(--green-deep), var(--green)); background: linear-gradient(180deg, var(--green-deep), var(--green));
@@ -895,20 +883,16 @@
gap: 2.5rem; gap: 2.5rem;
text-align: center; text-align: center;
} }
.music-content { .music-content {
align-items: center; align-items: center;
} }
.music .section-head { .music .section-head {
text-align: center; text-align: center;
} }
.music .hero-ctas { .music .hero-ctas {
justify-content: center; justify-content: center;
} }
} }
/* menu band */ /* menu band */
.menu-band .container { .menu-band .container {
max-width: 60rem; max-width: 60rem;
@@ -1021,12 +1005,11 @@
gap: 2rem; gap: 2rem;
} }
} }
/* scroll reveal */ /* scroll reveal */
.reveal { .reveal {
opacity: 0; opacity: 0;
transform: translateY(0.875rem); transform: translateY(0.875rem);
transition: opacity .6s ease, transform .6s ease; transition: opacity 0.6s ease, transform 0.6s ease;
} }
.reveal.is-visible { .reveal.is-visible {
@@ -1041,7 +1024,6 @@
transition: none; transition: none;
} }
} }
/* ---------- admin / management styles ---------- */ /* ---------- admin / management styles ---------- */
.admin-login-body { .admin-login-body {
min-height: 100vh; min-height: 100vh;
@@ -1118,7 +1100,7 @@
border-radius: 0.125rem; border-radius: 0.125rem;
box-sizing: border-box; box-sizing: border-box;
outline: none; outline: none;
transition: border-color .15s ease; transition: border-color 0.15s ease;
} }
.form-control:focus, .form-control:focus,
@@ -1209,7 +1191,6 @@
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
} }
/* two classes each, so these reliably outrank .admin-nav-links's and /* two classes each, so these reliably outrank .admin-nav-links's and
.admin-user's own base-rule gap regardless of source order */ .admin-user's own base-rule gap regardless of source order */
.admin-nav-inner .admin-nav-links, .admin-nav-inner .admin-nav-links,
@@ -1218,7 +1199,6 @@
gap: 0.5rem 1rem; gap: 0.5rem 1rem;
} }
} }
.dashboard-layout { .dashboard-layout {
padding-block: 2.5rem 4rem; padding-block: 2.5rem 4rem;
} }
@@ -1268,7 +1248,6 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.form-actions { .form-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -1297,7 +1276,7 @@
font-weight: 500; font-weight: 500;
} }
.checkbox-label input[type="checkbox"] { .checkbox-label input[type=checkbox] {
width: 1.15rem; width: 1.15rem;
height: 1.15rem; height: 1.15rem;
accent-color: var(--gold); accent-color: var(--gold);
@@ -1344,7 +1323,7 @@
-webkit-appearance: none; -webkit-appearance: none;
cursor: pointer; cursor: pointer;
justify-content: center; justify-content: center;
transition: opacity .15s ease; transition: opacity 0.15s ease;
} }
button.status-badge:hover { button.status-badge:hover {
@@ -1406,7 +1385,6 @@
white-space: normal; white-space: normal;
} }
} }
.btn-sm { .btn-sm {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
font-size: 0.75rem; font-size: 0.75rem;
@@ -1457,7 +1435,7 @@
color: var(--ink-muted); color: var(--ink-muted);
padding: 0.35rem 0.65rem; padding: 0.35rem 0.65rem;
border-radius: 0.125rem; border-radius: 0.125rem;
transition: color .15s ease, background .15s ease; transition: color 0.15s ease, background 0.15s ease;
} }
.admin-nav-links a:hover { .admin-nav-links a:hover {
@@ -1592,9 +1570,7 @@
.category-panel .panel-head { .category-panel .panel-head {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
grid-template-areas: grid-template-areas: "name actions" "desc actions";
"name actions"
"desc actions";
align-items: center; align-items: center;
column-gap: 0.75rem; column-gap: 0.75rem;
} }
@@ -1688,39 +1664,90 @@
} }
.col-status, .col-status,
.item-row [data-label="Status"], .item-row [data-label=Status],
.ann-row [data-label="Status"], .ann-row [data-label=Status],
.user-row [data-label="Status"] { .user-row [data-label=Status] {
grid-area: status; grid-area: status;
} }
.col-actions, .col-actions,
.item-row [data-label="Actions"], .item-row [data-label=Actions],
.ann-row [data-label="Actions"], .ann-row [data-label=Actions],
.user-row [data-label="Actions"] { .user-row [data-label=Actions] {
grid-area: actions; grid-area: actions;
} }
.col-item { grid-area: item; } .col-item {
.col-price { grid-area: price; } grid-area: item;
.item-row [data-label="Item"] { grid-area: item; } }
.item-row [data-label="Price"] { grid-area: price; }
.col-headline { grid-area: headline; } .col-price {
.col-type { grid-area: type; } grid-area: price;
.col-schedule { grid-area: schedule; } }
.col-placements { grid-area: placements; }
.ann-row [data-label="Headline"] { grid-area: headline; }
.ann-row [data-label="Type"] { grid-area: type; }
.ann-row [data-label="Schedule"] { grid-area: schedule; }
.ann-row [data-label="Placements"] { grid-area: placements; }
.col-username { grid-area: username; } .item-row [data-label=Item] {
.col-role { grid-area: role; } grid-area: item;
.col-created { grid-area: created; } }
.user-row [data-label="Username"] { grid-area: username; }
.user-row [data-label="Role"] { grid-area: role; } .item-row [data-label=Price] {
.user-row [data-label="Created"] { grid-area: created; } grid-area: price;
}
.col-headline {
grid-area: headline;
}
.col-type {
grid-area: type;
}
.col-schedule {
grid-area: schedule;
}
.col-placements {
grid-area: placements;
}
.ann-row [data-label=Headline] {
grid-area: headline;
}
.ann-row [data-label=Type] {
grid-area: type;
}
.ann-row [data-label=Schedule] {
grid-area: schedule;
}
.ann-row [data-label=Placements] {
grid-area: placements;
}
.col-username {
grid-area: username;
}
.col-role {
grid-area: role;
}
.col-created {
grid-area: created;
}
.user-row [data-label=Username] {
grid-area: username;
}
.user-row [data-label=Role] {
grid-area: role;
}
.user-row [data-label=Created] {
grid-area: created;
}
/* .item-rows being display:contents makes this a direct child of the /* .item-rows being display:contents makes this a direct child of the
.item-grid-wrap grid when a category is empty — span every column or .item-grid-wrap grid when a category is empty — span every column or
@@ -1783,7 +1810,6 @@
.user-grid-wrap { .user-grid-wrap {
grid-template-columns: 1fr minmax(0, auto); grid-template-columns: 1fr minmax(0, auto);
} }
/* Users' actions column gets a fixed cap instead of auto: "Change /* Users' actions column gets a fixed cap instead of auto: "Change
Password" is long enough that its max-content size (what auto would Password" is long enough that its max-content size (what auto would
size the column to) is wider than this corner has room for, which size the column to) is wider than this corner has room for, which
@@ -1792,7 +1818,6 @@
.user-grid-wrap { .user-grid-wrap {
grid-template-columns: minmax(0, 1fr) 6rem; grid-template-columns: minmax(0, 1fr) 6rem;
} }
/* Every row's Actions cell holds one or two .action-stacks (move /* Every row's Actions cell holds one or two .action-stacks (move
up/down, edit/delete, etc. — each stacked vertically, see up/down, edit/delete, etc. — each stacked vertically, see
.action-stack itself further up) side by side. Keeps those stacks .action-stack itself further up) side by side. Keeps those stacks
@@ -1803,13 +1828,11 @@
.user-row .row-actions { .user-row .row-actions {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
.item-grid-head, .item-grid-head,
.ann-grid-head, .ann-grid-head,
.user-grid-head { .user-grid-head {
display: none; display: none;
} }
.item-row, .item-row,
.ann-row, .ann-row,
.user-row { .user-row {
@@ -1819,14 +1842,12 @@
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
.item-row:last-child, .item-row:last-child,
.ann-row:last-child, .ann-row:last-child,
.user-row:last-child { .user-row:last-child {
border-bottom: none; border-bottom: none;
margin-bottom: 0; margin-bottom: 0;
} }
.item-row [data-label]::before, .item-row [data-label]::before,
.ann-row [data-label]::before, .ann-row [data-label]::before,
.user-row [data-label]::before { .user-row [data-label]::before {
@@ -1839,12 +1860,8 @@
color: var(--gold); color: var(--gold);
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
.item-row { .item-row {
grid-template-areas: grid-template-areas: "status actions" "item item" "price price";
"status actions"
"item item"
"price price";
/* Overrides the shared align-items: center above — on desktop that /* Overrides the shared align-items: center above — on desktop that
vertically centers Status against the taller Item cell in one wide vertically centers Status against the taller Item cell in one wide
row, which is fine, but here it also centers Status against the row, which is fine, but here it also centers Status against the
@@ -1852,26 +1869,14 @@
keeping both flush with the card's top edge. */ keeping both flush with the card's top edge. */
align-items: start; align-items: start;
} }
.ann-row { .ann-row {
grid-template-areas: grid-template-areas: "status actions" "headline headline" "type type" "schedule schedule" "placements placements";
"status actions"
"headline headline"
"type type"
"schedule schedule"
"placements placements";
align-items: start; align-items: start;
} }
.user-row { .user-row {
grid-template-areas: grid-template-areas: "status actions" "username username" "role role" "created created";
"status actions"
"username username"
"role role"
"created created";
align-items: start; align-items: start;
} }
/* Stretches Actions' whole flex chain out to the fixed 6rem column /* Stretches Actions' whole flex chain out to the fixed 6rem column
above (each is shrink-wrapped by default), then lets "Change above (each is shrink-wrapped by default), then lets "Change
Password" — long enough on its own to blow out this corner next to a Password" — long enough on its own to blow out this corner next to a
@@ -1883,21 +1888,15 @@
.user-row .row-actions .btn { .user-row .row-actions .btn {
width: 100%; width: 100%;
} }
.user-row .btn-change-password { .user-row .btn-change-password {
white-space: normal; white-space: normal;
} }
.category-panel .panel-head { .category-panel .panel-head {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-areas: grid-template-areas: "name" "desc" "actions";
"name"
"desc"
"actions";
row-gap: 0.5rem; row-gap: 0.5rem;
} }
} }
/* ---------- admin modal dialogs ---------- */ /* ---------- admin modal dialogs ---------- */
.admin-modal-backdrop { .admin-modal-backdrop {
position: fixed; position: fixed;
@@ -1911,7 +1910,7 @@
padding: 1.5rem; padding: 1.5rem;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity .2s ease; transition: opacity 0.2s ease;
} }
.admin-modal-backdrop.is-open { .admin-modal-backdrop.is-open {
@@ -1930,7 +1929,7 @@
padding: 2rem; padding: 2rem;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6); box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
transform: translateY(0.75rem); transform: translateY(0.75rem);
transition: transform .2s ease; transition: transform 0.2s ease;
} }
.admin-modal-backdrop.is-open .admin-modal { .admin-modal-backdrop.is-open .admin-modal {
@@ -1961,7 +1960,7 @@
padding: 0.25rem; padding: 0.25rem;
line-height: 1; line-height: 1;
text-decoration: none; text-decoration: none;
transition: color .15s ease; transition: color 0.15s ease;
} }
.modal-close:hover { .modal-close:hover {
@@ -2002,7 +2001,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 0.125rem; border-radius: 0.125rem;
transition: color .15s ease; transition: color 0.15s ease;
} }
.password-toggle-btn:hover { .password-toggle-btn:hover {
@@ -2089,11 +2088,9 @@
.hours-grid-wrap { .hours-grid-wrap {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.hours-grid-head { .hours-grid-head {
display: none; display: none;
} }
.hours-day-row { .hours-day-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
row-gap: 0.5rem; row-gap: 0.5rem;
@@ -2102,11 +2099,9 @@
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
.hours-day-row:last-child { .hours-day-row:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.hours-day-row [data-label]::before { .hours-day-row [data-label]::before {
content: attr(data-label); content: attr(data-label);
display: block; display: block;
@@ -2117,12 +2112,10 @@
color: var(--gold); color: var(--gold);
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
.hours-day-row .form-control { .hours-day-row .form-control {
width: 100%; width: 100%;
} }
} }
/* Live preview panel: mimics the public site's Visit card and footer /* Live preview panel: mimics the public site's Visit card and footer
mini-summary without depending on their .card/footer ancestor scoping mini-summary without depending on their .card/footer ancestor scoping
(this lives inside an admin .panel, which already shares that same (this lives inside an admin .panel, which already shares that same