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:
2026-10-16 00:47:50 +03:00
parent c5e0b009ca
commit d8a79ccbe3
19 changed files with 915 additions and 36 deletions
+25 -18
View File
@@ -1,13 +1,13 @@
# PROJECT_MEMORY — ta1sph.info
> Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur.
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 1)
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 2)
## 1. Durum
- **Aşama:** 1 tamamlandı (iskelet). Sıradaki: **Aşama 2 — Tasarım sistemi ve layout**.
- **Tamamlanan:** Aşama 0 (ön kontrol), Aşama 1 (entegrasyonlar, script'ler, Prettier, hafıza dosyaları repoda).
- **Açık işler:** Aşama 2–8 (`references/build-plan.md`). `LICENSE` / `LICENSE-CONTENT` Aşama 6'da (README şimdiden bağlantı veriyor).
- **Aşama:** 2 tamamlandı (tasarım sistemi + layout). Sıradaki: **Aşama 3 — Ana Sayfa**.
- **Tamamlanan:** Aşama 0, 1, 2. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`.
- **Açık işler:** Aşama 3–8. Footer'daki yasal sayfalar ve `/rss.xml` henüz yok (Aşama 4/6). Favicon hâlâ Astro varsayılanı (Aşama 7). `LICENSE` dosyaları Aşama 6.
- **Bilinen sorunlar:** —
## 2. Ortam
@@ -21,7 +21,11 @@
## 3. Dosya Haritası
- `astro.config.mjs` — site/trailingSlash/static + node adapter, mdx, sitemap (`/_styleguide/` hariç), astro-icon (tabler), Tailwind vite plugin
- `src/styles/global.css` — Tailwind girişi + typography plugin (token'lar Aşama 2'de)
- `src/styles/global.css` — tema token'ları (light/dark, `data-theme`), fontlar, `.surface-alt` (khaki bölüm), `.headline`, `.link-strong`, reduced-motion
- `src/data/site.json` (ad, açıklama, nav, footer, `ui` metinleri, postsPerPage) · `src/data/social.json` (boş `href` → gizli) · `src/lib/data.ts` (zod; hata build'i kırar)
- `src/layouts/BaseLayout.astro` — head (ThemeInit, Seo, RSS alternate), skip link, Header, `main#main`, Footer
- `src/components/` — `Seo` (title/desc/canonical/OG/Twitter + WebSite/Person JSON-LD graph, `jsonLd` prop'u ile ek düğüm), `ThemeInit` (FOUC önleyici inline), `ThemeToggle`, `Header` (mobil menü = native `popover`, JS yok), `Footer` (4 sütun), `Container`, `GridLines`, `SectionHeading` (eyebrow + kırmızı çizgi), `Button` (primary/secondary/link), `Badge` (solid/outline/category), `Card`, `ExternalLink`
- `src/dev/Styleguide.astro` — `astro.config.mjs`'deki `devRoutes` entegrasyonu ile sadece `astro dev`'de `/styleguide/` (`_` önekli sayfalar Astro'da rota olmaz)
- `src/lib/slug.ts` — Türkçe karakter dönüştürücü `slugify`
- `scripts/new-post.mjs` — `npm run new:post -- "Başlık"` → `src/content/blog/<slug>/index.md` (draft: true); `slug.ts`'yi Node type-stripping ile import eder
- `src/pages/index.astro` — geçici yer tutucu (Aşama 3'te değişecek)
@@ -30,19 +34,21 @@
## 4. Kararlar (ADR)
| Tarih | Karar | Gerekçe |
| ---------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 2026-10-15 | Statik çıktı + `@astrojs/node` ile sadece `/api/views` dinamik | "Öne çıkan = en çok okunan" için sayaç gerekli; site geri kalanı statik ve hızlı |
| 2026-10-15 | Sayaç: SQLite (`better-sqlite3`), volume `/app/data` | Harici servis yok, ev sunucusunda basit ve kalıcı |
| 2026-10-15 | Arama: Pagefind | Statik, sunucusuz, Türkçe destekli |
| 2026-10-15 | Harita: Leaflet + OpenStreetMap | API anahtarı / ücret yok, KVKK dostu |
| 2026-10-15 | İkonlar: astro-icon + Tabler | Tutarlı çizgi seti, anten kulesi ve marka ikonları mevcut |
| 2026-10-15 | Lisans: Kod MIT, içerik CC BY-SA 4.0 | Kaynak gösterilerek serbest kullanım talebi |
| 2026-10-15 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme |
| 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik |
| 2026-10-16 | Pagefind CLI postbuild (`astro-pagefind` yok) | Daha az bağımlılık; node adapter çıktısı `dist/client` |
| 2026-10-16 | Skill repoya kopyalanmadı, kullanıcı düzeyinde | Çift kopya senkron riski |
| 2026-10-15 | Locator KN41KB99HW → 41.08307 N, 28.91094 E | Harita merkezi |
| Tarih | Karar | Gerekçe |
| ---------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| 2026-10-15 | Statik çıktı + `@astrojs/node` ile sadece `/api/views` dinamik | "Öne çıkan = en çok okunan" için sayaç gerekli; site geri kalanı statik ve hızlı |
| 2026-10-15 | Sayaç: SQLite (`better-sqlite3`), volume `/app/data` | Harici servis yok, ev sunucusunda basit ve kalıcı |
| 2026-10-15 | Arama: Pagefind | Statik, sunucusuz, Türkçe destekli |
| 2026-10-15 | Harita: Leaflet + OpenStreetMap | API anahtarı / ücret yok, KVKK dostu |
| 2026-10-15 | İkonlar: astro-icon + Tabler | Tutarlı çizgi seti, anten kulesi ve marka ikonları mevcut |
| 2026-10-15 | Lisans: Kod MIT, içerik CC BY-SA 4.0 | Kaynak gösterilerek serbest kullanım talebi |
| 2026-10-15 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme |
| 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik |
| 2026-10-16 | Ek token'lar: `accent-fill`/`on-accent-fill`, `accent-text` (khaki'de fg'ye döner) | Ölçüm: ink/red 4.46, red-strong/khaki 3.85 → küçük metin AA'yı geçmiyordu. Kırmızı zeminli butonlar ≥19px bold |
| 2026-10-16 | Mobil menü native `popover` | Escape/odak/light-dismiss JS'siz |
| 2026-10-16 | Pagefind CLI postbuild (`astro-pagefind` yok) | Daha az bağımlılık; node adapter çıktısı `dist/client` |
| 2026-10-16 | Skill repoya kopyalanmadı, kullanıcı düzeyinde | Çift kopya senkron riski |
| 2026-10-15 | Locator KN41KB99HW → 41.08307 N, 28.91094 E | Harita merkezi |
## 5. İçerik Envanteri
@@ -52,6 +58,7 @@
## 6. Değişiklik Günlüğü (en yeni üstte)
- 2026-10-16 | Siteyi Kur A2 | Token'lar, tema anahtarı, SEO bileşeni, layout, header/footer, temel bileşenler, site/social JSON, dev styleguide; axe temiz (2 tema) | `src/styles/`, `src/components/`, `src/layouts/`, `src/data/`, `src/lib/data.ts`, `src/dev/`, `astro.config.mjs`
- 2026-10-16 | Siteyi Kur A1 | İskelet: Tailwind 4, mdx, sitemap, node, icon, rss, fontlar, pagefind, prettier, new:post | `astro.config.mjs`, `package.json`, `scripts/`, `src/lib/slug.ts`, `src/styles/`, `README.md`
- 2026-10-16 | Siteyi Kur A0 | Ortam kontrolü; hafıza + docs kitten projeye kopyalandı | `CLAUDE.md`, `PROJECT_MEMORY.md`, `docs/`
- 2026-10-15 | Kurulum | Skill, CLAUDE.md ve PROJECT_MEMORY.md eklendi | `.claude/`, `CLAUDE.md`, `PROJECT_MEMORY.md`
+15 -5
View File
@@ -7,6 +7,20 @@ import sitemap from '@astrojs/sitemap';
import node from '@astrojs/node';
import icon from 'astro-icon';
/**
* Dev-only routes (style preview); never part of a production build.
* @type {import('astro').AstroIntegration}
*/
const devRoutes = {
name: 'dev-routes',
hooks: {
'astro:config:setup': ({ command, injectRoute }) => {
if (command !== 'dev') return;
injectRoute({ pattern: '/styleguide', entrypoint: './src/dev/Styleguide.astro' });
},
},
};
// https://astro.build/config
export default defineConfig({
site: 'https://ta1sph.info',
@@ -14,11 +28,7 @@ export default defineConfig({
// Static by default; only routes with `export const prerender = false` (view counter API) run on the server.
output: 'static',
adapter: node({ mode: 'standalone' }),
integrations: [
mdx(),
sitemap({ filter: (page) => !page.includes('/_styleguide/') }),
icon({ include: { tabler: ['*'] } }),
],
integrations: [mdx(), sitemap(), icon({ include: { tabler: ['*'] } }), devRoutes],
vite: {
plugins: [tailwindcss()],
},
+36
View File
@@ -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>
+41
View File
@@ -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>
)}
+23
View File
@@ -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>
+14
View File
@@ -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>
+25
View File
@@ -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>
+94
View File
@@ -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>
+19
View File
@@ -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>
+88
View File
@@ -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>
+30
View File
@@ -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>
+85
View File
@@ -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)} />
+26
View File
@@ -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>
+37
View File
@@ -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>
+120
View File
@@ -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>
+41
View File
@@ -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>
+67
View File
@@ -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
View File
@@ -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>
+120
View File
@@ -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;
}
}