feat: add design system, theme toggle, SEO head and base layout
- Huemint Magazine tokens for light/dark themes with AA-safe accent variants - FOUC-free theme init + toggle (localStorage, follows OS until chosen) - Seo component (canonical, OG, Twitter, WebSite/Person JSON-LD) - Header with popover mobile menu, 4-column footer with social links - Container, GridLines, SectionHeading, Button, Badge, Card, ExternalLink - site.json / social.json validated with zod - dev-only /styleguide preview Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
type Variant = 'solid' | 'outline' | 'category';
|
||||
|
||||
export interface Props {
|
||||
variant?: Variant;
|
||||
icon?: string;
|
||||
href?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { variant = 'solid', icon, href, class: className } = Astro.props;
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
// accent-fill (not accent) keeps small text ≥ 4.5:1 in both themes.
|
||||
solid: 'bg-accent-fill text-on-accent-fill font-semibold',
|
||||
outline: 'border border-line text-fg-muted',
|
||||
category:
|
||||
'bg-khaki text-ink font-semibold dark:bg-bg-alt dark:text-fg dark:border dark:border-line',
|
||||
};
|
||||
const Tag = href ? 'a' : 'span';
|
||||
---
|
||||
|
||||
<Tag
|
||||
href={href}
|
||||
class:list={[
|
||||
'inline-flex w-fit items-center gap-1 rounded-sm px-2 py-0.5 text-xs leading-5',
|
||||
variants[variant],
|
||||
href && 'hover:underline',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
{icon && <Icon name={icon} class="size-3.5" aria-hidden="true" />}
|
||||
<slot />
|
||||
</Tag>
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'link';
|
||||
|
||||
export interface Props extends HTMLAttributes<'a'> {
|
||||
href?: string;
|
||||
variant?: Variant;
|
||||
icon?: string;
|
||||
type?: 'button' | 'submit';
|
||||
}
|
||||
|
||||
const { href, variant = 'primary', icon, type = 'button', class: className, ...rest } = Astro.props;
|
||||
|
||||
// Primary uses ink on red at ≥19px bold (large text, ratio 4.46 ≥ 3).
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
'bg-accent text-on-accent px-6 py-3 text-[1.1875rem] font-bold rounded-[2px] hover:bg-fg hover:text-bg',
|
||||
secondary:
|
||||
'bg-fg text-bg px-6 py-3 text-[1.1875rem] font-bold rounded-[2px] hover:bg-accent hover:text-on-accent',
|
||||
link: 'link-strong text-lg',
|
||||
};
|
||||
const classes = [
|
||||
'font-display inline-flex items-center gap-2 transition-colors',
|
||||
variants[variant],
|
||||
className,
|
||||
];
|
||||
---
|
||||
|
||||
{href ? (
|
||||
<a href={href} class:list={classes} {...rest}>
|
||||
<slot />
|
||||
{icon && <Icon name={icon} class="size-5" aria-hidden="true" />}
|
||||
</a>
|
||||
) : (
|
||||
<button type={type} class:list={[classes, 'cursor-pointer']}>
|
||||
<slot />
|
||||
{icon && <Icon name={icon} class="size-5" aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import type { HTMLTag } from 'astro/types';
|
||||
|
||||
export interface Props {
|
||||
as?: HTMLTag;
|
||||
/** Adds hover lift; the card's own link should cover it via `after:absolute after:inset-0`. */
|
||||
interactive?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { as: Tag = 'article', interactive = false, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<Tag
|
||||
class:list={[
|
||||
'bg-surface border-line relative flex flex-col border',
|
||||
interactive &&
|
||||
'transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[4px_4px_0_var(--line)] focus-within:-translate-y-0.5',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<slot />
|
||||
</Tag>
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import type { HTMLTag } from 'astro/types';
|
||||
|
||||
export interface Props {
|
||||
as?: HTMLTag;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { as: Tag = 'div', class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<Tag class:list={['mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8', className]}>
|
||||
<slot />
|
||||
</Tag>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { site } from '../lib/data';
|
||||
|
||||
export interface Props extends Omit<HTMLAttributes<'a'>, 'target' | 'rel'> {
|
||||
href: string;
|
||||
/** Show the trailing external-link icon. */
|
||||
icon?: boolean;
|
||||
}
|
||||
|
||||
const { href, icon = true, class: className, ...rest } = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class:list={['inline-flex items-center gap-1', className]}
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
{icon && <Icon name="tabler:external-link" class="size-[1em] shrink-0" aria-hidden="true" />}
|
||||
<span class="sr-only">{site.ui.newTab}</span>
|
||||
</a>
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { site, social } from '../lib/data';
|
||||
import Badge from './Badge.astro';
|
||||
import Container from './Container.astro';
|
||||
import ExternalLink from './ExternalLink.astro';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const linkClass =
|
||||
'decoration-accent underline-offset-4 hover:underline decoration-2 inline-flex items-center gap-2';
|
||||
const titleClass = 'font-display mb-4 text-sm font-bold tracking-wide uppercase';
|
||||
---
|
||||
|
||||
<footer class="surface-alt border-line border-t">
|
||||
<Container class="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.5fr_1fr_1fr_1fr]">
|
||||
<section aria-labelledby="footer-about">
|
||||
<h2 id="footer-about" class="font-display text-3xl font-black tracking-tight uppercase">
|
||||
{site.name}
|
||||
</h2>
|
||||
<p class="mt-3 max-w-sm">{site.footer.about}</p>
|
||||
<Badge variant="solid" icon="tabler:antenna" class="mt-4">
|
||||
{site.author.callsign} · {site.author.locator}
|
||||
</Badge>
|
||||
</section>
|
||||
|
||||
<nav aria-labelledby="footer-links">
|
||||
<h2 id="footer-links" class={titleClass}>
|
||||
{site.footer.quickLinksTitle}
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-2">
|
||||
{site.nav.map((item) => (
|
||||
<li>
|
||||
<a href={item.href} class={linkClass}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a href={site.footer.rss.href} class={linkClass}>
|
||||
<Icon name="tabler:rss" class="size-4" aria-hidden="true" />
|
||||
{site.footer.rss.label}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav aria-labelledby="footer-legal">
|
||||
<h2 id="footer-legal" class={titleClass}>
|
||||
{site.footer.legalTitle}
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-2">
|
||||
{site.footer.legal.map((item) => (
|
||||
<li>
|
||||
<a href={item.href} class={linkClass}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{social.length > 0 && (
|
||||
<section aria-labelledby="footer-social">
|
||||
<h2 id="footer-social" class={titleClass}>
|
||||
{site.footer.socialTitle}
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{social.map((s) => (
|
||||
<li>
|
||||
<ExternalLink
|
||||
href={s.href}
|
||||
icon={false}
|
||||
title={s.label}
|
||||
class="border-fg hover:bg-fg hover:text-bg size-11 justify-center border transition-colors"
|
||||
>
|
||||
<Icon name={s.icon} class="size-5" aria-hidden="true" />
|
||||
<span class="sr-only">{s.label}</span>
|
||||
</ExternalLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
<div class="border-line border-t">
|
||||
<Container class="flex flex-col gap-2 py-5 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>
|
||||
© {year} {site.author.name} · {site.footer.license}
|
||||
</p>
|
||||
<p class="font-display font-bold tracking-wide uppercase">{site.footer.signoff}</p>
|
||||
</Container>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
// Decorative vertical magazine grid lines (reference: Huemint "Magazine"). Hidden on mobile.
|
||||
export interface Props {
|
||||
class?: string;
|
||||
}
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class:list={['pointer-events-none absolute inset-0 hidden md:block', className]}
|
||||
>
|
||||
<div class="divide-line border-line mx-auto grid h-full max-w-7xl grid-cols-[1.4fr_1.6fr_2.2fr_2fr] divide-x border-x px-4 sm:px-6 lg:px-8">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { site } from '../lib/data';
|
||||
import Container from './Container.astro';
|
||||
import ThemeToggle from './ThemeToggle.astro';
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
|
||||
---
|
||||
|
||||
<header class="border-line relative z-20 border-b">
|
||||
<Container class="flex h-16 items-center justify-between gap-6 md:h-20">
|
||||
<a
|
||||
href="/"
|
||||
class="font-display text-2xl font-black tracking-tight uppercase"
|
||||
aria-label={`${site.name} — ${site.ui.home}`}
|
||||
>
|
||||
{site.name}
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-2 md:gap-6">
|
||||
<nav aria-label={site.ui.mainNav} class="hidden md:block">
|
||||
<ul class="flex items-center gap-6">
|
||||
{site.nav.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
class="font-display hover:border-accent aria-[current=page]:border-accent block border-b-3 border-transparent py-1 text-sm font-bold tracking-wide uppercase transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
popovertarget="mobile-nav"
|
||||
aria-label={site.ui.menuOpen}
|
||||
class="text-fg hover:text-accent inline-flex size-10 cursor-pointer items-center justify-center md:hidden"
|
||||
>
|
||||
<Icon name="tabler:menu-2" class="size-7" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Native popover: Escape / light-dismiss / focus handling without custom JS. -->
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
popover
|
||||
aria-label={site.ui.mainNav}
|
||||
class="bg-bg-alt text-fg fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none p-0 md:hidden"
|
||||
>
|
||||
<Container class="flex h-16 items-center justify-between">
|
||||
<span class="font-display text-2xl font-black tracking-tight uppercase" aria-hidden="true">
|
||||
{site.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
popovertarget="mobile-nav"
|
||||
popovertargetaction="hide"
|
||||
aria-label={site.ui.menuClose}
|
||||
class="hover:text-accent inline-flex size-10 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<Icon name="tabler:x" class="size-7" aria-hidden="true" />
|
||||
</button>
|
||||
</Container>
|
||||
<Container>
|
||||
<ul class="mt-8 flex flex-col gap-2">
|
||||
{site.nav.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
class="headline aria-[current=page]:decoration-accent block py-2 text-4xl decoration-4 underline-offset-8 aria-[current=page]:underline"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
// Magazine section heading: small red eyebrow with a rule, then a heavy uppercase title.
|
||||
export interface Props {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
level?: 1 | 2 | 3;
|
||||
id?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { eyebrow, title, level = 2, id, class: className } = Astro.props;
|
||||
const Heading = `h${level}` as 'h1' | 'h2' | 'h3';
|
||||
const sizes = {
|
||||
1: 'text-[clamp(2.75rem,7vw,6rem)]',
|
||||
2: 'text-[clamp(2rem,4.5vw,3.5rem)]',
|
||||
3: 'text-2xl sm:text-3xl',
|
||||
};
|
||||
---
|
||||
|
||||
<header class:list={['flex flex-col gap-3', className]}>
|
||||
{eyebrow && (
|
||||
<p class="font-display text-accent-text flex items-center gap-3 text-sm font-bold tracking-wide uppercase">
|
||||
<span>{eyebrow}</span>
|
||||
<span aria-hidden="true" class="bg-accent h-0.5 w-12" />
|
||||
</p>
|
||||
)}
|
||||
<Heading id={id} class:list={['headline', sizes[level]]}>
|
||||
{title}
|
||||
</Heading>
|
||||
</header>
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
import { site, social } from '../lib/data';
|
||||
|
||||
export interface Props {
|
||||
/** Page title without the site suffix. Omit on the home page. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Absolute or root-relative image URL for OG/Twitter. */
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
type?: 'website' | 'article';
|
||||
publishedTime?: Date;
|
||||
modifiedTime?: Date;
|
||||
noindex?: boolean;
|
||||
/** Extra JSON-LD nodes (BlogPosting, BreadcrumbList, …). */
|
||||
jsonLd?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = site.description,
|
||||
image,
|
||||
imageAlt,
|
||||
type = 'website',
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
noindex = false,
|
||||
jsonLd = [],
|
||||
} = Astro.props;
|
||||
|
||||
const fullTitle = title ? `${title}${site.titleSeparator}${site.name}` : site.title;
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site ?? site.url).href;
|
||||
const imageUrl = image ? new URL(image, Astro.site ?? site.url).href : undefined;
|
||||
const twitterHandle = social.find((s) => s.id === 'x')?.handle;
|
||||
|
||||
const personId = `${site.url}/#person`;
|
||||
const graph = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': `${site.url}/#website`,
|
||||
url: `${site.url}/`,
|
||||
name: site.title,
|
||||
description: site.description,
|
||||
inLanguage: site.lang,
|
||||
publisher: { '@id': personId },
|
||||
},
|
||||
{
|
||||
'@type': 'Person',
|
||||
'@id': personId,
|
||||
name: site.author.name,
|
||||
alternateName: site.author.callsign,
|
||||
url: `${site.url}/`,
|
||||
sameAs: social.map((s) => s.href),
|
||||
},
|
||||
...jsonLd,
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={site.title} />
|
||||
<meta property="og:locale" content={site.locale} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
{imageUrl && <meta property="og:image" content={imageUrl} />}
|
||||
{imageUrl && imageAlt && <meta property="og:image:alt" content={imageAlt} />}
|
||||
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
|
||||
{modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />}
|
||||
|
||||
<meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
|
||||
{twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
|
||||
{twitterHandle && <meta name="twitter:creator" content={twitterHandle} />}
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{imageUrl && <meta name="twitter:image" content={imageUrl} />}
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(graph)} />
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
// Runs before first paint to avoid a flash of the wrong theme.
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const media = matchMedia('(prefers-color-scheme: dark)');
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem('theme');
|
||||
} catch {}
|
||||
const apply = (t) => {
|
||||
root.dataset.theme = t;
|
||||
};
|
||||
apply(stored === 'light' || stored === 'dark' ? stored : media.matches ? 'dark' : 'light');
|
||||
// Follow the OS while the user has not chosen explicitly.
|
||||
media.addEventListener('change', (e) => {
|
||||
let s = null;
|
||||
try {
|
||||
s = localStorage.getItem('theme');
|
||||
} catch {}
|
||||
if (!s) apply(e.matches ? 'dark' : 'light');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { site } from '../lib/data';
|
||||
---
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle
|
||||
aria-label={site.ui.themeToggle}
|
||||
aria-pressed="false"
|
||||
class="text-fg hover:text-accent inline-flex size-10 cursor-pointer items-center justify-center rounded-sm transition-colors"
|
||||
>
|
||||
<Icon name="tabler:moon" class="size-6 dark:hidden" aria-hidden="true" />
|
||||
<Icon name="tabler:sun" class="hidden size-6 dark:block" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const root = document.documentElement;
|
||||
const sync = () => {
|
||||
const dark = root.dataset.theme === 'dark';
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-theme-toggle]').forEach((btn) => {
|
||||
btn.setAttribute('aria-pressed', String(dark));
|
||||
});
|
||||
};
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-theme-toggle]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
root.dataset.theme = next;
|
||||
try {
|
||||
localStorage.setItem('theme', next);
|
||||
} catch {}
|
||||
sync();
|
||||
});
|
||||
});
|
||||
new MutationObserver(sync).observe(root, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
sync();
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
// Dev-only style preview, injected at /styleguide/ by astro.config.mjs (never built for production).
|
||||
// Sample copy below is placeholder text for visual testing only.
|
||||
import Badge from '../components/Badge.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import Card from '../components/Card.astro';
|
||||
import Container from '../components/Container.astro';
|
||||
import ExternalLink from '../components/ExternalLink.astro';
|
||||
import GridLines from '../components/GridLines.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
const swatches = [
|
||||
['bg', 'bg-bg'],
|
||||
['bg-alt', 'bg-bg-alt'],
|
||||
['surface', 'bg-surface'],
|
||||
['fg', 'bg-fg'],
|
||||
['fg-muted', 'bg-fg-muted'],
|
||||
['accent', 'bg-accent'],
|
||||
['accent-strong', 'bg-accent-strong'],
|
||||
['accent-fill', 'bg-accent-fill'],
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Styleguide" noindex>
|
||||
<section lang="en" class="surface-alt relative overflow-hidden">
|
||||
<GridLines />
|
||||
<Container class="relative grid gap-10 py-20 md:grid-cols-[1.4fr_1.6fr_4.2fr]">
|
||||
<span aria-hidden="true" class="font-display text-paper hidden text-9xl font-black md:block">
|
||||
2
|
||||
</span>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="font-display text-2xl leading-tight font-bold">Two background colours</h2>
|
||||
<p class="text-sm">Paper ↔ Khaki sections, thin vertical grid lines.</p>
|
||||
<Button href="#" variant="link">
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
<SectionHeading
|
||||
level={1}
|
||||
eyebrow="Main"
|
||||
title="Content heavy designs require more contrast"
|
||||
/>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<section lang="en" class="py-16">
|
||||
<Container class="flex flex-col gap-10">
|
||||
<SectionHeading eyebrow="Tokens" title="Palette" />
|
||||
<ul class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{swatches.map(([name, cls]) => (
|
||||
<li class="border-line border">
|
||||
<div class:list={['h-16', cls]} />
|
||||
<p class="p-2 font-mono text-sm">{name}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<section lang="en" class="surface-alt py-16">
|
||||
<Container class="flex flex-col gap-8">
|
||||
<SectionHeading eyebrow="Feature" title="Buttons & badges on khaki" />
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<Button href="#" icon="tabler:arrow-right">
|
||||
Primary
|
||||
</Button>
|
||||
<Button href="#" variant="secondary">
|
||||
Secondary
|
||||
</Button>
|
||||
<Button>Button element</Button>
|
||||
<Button href="#" variant="link">
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge icon="tabler:refresh">Updated 16 Oct 2026</Badge>
|
||||
<Badge variant="outline" icon="tabler:calendar-plus">
|
||||
Created 1 Oct 2026
|
||||
</Badge>
|
||||
<Badge variant="category" href="#">
|
||||
Category
|
||||
</Badge>
|
||||
</div>
|
||||
<p>
|
||||
Inline{' '}
|
||||
<a href="#" class="decoration-accent underline decoration-2 underline-offset-4">
|
||||
link
|
||||
</a>{' '}
|
||||
and an{' '}
|
||||
<ExternalLink href="https://radio.org.tr" class="underline">
|
||||
external link
|
||||
</ExternalLink>
|
||||
.
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<section lang="en" class="py-16">
|
||||
<Container class="flex flex-col gap-8">
|
||||
<SectionHeading eyebrow="Cards" title="Cards on paper" level={2} />
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((n) => (
|
||||
<Card interactive>
|
||||
<div class="flex flex-1 flex-col gap-3 p-6">
|
||||
<Badge variant="category">Category {n}</Badge>
|
||||
<h3 class="text-xl font-bold">
|
||||
<a href="#" class="after:absolute after:inset-0">
|
||||
Card title {n}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-fg-muted text-sm">Muted supporting text for the card body.</p>
|
||||
<p class="text-accent-text mt-auto text-sm font-semibold">Small accent text</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Seo, { type Props as SeoProps } from '../components/Seo.astro';
|
||||
import ThemeInit from '../components/ThemeInit.astro';
|
||||
import { site } from '../lib/data';
|
||||
|
||||
export type Props = SeoProps;
|
||||
|
||||
const props = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={site.lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<ThemeInit />
|
||||
<meta name="theme-color" content="#f2efee" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#111012" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="alternate" type="application/rss+xml" title={site.title} href="/rss.xml" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<Seo {...props} />
|
||||
</head>
|
||||
<body class="flex min-h-dvh flex-col">
|
||||
<a
|
||||
href="#main"
|
||||
class="bg-fg text-bg sr-only z-50 px-4 py-2 font-bold focus:not-sr-only focus:fixed focus:top-2 focus:left-2"
|
||||
>
|
||||
{site.ui.skipToContent}
|
||||
</a>
|
||||
<Header />
|
||||
<main id="main" class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Typed, validated access to src/data/*.json.
|
||||
* A schema mismatch throws at import time, so `astro build` fails loudly.
|
||||
*/
|
||||
import { z } from 'astro/zod';
|
||||
import siteJson from '../data/site.json';
|
||||
import socialJson from '../data/social.json';
|
||||
|
||||
const link = z.object({ label: z.string().min(1), href: z.string().min(1) });
|
||||
|
||||
const siteSchema = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
url: z.url(),
|
||||
lang: z.string(),
|
||||
locale: z.string(),
|
||||
titleSeparator: z.string(),
|
||||
author: z.object({ name: z.string(), callsign: z.string(), locator: z.string() }),
|
||||
postsPerPage: z.number().int().positive(),
|
||||
nav: z.array(link).min(1),
|
||||
footer: z.object({
|
||||
about: z.string(),
|
||||
quickLinksTitle: z.string(),
|
||||
legalTitle: z.string(),
|
||||
socialTitle: z.string(),
|
||||
rss: link,
|
||||
legal: z.array(link),
|
||||
license: z.string(),
|
||||
signoff: z.string(),
|
||||
}),
|
||||
ui: z.object({
|
||||
skipToContent: z.string(),
|
||||
mainNav: z.string(),
|
||||
footerNav: z.string(),
|
||||
menuOpen: z.string(),
|
||||
menuClose: z.string(),
|
||||
themeToggle: z.string(),
|
||||
newTab: z.string(),
|
||||
home: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const socialSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
// Empty string = account not set → hidden in the UI.
|
||||
href: z.union([z.url(), z.literal('')]),
|
||||
icon: z.string().startsWith('tabler:'),
|
||||
handle: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new Error(`src/data/${name} is invalid:\n${z.prettifyError(result.error)}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const site = parse('site.json', siteSchema, siteJson);
|
||||
export const social = parse('social.json', socialSchema, socialJson).filter((s) => s.href);
|
||||
|
||||
export type Site = z.infer<typeof siteSchema>;
|
||||
export type SocialLink = (typeof social)[number];
|
||||
+9
-13
@@ -1,17 +1,13 @@
|
||||
---
|
||||
// Temporary placeholder; replaced by the real home page in stage 3.
|
||||
import '../styles/global.css';
|
||||
import Container from '../components/Container.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { site } from '../lib/data';
|
||||
---
|
||||
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>TA1SPH</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="p-8 text-4xl font-black">TA1SPH</h1>
|
||||
</body>
|
||||
</html>
|
||||
<BaseLayout>
|
||||
<Container class="py-20">
|
||||
<SectionHeading level={1} eyebrow={site.author.locator} title={site.name} />
|
||||
</Container>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,2 +1,122 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@fontsource-variable/rubik';
|
||||
@import '@fontsource-variable/inter';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
/*
|
||||
* Palette: Huemint "Magazine" — #f2efee #d5d09b #111012 #c95748
|
||||
* Contrast notes (WCAG): ink on red = 4.46 → only large/bold text (≥19px bold) on --accent.
|
||||
* Small text on a red fill uses --accent-fill + --on-accent-fill (5.3 light / 6.5 dark).
|
||||
* Small red text uses --accent-text, which falls back to --fg on khaki sections (red/khaki < 4.5).
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f2efee;
|
||||
--bg-alt: #d5d09b;
|
||||
--surface: #f8f6f5;
|
||||
--fg: #111012;
|
||||
--fg-muted: #4a474b;
|
||||
--line: color-mix(in oklab, #111012 18%, transparent);
|
||||
--accent: #c95748;
|
||||
--accent-strong: #a8412f;
|
||||
--on-accent: #111012;
|
||||
--accent-fill: #a8412f;
|
||||
--on-accent-fill: #f2efee;
|
||||
--accent-on-alt: var(--fg);
|
||||
--accent-text: var(--accent-strong);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--bg: #111012;
|
||||
--bg-alt: #1c1b17;
|
||||
--surface: #19181a;
|
||||
--fg: #ebe7e4;
|
||||
--fg-muted: #b3ad8a;
|
||||
--line: color-mix(in oklab, #d5d09b 16%, transparent);
|
||||
--accent: #e07a6b;
|
||||
--accent-strong: #e07a6b;
|
||||
--on-accent: #111012;
|
||||
--accent-fill: #e07a6b;
|
||||
--on-accent-fill: #111012;
|
||||
--accent-on-alt: var(--accent);
|
||||
--accent-text: var(--accent-strong);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-bg: var(--bg);
|
||||
--color-bg-alt: var(--bg-alt);
|
||||
--color-surface: var(--surface);
|
||||
--color-fg: var(--fg);
|
||||
--color-fg-muted: var(--fg-muted);
|
||||
--color-line: var(--line);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-strong: var(--accent-strong);
|
||||
--color-accent-text: var(--accent-text);
|
||||
--color-accent-fill: var(--accent-fill);
|
||||
--color-on-accent: var(--on-accent);
|
||||
--color-on-accent-fill: var(--on-accent-fill);
|
||||
--color-khaki: #d5d09b;
|
||||
--color-paper: #f2efee;
|
||||
--color-ink: #111012;
|
||||
--font-display: 'Rubik Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply bg-bg font-sans text-fg antialiased;
|
||||
scroll-padding-top: 5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-dvh text-[1.0625rem] leading-relaxed;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
@apply font-display;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@apply outline-3 outline-offset-2 outline-accent;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-accent text-on-accent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Khaki (alt) section: switches small red text to an accessible colour. */
|
||||
.surface-alt {
|
||||
@apply bg-bg-alt;
|
||||
--accent-text: var(--accent-on-alt);
|
||||
}
|
||||
|
||||
/* Magazine headline: heavy, uppercase, tight. */
|
||||
.headline {
|
||||
@apply font-display leading-[0.95] font-black tracking-tight uppercase;
|
||||
}
|
||||
|
||||
/* "Learn More" style link: thick underline that turns red on hover. */
|
||||
.link-strong {
|
||||
@apply font-display font-bold underline decoration-fg decoration-3 underline-offset-[0.35em] transition-colors hover:decoration-accent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user