Merge pull request 'feat(brand): default emblem — favicon, hero medallion, footer credit' (#69) from feature/branding into main
All checks were successful
Build container images / build (push) Successful in 1m57s
Build container images / deploy (push) Successful in 2m3s

Reviewed-on: #69
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-18 22:20:27 +00:00
11 changed files with 122 additions and 35 deletions

View File

@@ -53,7 +53,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
## Project structure ## Project structure
``` ```
UOMSITE/ website/
├─ server/ Express API ├─ server/ Express API
│ ├─ src/ │ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0) │ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -5,31 +5,40 @@ export default function SiteFooter() {
const { contactEmail, siteTitle } = useSite() const { contactEmail, siteTitle } = useSite()
return ( return (
<footer <footer
className="sans" className="sans site-footer"
style={{ style={{
borderTop: '1px solid var(--line)', borderTop: '1px solid var(--line)',
padding: '28px 16px', padding: '28px 16px',
color: 'var(--muted)', color: 'var(--muted)',
textAlign: 'center',
fontSize: '0.9rem', fontSize: '0.9rem',
background: 'rgba(9,13,18,0.6)', background: 'rgba(9,13,18,0.6)',
}} }}
> >
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}> <div className="site-footer-inner">
<span>{siteTitle} is an independent private shard project.</span> <div className="site-footer-badge">
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}> <img src="/assets/img/runic-emblem.png" alt="" aria-hidden="true" />
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}> <span>
{contactEmail} Powered by
</a> <br />
&nbsp;·&nbsp; <strong>Runic Gateway</strong>
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}> </span>
Shard Status </div>
</Link> <div className="site-footer-info">
&nbsp;·&nbsp; <span>{siteTitle} is an independent private shard project.</span>
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}> <span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
Admin <a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
</Link> {contactEmail}
</span> </a>
&nbsp;·&nbsp;
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Shard Status
</Link>
&nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin
</Link>
</span>
</div>
</div> </div>
</footer> </footer>
) )

View File

@@ -40,7 +40,7 @@ export function SiteProvider({ children }) {
siteTitle: brand.name || settings.site_title || 'Runic Gateway', siteTitle: brand.name || settings.site_title || 'Runic Gateway',
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway', siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
contactEmail: brand.contactEmail || settings.contact_email || '', contactEmail: brand.contactEmail || settings.contact_email || '',
heroImage: brand.hero || '/assets/img/hero-moon.png', heroImage: brand.hero || '/assets/img/runic-emblem.png',
} }
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider> return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>

View File

@@ -1,19 +1,27 @@
// Shared hero-layout helpers used by the public portal and the admin editor. // Shared hero-layout helpers used by the public portal and the admin editor.
// Neutral built-in default; the instance hero image (BRAND_HERO) overrides it at // Runic Gateway default hero emblem; the instance hero image (BRAND_HERO)
// runtime, threaded in as `defaultImage` by the portal. // overrides it at runtime, threaded in as `defaultImage` by the portal.
export const DEFAULT_HERO_IMAGE = '/assets/img/hero-moon.png' export const DEFAULT_HERO_IMAGE = '/assets/img/runic-emblem.png'
// The original hand-tuned multi-gradient hero background over a given image // Default hero background: the emblem centered behind the text as a medallion,
// (used for the untouched default so the live page stays consistent until edited). // under a symmetric dark overlay tuned to keep centered hero copy legible.
// Two layers (overlay gradient + image) so the per-layer background-size in
// `heroBackground` can contain the square emblem while the overlay stays full-bleed.
export function heroBgStack(image) { export function heroBgStack(image) {
return ( return (
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" + 'linear-gradient(180deg,rgba(11,15,20,0.62) 0%,rgba(11,15,20,0.48) 38%,rgba(11,15,20,0.52) 58%,rgba(11,15,20,0.86) 100%),' +
"url('" +
(image || DEFAULT_HERO_IMAGE) + (image || DEFAULT_HERO_IMAGE) +
"')" "')"
) )
} }
// Keep the emblem fully visible and centered, capped so it never overflows a
// narrow viewport; the overlay layer covers.
export const HERO_DEFAULT_SIZE = 'cover, min(74vh, 640px, 86vw)'
export const HERO_DEFAULT_POSITION = 'center, center'
export const HERO_BG = heroBgStack(DEFAULT_HERO_IMAGE) export const HERO_BG = heroBgStack(DEFAULT_HERO_IMAGE)
// Single-stop dark overlay driven by the editor's opacity slider. // Single-stop dark overlay driven by the editor's opacity slider.
@@ -26,13 +34,20 @@ export function buildOverlay(opacity) {
export function heroBackground(layout, { isDefault = false, defaultImage } = {}) { export function heroBackground(layout, { isDefault = false, defaultImage } = {}) {
const bg = layout.background || {} const bg = layout.background || {}
const fallback = defaultImage || DEFAULT_HERO_IMAGE const fallback = defaultImage || DEFAULT_HERO_IMAGE
const backgroundImage = // Untouched default: emblem contained + centered behind the text (per-layer
isDefault && !bg.image_url // size/position so the overlay stays full-bleed while the square emblem fits).
? heroBgStack(fallback) if (isDefault && !bg.image_url) {
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || fallback}')` return {
backgroundColor: 'var(--bg-deep)',
backgroundImage: heroBgStack(fallback),
backgroundPosition: HERO_DEFAULT_POSITION,
backgroundRepeat: 'no-repeat',
backgroundSize: HERO_DEFAULT_SIZE,
}
}
return { return {
backgroundColor: 'var(--bg-deep)', backgroundColor: 'var(--bg-deep)',
backgroundImage, backgroundImage: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || fallback}')`,
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`, backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
backgroundSize: bg.size || 'cover', backgroundSize: bg.size || 'cover',

View File

@@ -151,7 +151,8 @@ export default function AdminLogin() {
backgroundColor: 'var(--bg-deep)', backgroundColor: 'var(--bg-deep)',
backgroundImage: BG, backgroundImage: BG,
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundSize: 'cover', backgroundRepeat: 'no-repeat',
backgroundSize: 'min(60vh, 520px)',
}} }}
> >
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>

View File

@@ -18,7 +18,8 @@ export default function PlayerShell({ subtitle, children, footer }) {
backgroundColor: 'var(--bg-deep)', backgroundColor: 'var(--bg-deep)',
backgroundImage: bg, backgroundImage: bg,
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundSize: 'cover', backgroundRepeat: 'no-repeat',
backgroundSize: 'min(60vh, 520px)',
}} }}
> >
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>

View File

@@ -23,7 +23,7 @@ export default function Maintenance() {
backgroundImage: heroBg, backgroundImage: heroBg,
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
backgroundSize: 'cover', backgroundSize: 'min(60vh, 520px)',
}} }}
> >
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}> <div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>

View File

@@ -1009,6 +1009,67 @@ button[disabled] {
border-color: #8a4b47; border-color: #8a4b47;
} }
/* Site footer: powered-by badge left-justified, info centered. */
.site-footer-inner {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
max-width: 1100px;
margin: 0 auto;
}
.site-footer-badge {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.site-footer-badge img {
height: 48px;
width: 48px;
object-fit: contain;
filter: drop-shadow(0 0 6px rgba(127, 153, 189, 0.35));
}
.site-footer-badge span {
font-size: 0.72rem;
line-height: 1.25;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
text-align: left;
}
.site-footer-badge strong {
color: var(--muted);
font-weight: 700;
letter-spacing: 0.04em;
}
.site-footer-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
}
/* Pin the badge to the left while the info column stays visually centered. */
@media (min-width: 641px) {
.site-footer-badge {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
}
}
@media (max-width: 640px) {
.site-footer-inner {
flex-direction: column;
}
.site-footer-badge img {
height: 40px;
width: 40px;
}
}
/* Draft-preview banner on the public renderer. */ /* Draft-preview banner on the public renderer. */
.page-preview-banner { .page-preview-banner {
border: 1px solid var(--accent); border: 1px solid var(--accent);

View File

@@ -31,8 +31,8 @@ const brand = {
// Visual // Visual
accent: process.env.BRAND_ACCENT_COLOR || '#7f99bd', accent: process.env.BRAND_ACCENT_COLOR || '#7f99bd',
logo: process.env.BRAND_LOGO || '', // empty → no logo image rendered logo: process.env.BRAND_LOGO || '', // empty → no logo image rendered
hero: process.env.BRAND_HERO || '/assets/img/hero-moon.png', hero: process.env.BRAND_HERO || '/assets/img/runic-emblem.png',
favicon: process.env.BRAND_FAVICON || '', // empty → no favicon link injected favicon: process.env.BRAND_FAVICON || '/assets/img/favicon.ico', // Runic Gateway default emblem
} }
// Discord embeds want an int (0xRRGGBB). Parse the accent hex once; fall back to // Discord embeds want an int (0xRRGGBB). Parse the accent hex once; fall back to