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:
+25
-18
@@ -1,13 +1,13 @@
|
|||||||
# PROJECT_MEMORY — ta1sph.info
|
# 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.
|
> 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
|
## 1. Durum
|
||||||
|
|
||||||
- **Aşama:** 1 tamamlandı (iskelet). Sıradaki: **Aşama 2 — Tasarım sistemi ve layout**.
|
- **Aşama:** 2 tamamlandı (tasarım sistemi + layout). Sıradaki: **Aşama 3 — Ana Sayfa**.
|
||||||
- **Tamamlanan:** Aşama 0 (ön kontrol), Aşama 1 (entegrasyonlar, script'ler, Prettier, hafıza dosyaları repoda).
|
- **Tamamlanan:** Aşama 0, 1, 2. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`.
|
||||||
- **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çı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:** —
|
- **Bilinen sorunlar:** —
|
||||||
|
|
||||||
## 2. Ortam
|
## 2. Ortam
|
||||||
@@ -21,7 +21,11 @@
|
|||||||
## 3. Dosya Haritası
|
## 3. Dosya Haritası
|
||||||
|
|
||||||
- `astro.config.mjs` — site/trailingSlash/static + node adapter, mdx, sitemap (`/_styleguide/` hariç), astro-icon (tabler), Tailwind vite plugin
|
- `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`
|
- `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
|
- `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)
|
- `src/pages/index.astro` — geçici yer tutucu (Aşama 3'te değişecek)
|
||||||
@@ -30,19 +34,21 @@
|
|||||||
|
|
||||||
## 4. Kararlar (ADR)
|
## 4. Kararlar (ADR)
|
||||||
|
|
||||||
| Tarih | Karar | Gerekçe |
|
| 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 | 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 | 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 | Arama: Pagefind | Statik, sunucusuz, Türkçe destekli |
|
||||||
| 2026-10-15 | Harita: Leaflet + OpenStreetMap | API anahtarı / ücret yok, KVKK dostu |
|
| 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 | İ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 | 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 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme |
|
||||||
| 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik |
|
| 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 | 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 | Skill repoya kopyalanmadı, kullanıcı düzeyinde | Çift kopya senkron riski |
|
| 2026-10-16 | Mobil menü native `popover` | Escape/odak/light-dismiss JS'siz |
|
||||||
| 2026-10-15 | Locator KN41KB99HW → 41.08307 N, 28.91094 E | Harita merkezi |
|
| 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
|
## 5. İçerik Envanteri
|
||||||
|
|
||||||
@@ -52,6 +58,7 @@
|
|||||||
|
|
||||||
## 6. Değişiklik Günlüğü (en yeni üstte)
|
## 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 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-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`
|
- 2026-10-15 | Kurulum | Skill, CLAUDE.md ve PROJECT_MEMORY.md eklendi | `.claude/`, `CLAUDE.md`, `PROJECT_MEMORY.md`
|
||||||
|
|||||||
+15
-5
@@ -7,6 +7,20 @@ import sitemap from '@astrojs/sitemap';
|
|||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
import icon from 'astro-icon';
|
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
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://ta1sph.info',
|
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.
|
// Static by default; only routes with `export const prerender = false` (view counter API) run on the server.
|
||||||
output: 'static',
|
output: 'static',
|
||||||
adapter: node({ mode: 'standalone' }),
|
adapter: node({ mode: 'standalone' }),
|
||||||
integrations: [
|
integrations: [mdx(), sitemap(), icon({ include: { tabler: ['*'] } }), devRoutes],
|
||||||
mdx(),
|
|
||||||
sitemap({ filter: (page) => !page.includes('/_styleguide/') }),
|
|
||||||
icon({ include: { tabler: ['*'] } }),
|
|
||||||
],
|
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
// 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">
|
<BaseLayout>
|
||||||
<head>
|
<Container class="py-20">
|
||||||
<meta charset="utf-8" />
|
<SectionHeading level={1} eyebrow={site.author.locator} title={site.name} />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
</Container>
|
||||||
<meta name="viewport" content="width=device-width" />
|
</BaseLayout>
|
||||||
<meta name="generator" content={Astro.generator} />
|
|
||||||
<title>TA1SPH</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 class="p-8 text-4xl font-black">TA1SPH</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,2 +1,122 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import '@fontsource-variable/rubik';
|
||||||
|
@import '@fontsource-variable/inter';
|
||||||
@plugin '@tailwindcss/typography';
|
@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