Ortak bileşenler
Her oyunun kendi sitesi var, kendi yığını ve kendi paleti. Ortak olan tek şey yapımcı — bu sayfa onu görünür kılan bileşenleri hem tarif eder hem de çalışır halde verir.
Bu sayfadaki önizlemeler, kopyalanacak dosyanın ta kendisini kullanıyor. Önizleme bozuksa standart bozuktur.
Token sözleşmesi
Bileşen host'un token'larını bilmez. Üç değişken zorunlu; kalan her biri onlardan türetilir. Host eşlemeyi bir kez yazar, dört bileşeni birden alır.
--pmf-bgthe footer’s ground--pmf-fgprimary ink; the hairlines derive from it--pmf-accentthe game’s signature — not brass
--pmf-fg-mutedcolor-mix(in oklab, fg 72%, bg)--pmf-fg-subtlecolor-mix(in oklab, fg 56%, bg)--pmf-surfacecolor-mix(in oklab, fg 4%, bg)--pmf-line-softfg @ 9%--pmf-linefg @ 13%--pmf-line-strongfg @ 18%--pmf-line-tickfg @ 32%--pmf-radius0--pmf-hover-shadownone--pmf-focus-shadownone--pmf-font-sansinherit--pmf-font-monoui-monospace, …--pmf-scale1--pmf-maxw1180px
--pmf-brass#c8892f.pmf__pm-plate#131211 / #e8e3d8 / radius 0
/* Once, in the game's own stylesheet. */
.pmf,
.pmf-topbar,
.pmf-badge,
.pmf-legalbar {
--pmf-bg: var(--sac-bg);
--pmf-fg: var(--sac-fg);
--pmf-accent: var(--sac-success);
--pmf-radius: var(--sac-radius);
--pmf-font-sans: var(--sac-display);
--pmf-font-mono: var(--sac-mono);
/* Opaque mode: --sac-border is a colour, not an alpha, so all steps are set. */
--pmf-line-soft: #16211c;
--pmf-line: var(--sac-border);
--pmf-line-strong: #2a3b33;
--pmf-line-tick: #3d5449;
/* Shadow marks interaction, never presence. */
--pmf-hover-shadow: var(--sac-glow-green);
--pmf-focus-shadow: var(--sac-glow-green);
}Değere değil, referansa bağla. Özel değişkenler kullanım anında çözülür — referans yazınca host'un tema varyantları footer'a kendiliğinden akar. Sabit yazınca footer varsayılan temada donar ve biri anahtarı çevirene kadar kusursuz görünür.
Alt bilgi — 5·3·4
Tam beyan. Oyunun kimliği beş şeritte, iki menü üçte, yapımcı dörtte; altında telif ve yasal bağlantılar.
Üst bant
Oyunun kendi başlığının üstünde saç teli bir şerit. Bilinçli olarak sessiz: yapımcıyı söyler ve çekilir.
Marka rozeti
Tek satır. Hero altına ya da başlığın yanına. Bu ölçekte imi taşıyan şey kelime imi değil, nişanın kendisi.
Yasal şerit
Tam alt bilginin ağır kaldığı sayfalar için telif satırı tek başına. Alt bilgideki bandın birebir aynısı.
Bir oyuna takmak
Dosyaları kopyala, tokenları eşle, veriyi kendi i18n ve rota kurucularından üret. Standart hiçbir zaman URL kurmaz.
/*
* pmf — the data contract, v1.
*
* ONE object. The hardest constraint on it, and the one everything else falls
* out of: `PmFooterData` is 100% JSON-serializable. Anything that is not — an
* SVG node — travels through a named slot instead, never through the object.
*
* That constraint is what lets the same shape be produced by a react-i18next
* call, by MDX frontmatter, or by a build script reading remote JSON. It is
* also what lets the reference DOM be generated from a fixture file, which is
* how three renderers are held to one output.
*
* ── Resolved strings only ───────────────────────────────────────────────────
* Every human-readable field is a resolved string. No translation keys, no
* `t()` inside the component, no i18n dependency of any kind.
*
* This is the only shape that works across the network: SilkWard and SysAdmin
* Crisis resolve at runtime against a loaded react-i18next namespace, while
* Regnarium resolves at build time against a content tree. A key-based schema
* would force one of them to invent a dictionary it does not have.
*
* ── The standard never builds a URL ─────────────────────────────────────────
* Every `href` arrives ready. This is `path.*` from src/i18n/index.ts applied
* across repositories: each site's own path builder stays the single source of
* truth and the footer is purely a consumer.
*
* It is also what makes the route divergence a non-problem. The footer never
* learns that SilkWard and Regnarium use `[locale]` while SysAdmin Crisis uses
* `[lang]`, or that two sites put legal pages at the root while the third
* nests them under `legal/`.
*/
export interface PmFooterData {
version: 1;
/** BCP-47. Used for `hreflang` on cross-origin links and for `dir` — never for copy. */
locale: string;
dir?: 'ltr' | 'rtl';
game: GameIdentity;
socials: SocialLink[];
stores: StoreLinks;
discover: MenuColumn;
network: NetworkColumn;
studio: StudioBlock;
legal: FooterLink[];
/** Fully resolved, year already interpolated at build. See the note in README.md. */
copyright: string;
a11y: A11yStrings;
}
export interface GameIdentity {
/**
* Must match an id in the network registry.
*
* This is checked as a BUILD ERROR, not a warning — see `checkNetwork()` in
* README.md. A misspelt id makes the self-exclusion filter match nothing, so
* the game links to itself in its own footer, with a link that works
* perfectly and therefore never gets reported.
*/
id: string;
name: string;
/** The game's own home — where the wordmark links. */
href: string;
/** Two or three sentences. Resolved. */
description: string;
wordmark: Wordmark;
}
/**
* Three variants, and the choice between them is not stylistic.
*
* - `text` — SilkWard and SysAdmin Crisis have no logo asset. This IS the logo.
* - `img` — a same-origin path, legal under `img-src 'self'`. The right form
* for Regnarium's `brand/lockup-yatay.svg`: cacheable, no markup
* weight. `width`/`height` are mandatory, against layout shift.
* - `slot` — metadata only; the `<svg>` arrives through the `game-logo` slot.
* Use this and only this when the mark must inherit `currentColor`.
* The data object stays serializable because the node never enters it.
*/
export type Wordmark =
| { kind: 'text' }
| { kind: 'img'; src: string; width: number; height: number; alt: string }
| { kind: 'slot'; width: number; height: number; title: string };
/**
* Icons are hand-drawn on the 26px grid at 1.2px stroke — the same convention as
* `src/components/schematic/PrincipleIcon.astro`. They ship as an inline sprite
* emitted by the component itself.
*
* Not an icon font, not a CDN package, and deliberately NOT an external
* `<use href="/sprite.svg#id">`: external `use` is subject to fetch rules, and
* every site in this network runs `default-src 'self'`.
*/
export type SocialPlatform =
| 'x'
| 'bluesky'
| 'mastodon'
| 'discord'
| 'github'
| 'youtube'
| 'itch'
| 'reddit'
| 'steam'
| 'linkedin'
| 'email'
| 'rss';
export interface SocialLink {
platform: SocialPlatform;
href: string;
/** Resolved, becomes the accessible name — "SilkWard on Bluesky", not "Bluesky". */
label: string;
/** Defaults to `me noopener`. */
rel?: string;
}
/**
* Either store may be absent and the row collapses; both absent and the block is
* omitted with no reserved gap.
*
* Badges resolve to `${basePath}appstore-${badgeLocale ?? locale}.svg` and
* `${basePath}googleplay-${badgeLocale ?? locale}.svg`. Both vendors publish
* localised artwork, and under `img-src 'self'` every locale you use must be
* committed locally.
*
* `badgeLocale` is separate from `locale` because Apple and Google use different
* locale code sets, and neither matches your route params.
*/
export interface StoreLinks {
ios?: { href: string; badgeLocale?: string };
android?: { href: string; badgeLocale?: string };
/** Defaults to `/standard/`. */
basePath?: string;
}
export interface MenuColumn {
/** Column heading. Resolved. */
label: string;
links: FooterLink[];
}
export interface FooterLink {
href: string;
label: string;
external?: boolean;
rel?: string;
/** Renders `aria-current="page"`. */
current?: boolean;
}
export interface NetworkColumn {
label: string;
items: NetworkItem[];
/** Resolved marker for an unreleased sibling. Required if any item is `soon`. */
soonLabel?: string;
}
export interface NetworkItem {
id: string;
name: string;
href: string;
/**
* The sibling game's own signature colour, rendered as a 6px dot — the one
* place a foreign hue is allowed inside the footer.
*/
signature?: string;
/** `soon` renders a muted mono marker instead of a live link treatment. */
status?: 'live' | 'soon';
}
/**
* Almost all constant, so almost all of it is a package export rather than host
* data — see `PM_STUDIO` in `pmf-data.ts`. Only the blurb and the two labels are
* per-locale, and therefore only they live here.
*/
export interface StudioBlock {
/** One or two sentences. Resolved. */
blurb: string;
/** Resolved label for the link to the Parva Machina about page. */
aboutLabel: string;
/** Resolved eyebrow above the lockup. Defaults to `// PARVA MACHINA`. */
eyebrow?: string;
/**
* Which Parva Machina locale to link to.
*
* Parva Machina ships `tr` and `en`; a game may carry locales it does not.
* Resolve with `pmLocaleFor()`, which maps by primary subtag and falls back
* to `en` — never link a visitor to a 404 to preserve a locale prefix.
*/
pmLocale?: string;
}
export interface A11yStrings {
footerLabel: string;
discoverLabel: string;
networkLabel: string;
legalLabel: string;
socialsLabel: string;
pmSocialsLabel: string;
}import { PmFooter } from '@/standard/PmFooter';
import { assertKnownGame, selectNetwork } from '@/standard/pmf-data';
import registry from '@/standard/network.json';
assertKnownGame(registry, 'sysadmin');
export function SiteFooter({ lang }: { lang: string }) {
const { t } = useTranslation();
return (
<PmFooter
data={{
version: 1,
locale: lang,
game: {
id: 'sysadmin',
name: 'SysAdmin Crisis',
href: localePath(lang, ''),
description: t('site.footer.pitch'),
wordmark: { kind: 'text' },
},
socials: [{ platform: 'github', href: '…', label: t('site.social.github') }],
stores: {},
discover: {
label: t('site.footer.discover'),
links: [
{ href: localePath(lang, 'about'), label: t('site.nav.about') },
{ href: localePath(lang, 'contact'), label: t('site.nav.contact') },
],
},
network: {
label: t('site.footer.network'),
soonLabel: t('site.footer.soon'),
items: selectNetwork(registry, 'sysadmin'),
},
studio: {
blurb: t('site.footer.studio'),
aboutLabel: t('site.footer.studio_about'),
},
legal: LEGAL_LINKS.map((l) => ({
href: localePath(lang, l.path),
label: t(l.key),
})),
copyright: t('site.footer.rights', { year: BUILD_YEAR }),
a11y: { /* … */ },
}}
/>
);
}/*
* pmf — Parva Machina brand footer standard
* v1.0.0
*
* ONE file. Plain CSS, zero dependencies, zero build step. Copy it verbatim
* into a game site and map the token contract; nothing else is required.
*
* This file is the standard. The `/components` page on parvamachina.com does
* not re-express it in Tailwind — it imports THIS file and prints THIS file's
* source. If the preview looks wrong, the standard is wrong. Drift is
* structurally impossible, which is the only reason a standard survives four
* repositories and one maintainer.
*
* ── The contract ────────────────────────────────────────────────────────────
* Three variables are required. Everything else derives from them:
*
* .pmf { --pmf-bg: …; --pmf-fg: …; --pmf-accent: …; }
*
* Map them BY REFERENCE, never by value:
*
* .pmf { --pmf-accent: var(--sac-success); } ← correct
* .pmf { --pmf-accent: #5ef08a; } ← wrong
*
* Custom properties resolve at use time, so a reference makes every one of the
* host's theme variants (`data-theme`, `data-contrast`, `data-font`) flow into
* the footer for free. A literal freezes the footer in the default theme — and
* it looks perfectly fine until someone flips the switch, which is why this is
* the first thing written down.
*
* ── Namespace ───────────────────────────────────────────────────────────────
* `pmf-`, not `pm-`: parvamachina.com already owns `.pm-nisan`, `.pm-cetvel`
* and `.pm-izgara`, and SysAdmin Crisis owns `sac-`. Every selector here is a
* single class, unlayered, and nothing outside `.pmf` is ever targeted.
*/
:where(.pmf, .pmf-topbar, .pmf-badge, .pmf-legalbar) {
/* ── Tier 1: required ───────────────────────────────────────────────── */
--pmf-bg: #131211;
--pmf-fg: #e8e3d8;
--pmf-accent: #c8892f;
/* ── Tier 2: derived, all overridable ───────────────────────────────── */
--pmf-fg-muted: color-mix(in oklab, var(--pmf-fg) 72%, var(--pmf-bg));
--pmf-fg-subtle: color-mix(in oklab, var(--pmf-fg) 56%, var(--pmf-bg));
--pmf-surface: color-mix(in oklab, var(--pmf-fg) 4%, var(--pmf-bg));
--pmf-accent-contrast: var(--pmf-bg);
/*
* The hairline ramp. parvamachina.com carries eight steps because that page
* has eight kinds of surface; a footer has three — inner dividers, block
* separators, cell edges.
*
* ALPHA MODE (default): the ramp is one ink plus three scalars. With the
* default ink and alphas, `--pmf-line` resolves to exactly
* rgb(232 227 216 / 0.13) — the STANDARD hairline in global.css, byte for
* byte. The default state of this file IS the Parva Machina design.
*
* OPAQUE MODE (opt-in): a host whose border token is an opaque colour —
* SysAdmin Crisis's `--sac-border: #1e2b25` — must set all THREE steps
* explicitly. Do NOT derive them by mixing toward the ground: mixing a
* translucent colour with an opaque one RAISES alpha, so `line-soft` comes
* out heavier than `line`. That reads as "almost right", which is worse
* than broken. Setting fewer than three steps is a spec violation.
*/
--pmf-line-ink: var(--pmf-fg);
--pmf-line-a-soft: 0.09;
--pmf-line-a: 0.13;
--pmf-line-a-strong: 0.18;
--pmf-line-soft: color-mix(in srgb, var(--pmf-line-ink) calc(var(--pmf-line-a-soft) * 100%), transparent);
--pmf-line: color-mix(in srgb, var(--pmf-line-ink) calc(var(--pmf-line-a) * 100%), transparent);
--pmf-line-strong: color-mix(in srgb, var(--pmf-line-ink) calc(var(--pmf-line-a-strong) * 100%), transparent);
/* The registration-mark / hover-edge step. Opaque mode must set this too. */
--pmf-line-tick: color-mix(in srgb, var(--pmf-line-ink) 32%, transparent);
/*
* Brass. LOCKED — not part of the host mapping, not overridable.
*
* It governs exactly four things, all inside the Parva Machina column: the
* mark's frame and centre dot, the mono eyebrow, the about-link underline,
* and the network heading tick. A publisher's mark that recolours per host
* is not a mark, it is decoration.
*
* It is also QUARANTINED to that column, so it reads as a colophon stamped
* onto the page rather than as a second accent leaking through the footer.
* Contrast is fine where it matters: #c8892f on #07090b is 6.72:1.
*/
--pmf-brass: #c8892f;
/* ── Shape ──────────────────────────────────────────────────────────── */
/*
* Radius is INHERITED — a host with rounded corners keeps them, on the four
* elements listed at the bottom of this file. It never reaches hairline
* rules, grid gaps, or store badges (see `.pmf__store img`).
*/
--pmf-radius: 0;
/*
* Shadow is REFUSED at rest, on every host. Depth here is line weight and
* ground tone. A glowing footer competes with the game above it, and every
* glow a host owns is tuned for a different surface. The two hooks below
* are interaction-only: shadow marks interaction, never presence.
*/
--pmf-hover-shadow: none;
--pmf-focus-shadow: none;
/* ── Type island ────────────────────────────────────────────────────── */
/*
* `.pmf` sets its own font sizes. It cannot use the `.t-*` ramp, which
* exists only on parvamachina.com. Every step routes through `--pmf-scale`
* so a host's font-size preference reaches all six at once.
*/
--pmf-font-sans: inherit;
--pmf-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--pmf-scale: 1;
--pmf-t-wordmark: calc(14px * var(--pmf-scale));
--pmf-t-body: calc(14.5px * var(--pmf-scale));
--pmf-t-link: calc(14px * var(--pmf-scale));
--pmf-t-label: calc(11px * var(--pmf-scale));… + 866
/*
* pmf — the React reference implementation.
*
* For the three Next.js game sites. Server-component safe: no hooks, no state,
* no effects, no `'use client'`. It renders in the RSC pass and ships zero
* JavaScript, which matters on sites whose whole claim is that they make no
* requests after the first load.
*
* ── Installing ──────────────────────────────────────────────────────────────
* 1. Copy this file, `pmf.css`, `pmf-types.ts`, `pmf-data.ts`, `pmf-icons.ts`
* into the game's own tree.
* 2. `import './pmf.css'` once, in the root layout.
* 3. Write the token mapping — see README.md §2.
* 4. Build `PmFooterData` from your own i18n and path builders.
*
* ── The one rule that will bite ─────────────────────────────────────────────
* This file is a MIRROR of `pmf-render.ts`, which is normative. If you change
* the markup here, change it there, and run `npm run standard:check` on
* parvamachina.com — it diffs the two and fails on any difference. Fixing a bug
* in one renderer and not the other is how a standard quietly stops being one.
*/
import type { JSX } from 'react';
import type { FooterLink, PmFooterData, SocialLink } from './pmf-types';
import { PMF_ARROW, PMF_MARK, PM_STUDIO, pmLocaleFor } from './pmf-data';
import { PMF_ICONS } from './pmf-icons';
export interface PmFooterProps {
data: PmFooterData;
/**
* The inline `<svg>` for a `wordmark.kind === 'slot'` logo — the only case
* where the mark must inherit `currentColor`. Everything else goes through
* the data object.
*/
gameLogo?: JSX.Element;
}
function Sprite({ data }: { data: PmFooterData }) {
const ids = [
...new Set([
...data.socials.map((social) => social.platform),
...PM_STUDIO.socials.map((social) => social.platform),
]),
];
return (
<svg className="pmf__sprite" aria-hidden="true">
{ids.map((id) => (
<symbol
key={id}
id={`pmf-i-${id}`}
viewBox="0 0 26 26"
fill="none"
stroke="currentColor"
strokeWidth={1.2}
dangerouslySetInnerHTML={{ __html: PMF_ICONS[id] }}
/>
))}
</svg>
);
}
function Socials({ socials, label }: { socials: readonly SocialLink[]; label: string }) {
if (socials.length === 0) return null;
return (
<ul className="pmf__socials" role="list" aria-label={label}>
{socials.map((social) => (
<li key={social.href}>
<a
className="pmf__social"
href={social.href}
rel={social.rel ?? 'me noopener'}
aria-label={social.label}
>
<svg aria-hidden="true">
<use href={`#pmf-i-${social.platform}`} />
</svg>
</a>
</li>
))}
</ul>
);
}
function LinkItems({ links }: { links: FooterLink[] }) {
return (
<>
{links.map((link) => (
<li key={link.href}>
<a
href={link.href}
rel={link.rel ?? (link.external ? 'noopener' : undefined)}
aria-current={link.current ? 'page' : undefined}
>
{link.label}
</a>
</li>
))}
</>
);
}
function Wordmark({ data, gameLogo }: { data: PmFooterData; gameLogo?: JSX.Element }) {
const mark = data.game.wordmark;
if (mark.kind === 'img') {
return (
<img
className="pmf__logo"
src={mark.src}
alt={mark.alt}
width={mark.width}
height={mark.height}
/>
);
}
if (mark.kind === 'slot' && gameLogo) return gameLogo;
return <span className="pmf__wordmark">{data.game.name}</span>;
}
function Stores({ data }: { data: PmFooterData }) {
const { ios, android } = data.stores;
if (!ios && !android) return null;… + 174
# pmf — Parva Machina brand components
Four components that make the publisher visible on a game's own site, in a form
that survives four repositories, three stacks and three unrelated palettes.
| Class | What | Where |
| --- | --- | --- |
| `.pmf` | the 5·3·4 footer and its copyright bar | game landing page, bottom |
| `.pmf-topbar` | a hairline publisher strip | above the game's own header |
| `.pmf-badge` | one-line signature | under a hero, beside a title |
| `.pmf-legalbar` | the copyright line alone | where the full footer is too heavy |
## 1. Copy
```
pmf.css the standard — every visual decision is here
pmf-types.ts the data contract
pmf-data.ts constants and the three helpers
pmf-icons.ts the hand-drawn social set
pmf-render.ts the normative renderer (framework-free)
PmFooter.tsx the React mirror, for the Next.js sites
```
Copy all six. Then `import './pmf.css'` once, in the root layout.
## 2. Map the tokens
Three variables are required; everything else derives. Write the mapping once:
```css
.pmf,
.pmf-topbar,
.pmf-badge,
.pmf-legalbar {
--pmf-bg: var(--your-ground);
--pmf-fg: var(--your-ink);
--pmf-accent: var(--your-signature);
}
```
**By reference, never by value.** Custom properties resolve at use time, so a
reference makes your theme variants — `data-theme`, `data-contrast`,
`data-font` — reach the footer with no extra rules. A literal freezes it in the
default theme, and it looks perfectly fine until someone flips the switch.
If your border token is an **opaque colour** rather than an alpha, set all four
hairline steps explicitly: `--pmf-line-soft`, `--pmf-line`, `--pmf-line-strong`,
`--pmf-line-tick`. Do not derive them by mixing toward the ground — mixing a
translucent colour with an opaque one raises alpha, so `line-soft` comes out
heavier than `line`.
## 3. Build the data
`PmFooterData` takes **resolved strings**, never translation keys, and every
`href` arrives ready. The standard never constructs a URL: your own path builder
stays the single source of truth, which is why route shapes can differ across
the network without the footer ever learning about it.
## 4. Wire the network
```bash
node scripts/sync-network.mjs # prebuild
```
The script fetches `https://parvamachina.com/network.json`, writes a gitignored
generated copy, and leaves the committed `network.fallback.json` in place as the
offline path. A failed fetch is a warning, never a failed release.
`assertKnownGame(registry, ownId)` is the one hard failure in the standard, and
it is deliberate: a misspelt id makes self-exclusion match nothing, so the game
links to itself in its own footer with a link that works perfectly — and is
therefore never reported. A stale sibling tagline is invisible; a self-link is
an embarrassment.
## 5. Store badges
Commit Apple's and Google's official artwork per locale to your own `public/`
directory — `img-src 'self'` rules out hot-linking. Name them
`appstore-<locale>.svg` and `googleplay-<locale>.svg`.
**Render them untouched.** No radius, no filter, no opacity, no recolour. Apple's
guidelines mandate a minimum size and clear space; Google's mandate no
modification. `--pmf-radius` deliberately does not reach the image, because
inheriting a host radius onto an Apple badge is exactly the accident the rule
exists to prevent.
## 6. What is yours, and what is not
**Yours.** Every token in the contract. Which components you use. Which links go
in either menu. Whether the store row exists at all.… + 11