Initial commit

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-11 15:55:14 -04:00
commit 290ff0bc1e
41 changed files with 7998 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
---
import SectionHeading from './SectionHeading.astro';
import type { ContactItem } from '../lib/fallback-data';
type Props = {
contacts: ContactItem[];
};
const { contacts } = Astro.props as Props;
---
<section>
<SectionHeading eyebrow="Contact" title="Public contacts" description="Only public-facing contacts are shown here." />
<div class="cards-grid">
{contacts
.filter((contact) => contact.is_public !== false)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
.map((contact) => (
<article class="card">
<h3>{contact.name}</h3>
<p class="muted">{contact.role}</p>
<p><a href={`mailto:${contact.email}`}>{contact.email}</a></p>
{contact.phone && <p><a href={`tel:${contact.phone.replace(/\s+/g, '')}`}>{contact.phone}</a></p>}
</article>
))}
</div>
</section>
+32
View File
@@ -0,0 +1,32 @@
---
import SectionHeading from './SectionHeading.astro';
import type { DocumentItem } from '../lib/fallback-data';
import { formatDate } from '../lib/format';
type Props = {
documents: DocumentItem[];
};
const { documents } = Astro.props as Props;
---
<section>
<SectionHeading eyebrow="Documents" title="Downloads and reference material" description="Flight documents, airport guidance, and public information in one place." />
<div class="stack">
{documents.map((document) => (
<article class="card">
<div class="split-grid" style="align-items:start;">
<div>
<p class="pill">{document.category}</p>
<h3>{document.title}</h3>
{document.description && <p>{document.description}</p>}
</div>
<div>
{document.uploaded_at && <p class="meta">Uploaded {formatDate(document.uploaded_at)}</p>}
{document.fileUrl ? <p><a class="button secondary" href={document.fileUrl}>Download</a></p> : <p class="muted">File will be linked from Directus.</p>}
</div>
</div>
</article>
))}
</div>
</section>
+44
View File
@@ -0,0 +1,44 @@
---
import SectionHeading from './SectionHeading.astro';
import type { EventItem } from '../lib/fallback-data';
import { formatDateTime } from '../lib/format';
type Props = {
events: EventItem[];
title?: string;
description?: string;
};
const { events, title = 'Upcoming events', description = 'A quick scan list for pilots, visitors, and local supporters.' } = Astro.props as Props;
---
<section>
<SectionHeading eyebrow="Events" title={title} description={description} />
<div class="stack">
{events.length > 0 ? (
events.map((event) => (
<article class="card">
<div class="split-grid" style="align-items:start;">
<div>
<p class="pill">{event.is_featured ? 'Featured' : 'Event'}</p>
<h3>{event.title}</h3>
<p>{event.description}</p>
</div>
<div>
<p class="meta">{formatDateTime(event.start_datetime)}</p>
{event.location_text && <p>{event.location_text}</p>}
{event.registration_link && (
<p><a class="button secondary" href={event.registration_link}>Register</a></p>
)}
</div>
</div>
</article>
))
) : (
<article class="card">
<h3>No events published</h3>
<p>Directus events will render here when content is available.</p>
</article>
)}
</div>
</section>
+30
View File
@@ -0,0 +1,30 @@
---
import SectionHeading from './SectionHeading.astro';
import type { FuelPrice } from '../lib/fallback-data';
import { formatDate } from '../lib/format';
type Props = {
fuelPrices: FuelPrice[];
};
const { fuelPrices } = Astro.props as Props;
function formatFuelPrice(value: unknown): string {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : '0.00';
}
---
<section>
<SectionHeading eyebrow="Fuel" title="Current fuel prices" description="A simple, mobile-friendly snapshot of active prices and update times." />
<div class="cards-grid">
{fuelPrices.map((fuel) => (
<article class="card">
<p class="pill">{fuel.fuel_type}</p>
<h3>{fuel.currency} {formatFuelPrice(fuel.price_per_litre)} / litre</h3>
<p class="muted">Updated {formatDate(fuel.last_updated)}</p>
{fuel.notes && <p>{fuel.notes}</p>}
</article>
))}
</div>
</section>
+33
View File
@@ -0,0 +1,33 @@
---
import SectionHeading from './SectionHeading.astro';
import type { NewsItem } from '../lib/fallback-data';
import { formatDate } from '../lib/format';
type Props = {
news: NewsItem[];
title?: string;
description?: string;
};
const { news, title = 'Latest news', description = 'Fresh updates, operational changes, and airport announcements.' } = Astro.props as Props;
---
<section>
<SectionHeading eyebrow="News" title={title} description={description} />
<div class="cards-grid">
{news.length > 0 ? (
news.map((item) => (
<article class="card">
<p class="meta">{formatDate(item.publish_date)}</p>
<h3><a href={`/news/${item.slug}/`}>{item.title}</a></h3>
<p>{item.summary}</p>
</article>
))
) : (
<article class="card">
<h3>No news items</h3>
<p>News articles will be generated from Directus at build time.</p>
</article>
)}
</div>
</section>
+31
View File
@@ -0,0 +1,31 @@
---
import SectionHeading from './SectionHeading.astro';
import type { Notice } from '../lib/fallback-data';
type Props = {
notices: Notice[];
};
const { notices } = Astro.props as Props;
const visibleNotices = notices.filter((notice) => notice.active !== false).sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
---
<section>
<SectionHeading eyebrow="Operational notices" title="Live airfield alerts" description="High-visibility status messages for pilots, visitors, and staff." />
<div class="notice-list">
{visibleNotices.length > 0 ? (
visibleNotices.map((notice) => (
<article class={`notice ${notice.severity}`}>
<p class="pill">{notice.severity}</p>
<h3>{notice.title}</h3>
<p>{notice.message}</p>
</article>
))
) : (
<article class="notice info">
<h3>No active notices</h3>
<p>Operational messages will appear here when published in Directus.</p>
</article>
)}
</div>
</section>
+17
View File
@@ -0,0 +1,17 @@
---
type Props = {
eyebrow?: string;
title: string;
description?: string;
};
const { eyebrow, title, description } = Astro.props as Props;
---
<div class="section-head">
<div>
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h2 class="section-title">{title}</h2>
</div>
{description && <p class="section-copy">{description}</p>}
</div>
+1
View File
@@ -0,0 +1 @@
/// <reference types="astro/client" />
+74
View File
@@ -0,0 +1,74 @@
---
import { homepageHighlights, site } from '../lib/site';
import '../styles/global.css';
type Props = {
title?: string;
description?: string;
};
const { title, description = 'A fast, static, operational website for Swansea Airport.' } = Astro.props as Props;
const pageTitle = title ? `${title} · ${site.name}` : site.name;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{pageTitle}</title>
</head>
<body>
<header class="topbar">
<div class="container topbar-inner">
<div>
<p class="eyebrow">{site.name}</p>
<p class="topline">{site.tagline}</p>
</div>
<div class="topbar-meta">
<span>{site.openingHours}</span>
<span>{site.phone}</span>
</div>
</div>
</header>
<nav class="navshell" aria-label="Primary">
<div class="container nav-inner">
<a class="brand" href="/">Swansea Airport</a>
<div class="nav-links">
{site.navigation.map((item) => (
<a href={item.href}>{item.label}</a>
))}
</div>
</div>
</nav>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container footer-grid">
<section>
<p class="eyebrow">Airport essentials</p>
<p>{site.address}</p>
<p>{site.licensedHours}</p>
</section>
<section>
<p class="eyebrow">Operational highlights</p>
<ul class="compact-list">
{homepageHighlights.map((item) => (
<li>{item.title}</li>
))}
</ul>
</section>
<section>
<p class="eyebrow">Quick contact</p>
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
<p><a href={`mailto:info@swansea-airport.wales`}>info@swansea-airport.wales</a></p>
</section>
</div>
</footer>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
import {
fallbackContacts,
fallbackDocuments,
fallbackEvents,
fallbackFuelPrices,
fallbackNews,
fallbackNotices,
type ContactItem,
type DocumentItem,
type EventItem,
type FuelPrice,
type NewsItem,
type Notice,
} from './fallback-data';
type CollectionName = 'news' | 'events' | 'notices' | 'fuel_prices' | 'documents' | 'contacts';
const defaultSortByCollection: Partial<Record<CollectionName, string>> = {
news: '-publish_date',
events: '-start_datetime',
notices: '-priority',
fuel_prices: '-last_updated',
documents: '-uploaded_at',
contacts: 'order',
};
declare const process: {
env: Record<string, string | undefined>;
};
const directusUrl = process.env.DIRECTUS_URL ?? 'http://directus:8055';
const directusToken = process.env.DIRECTUS_ADMIN_TOKEN;
async function readCollection<T>(collection: CollectionName): Promise<T[]> {
const endpoint = new URL(`/items/${collection}`, directusUrl);
endpoint.searchParams.set('limit', '100');
const sort = defaultSortByCollection[collection];
if (sort) {
endpoint.searchParams.set('sort', sort);
}
try {
const headers: Record<string, string> = {};
if (directusToken) {
headers['Authorization'] = `Bearer ${directusToken}`;
}
const response = await fetch(endpoint, {
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
if (!response.ok) {
throw new Error(`Directus responded with ${response.status}`);
}
const payload = (await response.json()) as { data?: T[] };
return payload.data ?? [];
} catch {
return fallbackFor(collection) as T[];
}
}
function fallbackFor(collection: CollectionName) {
switch (collection) {
case 'news':
return fallbackNews;
case 'events':
return fallbackEvents;
case 'notices':
return fallbackNotices;
case 'fuel_prices':
return fallbackFuelPrices;
case 'documents':
return fallbackDocuments;
case 'contacts':
return fallbackContacts;
}
}
export const getNews = () => readCollection<NewsItem>('news');
export const getEvents = () => readCollection<EventItem>('events');
export const getNotices = () => readCollection<Notice>('notices');
export const getFuelPrices = () => readCollection<FuelPrice>('fuel_prices');
export const getDocuments = () => readCollection<DocumentItem>('documents');
export const getContacts = () => readCollection<ContactItem>('contacts');
+121
View File
@@ -0,0 +1,121 @@
export type Notice = {
title: string;
message: string;
severity: 'info' | 'warning' | 'critical';
start_date?: string;
end_date?: string;
active?: boolean;
priority?: number;
};
export type FuelPrice = {
fuel_type: string;
price_per_litre: number;
currency: string;
last_updated: string;
notes?: string;
};
export type EventItem = {
title: string;
slug: string;
description: string;
start_datetime: string;
end_datetime?: string;
location_text?: string;
registration_link?: string;
status?: string;
is_featured?: boolean;
tags?: string[];
};
export type NewsItem = {
title: string;
slug: string;
summary: string;
body: string;
publish_date: string;
status?: string;
tags?: string[];
};
export type DocumentItem = {
title: string;
category: string;
description?: string;
fileUrl?: string;
uploaded_at?: string;
};
export type ContactItem = {
name: string;
role: string;
email: string;
phone?: string;
is_public?: boolean;
order?: number;
};
export const fallbackNotices: Notice[] = [
{
title: 'Welcome to Swansea Airport',
message: 'Operational notices and visitor information will appear here once Directus content is published.',
severity: 'info',
active: true,
priority: 1,
},
];
export const fallbackFuelPrices: FuelPrice[] = [
{
fuel_type: 'AVGAS',
price_per_litre: 2.35,
currency: 'GBP',
last_updated: '2026-05-11',
notes: 'Placeholder rate for the initial scaffold.',
},
];
export const fallbackEvents: EventItem[] = [
{
title: 'Airfield open day',
slug: 'airfield-open-day',
description: 'Example event to verify the listing and detail page flow.',
start_datetime: '2026-06-14T09:00:00Z',
end_datetime: '2026-06-14T16:00:00Z',
location_text: 'Main apron',
is_featured: true,
tags: ['Public'],
},
];
export const fallbackNews: NewsItem[] = [
{
title: 'Site scaffolding started',
slug: 'site-scaffolding-started',
summary: 'The new Astro and Directus architecture has been scaffolded.',
body: '<p>This is a starter article that proves the detail route and rich text rendering.</p>',
publish_date: '2026-05-11',
tags: ['Website'],
},
];
export const fallbackDocuments: DocumentItem[] = [
{
title: 'Pilot information pack',
category: 'Pilots',
description: 'Starter document entry for the documents listing.',
uploaded_at: '2026-05-11',
},
];
export const fallbackContacts: ContactItem[] = [
{
name: 'Airport office',
role: 'General enquiries',
email: 'info@swansea-airport.wales',
phone: '01792 687 042',
is_public: true,
order: 1,
},
];
+25
View File
@@ -0,0 +1,25 @@
export function formatDate(value?: string) {
if (!value) {
return 'To be confirmed';
}
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value));
}
export function formatDateTime(value?: string) {
if (!value) {
return 'To be confirmed';
}
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
}
+38
View File
@@ -0,0 +1,38 @@
export const site = {
name: 'Swansea Airport',
tagline: 'The gateway to Gower and Swansea',
address: 'Swansea Airport, Fairwood Common, Swansea, SA2 7JU',
phone: '01792 687 042',
openingHours: '7 days 0900-1600',
licensedHours: 'Friday to Sunday 0900-1700',
runwayFacts: [
'Runway 04/22 concrete 1351m x 30m licensed',
'Runway 10/28 asphalt 857m x 18m unlicensed',
'Category 1 RFFS',
'Air Ground Service 119.705',
],
navigation: [
{ label: 'Home', href: '/' },
{ label: 'Visiting Pilots', href: '/visiting-pilots/' },
{ label: 'Procedures', href: '/procedures-safety-noise-abatement/' },
{ label: 'Events', href: '/events/' },
{ label: 'News', href: '/news/' },
{ label: 'Documents', href: '/documents/' },
{ label: 'Contact', href: '/contact/' },
],
};
export const homepageHighlights = [
{
title: 'Operational clarity first',
body: 'Notices, fuel, events, and the latest news are kept at the top of the page for fast scanning on mobile.',
},
{
title: 'Layout controlled in code',
body: 'Astro owns the structure, spacing, and information hierarchy so the CMS only supplies content.',
},
{
title: 'Static output by default',
body: 'The public site remains available from built files even if Directus is offline after publication.',
},
];
+21
View File
@@ -0,0 +1,21 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import ContactList from '../components/ContactList.astro';
import { getContacts } from '../lib/directus';
import { site } from '../lib/site';
const contacts = await getContacts();
---
<BaseLayout title="Contact" description="How to reach Swansea Airport and its public contacts.">
<div class="container stack">
<section class="prose">
<p class="eyebrow">Contact</p>
<h1 class="section-title">Reach the airport team</h1>
<p>{site.address}</p>
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
</section>
<ContactList contacts={contacts} />
</div>
</BaseLayout>
+13
View File
@@ -0,0 +1,13 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import DocumentList from '../components/DocumentList.astro';
import { getDocuments } from '../lib/directus';
const documents = await getDocuments();
---
<BaseLayout title="Documents" description="Airport documents and downloads for pilots, visitors, and staff.">
<div class="container">
<DocumentList documents={documents} />
</div>
</BaseLayout>
+24
View File
@@ -0,0 +1,24 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getEvents } from '../../lib/directus';
import { formatDateTime } from '../../lib/format';
type EventItem = Awaited<ReturnType<typeof getEvents>>[number];
export async function getStaticPaths() {
const events = await getEvents();
return events.map((item) => ({ params: { slug: item.slug }, props: { item } }));
}
const { item } = Astro.props as { item: EventItem };
---
<BaseLayout title={item.title} description={item.description}>
<article class="container prose">
<p class="meta">{formatDateTime(item.start_datetime)}</p>
<h1 class="section-title">{item.title}</h1>
<p>{item.description}</p>
{item.location_text && <p><strong>Location:</strong> {item.location_text}</p>}
{item.registration_link && <p><a class="button primary" href={item.registration_link}>Register</a></p>}
</article>
</BaseLayout>
+13
View File
@@ -0,0 +1,13 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EventsList from '../../components/EventsList.astro';
import { getEvents } from '../../lib/directus';
const events = (await getEvents()).sort((left, right) => new Date(left.start_datetime).getTime() - new Date(right.start_datetime).getTime());
---
<BaseLayout title="Events" description="Airport events and flying opportunities.">
<div class="container">
<EventsList events={events} title="Events listing" description="Scannable listings for public and operational events." />
</div>
</BaseLayout>
+80
View File
@@ -0,0 +1,80 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import NoticeBanner from '../components/NoticeBanner.astro';
import FuelPricesWidget from '../components/FuelPricesWidget.astro';
import EventsList from '../components/EventsList.astro';
import NewsFeed from '../components/NewsFeed.astro';
import { homepageHighlights, site } from '../lib/site';
import { getEvents, getFuelPrices, getNews, getNotices } from '../lib/directus';
const [notices, fuelPrices, events, news] = await Promise.all([
getNotices(),
getFuelPrices(),
getEvents(),
getNews(),
]);
const featuredEvents = events.filter((event) => event.is_featured).slice(0, 3);
const latestNews = news.slice(0, 3);
---
<BaseLayout title="Home" description="Fast, clear airfield information for pilots and visitors.">
<section class="hero">
<div class="container hero-grid">
<div class="hero-panel">
<p class="eyebrow">Operational website</p>
<h1 class="hero-title">Clear airfield information, built for speed.</h1>
<p class="hero-copy">
Visitor-critical information stays visible up front, while Directus supplies notices, news, events, and fuel pricing at build time.
</p>
<div class="cta-row">
<a class="button primary" href="/visiting-pilots/">Visiting pilots</a>
<a class="button secondary" href="/procedures-safety-noise-abatement/">Procedures and safety</a>
</div>
</div>
<aside class="hero-rail">
<div class="surface">
<p class="eyebrow">Today at the airfield</p>
<div class="stats-grid">
<div class="stat">
<strong>{site.openingHours}</strong>
<span class="muted">Opening hours</span>
</div>
<div class="stat">
<strong>{site.licensedHours}</strong>
<span class="muted">Licensed hours</span>
</div>
</div>
</div>
<div class="surface">
<p class="eyebrow">Runway overview</p>
<ul class="compact-list">
{site.runwayFacts.map((fact) => (
<li>{fact}</li>
))}
</ul>
</div>
</aside>
</div>
</section>
<div class="container stack">
<NoticeBanner notices={notices} />
<FuelPricesWidget fuelPrices={fuelPrices} />
<section>
<div class="cards-grid">
{homepageHighlights.map((item) => (
<article class="card">
<h3>{item.title}</h3>
<p>{item.body}</p>
</article>
))}
</div>
</section>
<EventsList events={featuredEvents} title="Upcoming events" description="Featured events are surfaced here first for quick scanning." />
<NewsFeed news={latestNews} />
</div>
</BaseLayout>
+23
View File
@@ -0,0 +1,23 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getNews } from '../../lib/directus';
import { formatDate } from '../../lib/format';
type NewsItem = Awaited<ReturnType<typeof getNews>>[number];
export async function getStaticPaths() {
const news = await getNews();
return news.map((item) => ({ params: { slug: item.slug }, props: { item } }));
}
const { item } = Astro.props as { item: NewsItem };
---
<BaseLayout title={item.title} description={item.summary}>
<article class="container prose">
<p class="meta">Published {formatDate(item.publish_date)}</p>
<h1 class="section-title">{item.title}</h1>
<p>{item.summary}</p>
<div set:html={item.body} />
</article>
</BaseLayout>
+13
View File
@@ -0,0 +1,13 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import NewsFeed from '../../components/NewsFeed.astro';
import { getNews } from '../../lib/directus';
const news = (await getNews()).sort((left, right) => new Date(right.publish_date).getTime() - new Date(left.publish_date).getTime());
---
<BaseLayout title="News" description="Latest airport news and operational updates.">
<div class="container">
<NewsFeed news={news} title="All news" description="Read the latest public updates and operational announcements." />
</div>
</BaseLayout>
@@ -0,0 +1,29 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Procedures, Safety and Noise Abatement" description="Operational procedures, safety notes, and noise-sensitive guidance.">
<div class="container prose">
<p class="eyebrow">Operations</p>
<h1 class="section-title">Procedures, safety, and noise abatement</h1>
<p>
This page is intentionally text-led and easy to scan. It is controlled by Astro so the structure stays stable even as the content evolves.
</p>
<h2>Safety priorities</h2>
<div class="cards-grid">
<article class="card">
<h3>Brief before flight</h3>
<p>Surface the checklist items pilots need most, without burying them under visual clutter.</p>
</article>
<article class="card">
<h3>Check current notices</h3>
<p>Operational notices should be reviewed before taxi, because the homepage is fed by the same notices collection.</p>
</article>
<article class="card">
<h3>Respect local noise guidance</h3>
<p>Noise abatement text can be expanded in Directus while the page structure stays fixed in code.</p>
</article>
</div>
</div>
</BaseLayout>
+35
View File
@@ -0,0 +1,35 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { site } from '../lib/site';
---
<BaseLayout title="Visiting Pilots" description="Essential information for pilots planning a visit to Swansea Airport.">
<div class="container prose">
<p class="eyebrow">Visiting pilots</p>
<h1 class="section-title">Essential information for arriving aircraft</h1>
<p>
This page is structured for quick pre-flight checks. It keeps operational details in fixed Astro components and leaves content updates to Directus.
</p>
<h2>Airport facts</h2>
<ul>
{site.runwayFacts.map((fact) => <li>{fact}</li>)}
</ul>
<h2>Arrival essentials</h2>
<div class="cards-grid">
<article class="card">
<h3>PPR</h3>
<p>Pre-landing fogging is presented prominently here and can be linked to the relevant Directus content or booking workflow.</p>
</article>
<article class="card">
<h3>Book out</h3>
<p>Departure procedures and any required outbound reporting remain in the same controlled page structure.</p>
</article>
<article class="card">
<h3>Fuel and services</h3>
<p>Fuel prices are shown on the homepage and can be reused here with the same data source.</p>
</article>
</div>
</div>
</BaseLayout>
+386
View File
@@ -0,0 +1,386 @@
:root {
color-scheme: light;
--bg: #f5f1e8;
--panel: #fffaf0;
--panel-strong: #ffffff;
--text: #12212c;
--muted: #5b6570;
--brand: #11384d;
--brand-2: #8d5f2d;
--line: rgba(18, 33, 44, 0.12);
--critical: #8e1f1b;
--warning: #b36a09;
--info: #245b7d;
--shadow: 0 18px 40px rgba(18, 33, 44, 0.08);
--radius: 1.1rem;
--radius-sm: 0.8rem;
--content: 1120px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: "Trebuchet MS", "Lucida Grande", "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(141, 95, 45, 0.16), transparent 35%),
radial-gradient(circle at right 20%, rgba(18, 57, 77, 0.12), transparent 30%),
var(--bg);
color: var(--text);
line-height: 1.55;
}
a {
color: inherit;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.16em;
}
a:hover {
color: var(--brand);
}
img {
max-width: 100%;
display: block;
}
.container {
width: min(calc(100% - 2rem), var(--content));
margin-inline: auto;
}
.topbar,
.navshell,
.site-footer {
backdrop-filter: blur(18px);
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid var(--line);
background: rgba(245, 241, 232, 0.84);
}
.topbar-inner,
.nav-inner,
.footer-grid {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.topbar-inner {
align-items: center;
padding: 0.85rem 0;
}
.topbar-meta {
display: flex;
flex-wrap: wrap;
gap: 0.8rem 1.2rem;
color: var(--muted);
font-size: 0.95rem;
}
.navshell {
border-bottom: 1px solid var(--line);
background: rgba(255, 250, 240, 0.88);
}
.nav-inner {
align-items: center;
padding: 1rem 0;
flex-wrap: wrap;
}
.brand {
font-family: Georgia, "Times New Roman", serif;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
}
.nav-links {
display: flex;
flex-wrap: wrap;
gap: 0.9rem 1.2rem;
color: var(--muted);
font-size: 0.98rem;
}
.nav-links a {
text-decoration: none;
}
main {
padding-block: 1.5rem 4rem;
}
section {
padding-block: 1rem;
}
.hero {
padding-block: 1rem 2rem;
}
.hero-grid {
display: grid;
grid-template-columns: 1.35fr 0.95fr;
gap: 1.5rem;
align-items: start;
}
.hero-panel,
.card,
.surface,
.notice {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 240, 0.94));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero-panel {
padding: 1.6rem;
}
.hero-title {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(2.2rem, 5vw, 4.6rem);
line-height: 0.98;
}
.hero-copy {
max-width: 62ch;
color: var(--muted);
font-size: 1.05rem;
}
.cta-row {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 1.3rem;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.8rem;
padding: 0.75rem 1rem;
border-radius: 999px;
text-decoration: none;
font-weight: 700;
border: 1px solid transparent;
}
.button.primary {
background: var(--brand);
color: white;
}
.button.secondary {
background: transparent;
border-color: rgba(18, 57, 77, 0.18);
}
.hero-rail {
display: grid;
gap: 1rem;
}
.stats-grid,
.panel-grid,
.cards-grid {
display: grid;
gap: 1rem;
}
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat,
.card,
.surface,
.notice {
padding: 1rem;
}
.stat strong {
display: block;
font-size: 1.6rem;
margin-bottom: 0.25rem;
}
.eyebrow {
margin: 0 0 0.35rem;
color: var(--brand-2);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
font-weight: 700;
}
.topline,
.muted {
color: var(--muted);
}
.section-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
margin-bottom: 1rem;
}
.section-title {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(1.4rem, 2.8vw, 2.2rem);
}
.section-copy {
margin: 0.25rem 0 0;
color: var(--muted);
max-width: 68ch;
}
.notice-list,
.compact-list {
list-style: none;
margin: 0;
padding: 0;
}
.notice-list {
display: grid;
gap: 0.8rem;
}
.notice {
border-left: 0.5rem solid var(--info);
}
.notice.warning {
border-left-color: var(--warning);
}
.notice.critical {
border-left-color: var(--critical);
}
.pill,
.tag,
.meta {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(18, 57, 77, 0.08);
color: var(--brand);
font-size: 0.8rem;
font-weight: 700;
}
.tag {
background: rgba(141, 95, 45, 0.1);
color: var(--brand-2);
}
.meta {
background: rgba(18, 33, 44, 0.06);
color: var(--muted);
}
.cards-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.card h3,
.card h4,
.surface h3,
.surface h4,
.notice h3 {
margin-top: 0;
}
.card p:last-child,
.surface p:last-child,
.notice p:last-child {
margin-bottom: 0;
}
.split-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.site-footer {
border-top: 1px solid var(--line);
background: rgba(18, 33, 44, 0.93);
color: rgba(255, 250, 240, 0.88);
padding: 2rem 0;
}
.site-footer a {
color: inherit;
}
.footer-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.site-footer .eyebrow,
.site-footer .muted {
color: rgba(255, 250, 240, 0.66);
}
.prose {
max-width: 72ch;
}
.prose h2,
.prose h3 {
font-family: Georgia, "Times New Roman", serif;
}
.stack {
display: grid;
gap: 1rem;
}
@media (max-width: 860px) {
.hero-grid,
.split-grid,
.footer-grid {
grid-template-columns: 1fr;
}
.topbar-inner,
.nav-inner,
.footer-grid {
display: grid;
}
.nav-links {
gap: 0.7rem 1rem;
}
}