Setup
This commit is contained in:
+2
-20
@@ -16,26 +16,8 @@
|
||||
<span class="edition">The 2026 edition</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="intro" aria-labelledby="page-title">
|
||||
<p class="eyebrow"><span aria-hidden="true">★</span> Friends. Friendly competition. The midterms.</p>
|
||||
<h1 id="page-title">Your picks.<br />Your party.<br /><em>Bragging rights.</em></h1>
|
||||
<p class="description">A fantasy-style game for our own little political league. Build a party of candidates, follow the 2026 US midterms, and see whose picks come out on top.</p>
|
||||
<p class="status"><span class="status-dot" aria-hidden="true"></span> The league is taking shape. More soon.</p>
|
||||
</section>
|
||||
|
||||
<section class="overview" aria-labelledby="overview-title">
|
||||
<div class="overview-heading">
|
||||
<h2 id="overview-title">The idea</h2>
|
||||
<span>On the drawing board</span>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li><span class="step-number" aria-hidden="true">01</span><h3>Draft your party</h3><p>Pick your candidates when the league gets together for the draft.</p></li>
|
||||
<li><span class="step-number" aria-hidden="true">02</span><h3>Make your moves</h3><p>Trade with friends as the election season unfolds.</p></li>
|
||||
<li><span class="step-number" aria-hidden="true">03</span><h3>Watch it play out</h3><p>Real-world candidate performance will decide the final standings.</p></li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
<main id="app"><p class="loading" role="status">Loading the field…</p></main>
|
||||
<noscript>This prototype needs JavaScript to browse candidates and calculate scores.</noscript>
|
||||
|
||||
<footer><span>A game among friends.</span><span>2026 US midterm elections</span></footer>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
@@ -1 +1,172 @@
|
||||
import './style.css';
|
||||
|
||||
type Source = { name: string; url: string; retrieved_at: string };
|
||||
type Race = { id: string; office: string; state: string; name: string; round: string };
|
||||
type Candidate = { id: string; race_id: string; name: string; affiliation: string; ballot_lines: string[]; source: Source };
|
||||
type Document = { id: string; kind: string; mode: 'demo' | 'real'; label: string; as_of: string; source: Source; catalog_id?: string };
|
||||
type Catalog = Document & { complete: boolean; races: Race[]; candidates: Candidate[] };
|
||||
type Ratings = Document & { entries: { race_id: string; rating: string }[] };
|
||||
type Slot = { id: string; office: string; affiliation: string };
|
||||
type Rules = { slots: Slot[]; formula: string; rounding: string; multipliers: Record<string, number> };
|
||||
type Capacity = { offices: { office: string; requirements: { group: string; available: number; required: number; shortfall: number }[] }[]; sufficient_candidates: boolean; catalog_complete: boolean };
|
||||
type Score = { slot: string; candidate_id: string; points: number | null; reason?: string; vote_share?: number; win_bonus?: number; multiplier?: number; baseline_points?: number; leader_preview_points?: number | null; status?: string };
|
||||
type Preview = { valid: boolean; complete_roster: boolean; errors: string[]; scores: Score[]; missing_scores?: number; unavailable_leader_previews?: number; known_subtotal?: number; baseline_subtotal?: number; leader_preview_subtotal?: number; total: number | null };
|
||||
|
||||
const root = document.querySelector<HTMLElement>('#app')!;
|
||||
let documents: Document[] = [];
|
||||
let rules: Rules;
|
||||
let catalog: Catalog;
|
||||
let ratingId = '';
|
||||
let roster: Record<string, string> = {};
|
||||
let step = 0;
|
||||
let playing: ReturnType<typeof setInterval> | undefined;
|
||||
let previewVersion = 0;
|
||||
let capacityVersion = 0;
|
||||
const labels: Record<string, string> = { house: 'House', senate: 'Senate', governor: 'Governor', democrat: 'Democrat', republican: 'Republican', other: 'Other', flex: 'Flex', safe: 'Safe', likely: 'Likely', lean: 'Lean', toss_up: 'Toss-up', total: 'All candidates' };
|
||||
const escape = (value: unknown) => String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!);
|
||||
const label = (value: string) => labels[value] ?? value;
|
||||
const money = (value: number | null | undefined) => value == null ? '—' : value.toFixed(2);
|
||||
const element = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const ratings = () => documents.filter(document => document.kind === 'ratings' && document.catalog_id === catalog.id) as Ratings[];
|
||||
const results = () => documents.filter(document => document.kind === 'results' && document.catalog_id === catalog.id);
|
||||
const selectedRatings = () => ratings().find(rating => rating.id === ratingId);
|
||||
const raceFor = (candidate: Candidate) => catalog.races.find(race => race.id === candidate.race_id)!;
|
||||
const fits = (candidate: Candidate, slot: Slot) => raceFor(candidate).office === slot.office && (slot.affiliation === 'flex' || candidate.affiliation === slot.affiliation);
|
||||
const options = (items: { id: string; name: string }[], selected = '') => items.map(item => `<option value="${escape(item.id)}" ${item.id === selected ? 'selected' : ''}>${escape(item.name)}</option>`).join('');
|
||||
function sourceLink(source: Source): string {
|
||||
if (!/^https?:\/\//.test(source.url)) return '<span class="muted">Fictional example</span>';
|
||||
return `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">Source ↗</a> <span class="muted">${escape(source.retrieved_at.slice(0, 10))}</span>`;
|
||||
}
|
||||
async function api<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`/api/${path}`, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const envelope = await response.json();
|
||||
if (!response.ok || !envelope.ok) throw new Error(envelope.message ?? `Request failed (${response.status})`);
|
||||
return envelope.payload as T;
|
||||
}
|
||||
function stopReplay() {
|
||||
clearInterval(playing);
|
||||
playing = undefined;
|
||||
const button = document.getElementById('play');
|
||||
if (button) button.textContent = 'Play replay';
|
||||
}
|
||||
function render() {
|
||||
stopReplay();
|
||||
++previewVersion;
|
||||
++capacityVersion;
|
||||
const snapshots = results();
|
||||
root.innerHTML = `
|
||||
<section class="intro"><p class="eyebrow">The draft starts with a little homework.</p><h1>Build your party.<br><em>Make every race count.</em></h1><p class="description">Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.</p></section>
|
||||
<section class="workspace" aria-label="Data selection"><label>Candidate catalog<select id="dataset">${options(documents.filter(item => item.kind === 'catalog').map(item => ({ id: item.id, name: item.label })), catalog.id)}</select></label><p class="notice ${catalog.mode}"><strong>${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'}</strong> ${catalog.mode === 'demo' ? 'Every candidate, rating, vote and call in this dataset is fictional.' : catalog.complete ? 'Candidate coverage is marked complete by its curator. Ratings and live coverage still need verification before a draft.' : 'Candidate coverage is incomplete. Verify the remaining candidates, real ratings and live results before a draft.'}</p><p class="small">Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}</p></section>
|
||||
<nav class="section-nav" aria-label="Prototype sections"><a href="#field">01 / The field</a><a href="#party">02 / Your party</a><a href="#night">03 / Election night</a></nav>
|
||||
<section id="field" class="panel"><div class="section-heading"><div><p class="eyebrow">01 / Scout your picks</p><h2>The field</h2></div><span id="candidate-count" class="small"></span></div>
|
||||
<div class="filters"><label class="search-label">Search candidates<input id="search" type="search" placeholder="Name or race"></label>${['office', 'affiliation', 'state', 'rating'].map(filter => `<label>${filter === 'affiliation' ? 'Party' : label(filter)}<select id="filter-${filter}"><option value="">All ${filter === 'affiliation' ? 'parties' : filter + 's'}</option>${filter === 'state' ? options([...new Set(catalog.races.map(race => race.state))].sort().map(state => ({ id: state, name: state }))) : options((filter === 'office' ? ['house', 'senate', 'governor'] : filter === 'affiliation' ? ['democrat', 'republican', 'other'] : ['safe', 'likely', 'lean', 'toss_up', 'unrated']).map(id => ({ id, name: label(id) })))}</select></label>`).join('')}</div>
|
||||
<div class="table-scroll"><table><thead><tr><th>Candidate</th><th>Race</th><th>Party / ballot lines</th><th>Frozen rating</th><th>Provenance</th></tr></thead><tbody id="candidates"></tbody></table></div>
|
||||
<details><summary>Can everyone fill a roster?</summary><div class="capacity-controls"><label>League size<select id="players">${options(Array.from({ length: 11 }, (_, index) => ({ id: String(index + 2), name: `${index + 2} players` })), '12')}</select></label><p class="small">Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.</p></div><div id="capacity" aria-live="polite"></div></details>
|
||||
</section>
|
||||
<section id="party" class="panel"><div class="section-heading"><div><p class="eyebrow">02 / Twelve picks. Three offices.</p><h2>Your party</h2></div><div class="actions">${catalog.mode === 'demo' ? '<button id="fill">Try an example roster</button>' : ''}<button id="clear" class="secondary">Clear picks</button></div></div><p class="small">One Democrat, one Republican, one Other and one Flex in each office. Other includes independent and third-party candidates. Flex accepts any party in that office. These picks stay in this page only.</p><div class="roster-grid">${['house', 'senate', 'governor'].map(office => `<fieldset><legend>${label(office)}</legend>${rules.slots.filter(slot => slot.office === office).map(slot => `<label>${label(slot.affiliation)}<select data-slot="${escape(slot.id)}" aria-label="${label(office)} ${label(slot.affiliation)}"></select></label>`).join('')}</fieldset>`).join('')}</div><p id="roster-status" role="status"></p></section>
|
||||
<section id="night" class="panel"><div class="section-heading"><div><p class="eyebrow">03 / Follow the swings</p><h2>Election night, on replay.</h2></div><span class="tag">${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}</span></div><p class="formula">${escape(rules.formula)}</p><div class="rating-key">${Object.entries(rules.multipliers).map(([rating, factor]) => `<span>${label(rating)} <strong>×${factor.toFixed(2)}</strong></span>`).join('')}</div><p class="small">${escape(rules.rounding)} Ratings are selected as a single snapshot here; a real league will freeze one before the draft. A lead is not a win. Corrections can reverse awarded points.</p><div class="replay-controls"><label>Rating snapshot<select id="rating-snapshot" ${ratings().length ? '' : 'disabled'}>${ratings().length ? options(ratings().map(item => ({ id: item.id, name: item.label })), ratingId) : '<option>No ratings imported</option>'}</select></label><label class="timeline">Result snapshot<input id="timeline" type="range" min="0" max="${Math.max(0, snapshots.length - 1)}" value="${step}" ${snapshots.length ? '' : 'disabled'}><span id="snapshot-label" class="small"></span></label><button id="play" ${snapshots.length > 1 ? '' : 'disabled'}>Play replay</button></div><p id="snapshot-source" class="small"></p><div id="preview" aria-live="polite"></div></section>`;
|
||||
element<HTMLSelectElement>('dataset').onchange = event => {
|
||||
catalog = documents.find(document => document.id === (event.target as HTMLSelectElement).value) as Catalog;
|
||||
roster = {};
|
||||
ratingId = ratings()[0]?.id ?? '';
|
||||
step = 0;
|
||||
render();
|
||||
};
|
||||
for (const id of ['search', 'filter-office', 'filter-affiliation', 'filter-state', 'filter-rating']) element(id).oninput = renderCandidates;
|
||||
element('players').onchange = () => void renderCapacity();
|
||||
element('clear').onclick = () => { roster = {}; renderRoster(); void renderPreview(); };
|
||||
const fill = document.getElementById('fill');
|
||||
if (fill) fill.onclick = () => {
|
||||
roster = {};
|
||||
for (const slot of rules.slots) {
|
||||
const eligible = catalog.candidates.filter(candidate => fits(candidate, slot) && !Object.values(roster).includes(candidate.id));
|
||||
const preferredRatings: Record<string, string> = { democrat: 'toss_up', republican: 'safe', other: 'likely', flex: 'lean' };
|
||||
const preferredRating = preferredRatings[slot.affiliation];
|
||||
const match = eligible.find(candidate => selectedRatings()?.entries.some(entry => entry.race_id === candidate.race_id && entry.rating === preferredRating)) ?? eligible[0];
|
||||
if (match) roster[slot.id] = match.id;
|
||||
}
|
||||
renderRoster();
|
||||
void renderPreview();
|
||||
};
|
||||
element('rating-snapshot').onchange = event => { ratingId = (event.target as HTMLSelectElement).value; renderCandidates(); void renderPreview(); };
|
||||
element('timeline').oninput = event => { stopReplay(); step = Number((event.target as HTMLInputElement).value); void renderPreview(); };
|
||||
element('play').onclick = () => {
|
||||
if (playing) { stopReplay(); return; }
|
||||
if (step === snapshots.length - 1) step = 0;
|
||||
element('play').textContent = 'Pause replay';
|
||||
void renderPreview();
|
||||
playing = setInterval(() => {
|
||||
step += 1;
|
||||
void renderPreview();
|
||||
if (step === snapshots.length - 1) stopReplay();
|
||||
}, 1800);
|
||||
};
|
||||
renderCandidates();
|
||||
renderRoster();
|
||||
void renderCapacity();
|
||||
void renderPreview();
|
||||
}
|
||||
function renderCandidates() {
|
||||
const search = element<HTMLInputElement>('search').value.toLowerCase();
|
||||
const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element<HTMLSelectElement>(`filter-${filter}`).value);
|
||||
const ratingEntries = selectedRatings()?.entries ?? [];
|
||||
const candidates = catalog.candidates.filter(candidate => {
|
||||
const race = raceFor(candidate);
|
||||
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating ?? 'unrated';
|
||||
return `${candidate.name} ${race.name}`.toLowerCase().includes(search) && [race.office, candidate.affiliation, race.state, rating].every((value, index) => !filters[index] || filters[index] === value);
|
||||
});
|
||||
element('candidate-count').textContent = `${candidates.length} of ${catalog.candidates.length} candidates`;
|
||||
element('candidates').innerHTML = candidates.map(candidate => {
|
||||
const race = raceFor(candidate);
|
||||
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating;
|
||||
return `<tr><td><strong>${escape(candidate.name)}</strong></td><td>${escape(race.name)}<span class="cell-note">${escape(race.state)} · ${escape(label(race.office))}</span></td><td>${escape(label(candidate.affiliation))}<span class="cell-note">${escape(candidate.ballot_lines.join(' / '))}</span></td><td><span class="tag">${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}</span></td><td class="small">${sourceLink(candidate.source)}</td></tr>`;
|
||||
}).join('') || '<tr><td colspan="5">No candidates match these filters.</td></tr>';
|
||||
}
|
||||
function renderRoster() {
|
||||
for (const slot of rules.slots) {
|
||||
const select = root.querySelector<HTMLSelectElement>(`[data-slot="${slot.id}"]`)!;
|
||||
select.innerHTML = '<option value="">Choose a candidate</option>' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => `<option value="${escape(candidate.id)}" ${roster[slot.id] === candidate.id ? 'selected' : ''} ${Object.entries(roster).some(([id, pick]) => id !== slot.id && pick === candidate.id) ? 'disabled' : ''}>${escape(candidate.name)} · ${escape(raceFor(candidate).state)}</option>`).join('');
|
||||
select.onchange = () => { roster[slot.id] = select.value; renderRoster(); void renderPreview(); };
|
||||
}
|
||||
const filled = Object.values(roster).filter(Boolean).length;
|
||||
element('roster-status').textContent = `${filled} / 12 slots filled${filled === 12 ? ' · Ready to simulate.' : ' · Fill each slot to calculate a complete total.'}`;
|
||||
}
|
||||
async function renderCapacity() {
|
||||
const version = ++capacityVersion;
|
||||
try {
|
||||
const result = await api<Capacity>('capacity', { catalog_id: catalog.id, players: Number(element<HTMLSelectElement>('players').value) });
|
||||
if (version !== capacityVersion) return;
|
||||
element('capacity').innerHTML = `<p class="notice"><strong>${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'}</strong> ${!result.catalog_complete ? 'This is a partial catalog; these counts do not establish nationwide feasibility.' : catalog.mode === 'demo' ? 'Synthetic capacity only; this does not establish real-world feasibility.' : 'Ratings and results coverage still require separate verification.'}</p><div class="table-scroll"><table><thead><tr><th>Office</th><th>Group</th><th>Available</th><th>Needed</th><th>Shortfall</th></tr></thead><tbody>${result.offices.flatMap(office => office.requirements.map(group => `<tr><td>${label(office.office)}</td><td>${label(group.group)}</td><td>${group.available}</td><td>${group.required}</td><td class="${group.shortfall ? 'warning' : ''}">${group.shortfall}</td></tr>`)).join('')}</tbody></table></div>`;
|
||||
} catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); }
|
||||
}
|
||||
async function renderPreview() {
|
||||
const version = ++previewVersion;
|
||||
const snapshot = results()[step];
|
||||
element<HTMLInputElement>('timeline').value = String(step);
|
||||
element('snapshot-label').textContent = snapshot ? `${snapshot.label} · ${snapshot.as_of}` : 'No result snapshots imported';
|
||||
element('snapshot-source').innerHTML = snapshot ? `Results: ${sourceLink(snapshot.source)}${selectedRatings() ? ` · Ratings: ${sourceLink(selectedRatings()!.source)}` : ''}` : '';
|
||||
if (!snapshot || !ratingId) { element('preview').innerHTML = '<p class="notice">Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.</p>'; return; }
|
||||
element('preview').innerHTML = '<p role="status">Calculating…</p>';
|
||||
try {
|
||||
const preview = await api<Preview>('score-preview', { catalog_id: catalog.id, ratings_id: ratingId, results_id: snapshot.id, roster: Object.entries(roster).filter(([, id]) => id).map(([slot, candidate_id]) => ({ slot, candidate_id })) });
|
||||
if (version !== previewVersion) return;
|
||||
if (!preview.valid) { element('preview').innerHTML = `<p class="notice warning">${preview.errors.map(escape).join('<br>')}</p>`; return; }
|
||||
const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1);
|
||||
element('preview').innerHTML = `<div class="metrics"><div><span>Complete roster total</span><strong>${money(preview.total)}</strong><small>${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}</small></div><div><span>Known subtotal</span><strong>${money(preview.known_subtotal)}</strong><small>${preview.missing_scores ?? 0} selected scores unavailable</small></div><div><span>If current leaders win</span><strong>${money(preview.leader_preview_subtotal)}</strong><small>${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points</small></div></div><div class="comparison" aria-label="Scoring comparison"><div><span>No multipliers ${money(preview.baseline_subtotal)}</span><i style="width:${(preview.baseline_subtotal ?? 0) / max * 100}%"></i></div><div><span>Race-weighted ${money(preview.known_subtotal)}</span><i style="width:${(preview.known_subtotal ?? 0) / max * 100}%"></i></div></div><div class="table-scroll"><table><thead><tr><th>Pick</th><th>Vote share</th><th>Win bonus</th><th>Multiplier</th><th>Points</th><th>If leader wins</th></tr></thead><tbody>${preview.scores.map(score => `<tr><td><strong>${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}</strong><span class="cell-note">${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}</span></td><td>${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}</td><td>${score.win_bonus ?? '—'}</td><td>${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}</td><td><strong>${money(score.points)}</strong></td><td>${money(score.leader_preview_points)}</td></tr>`).join('') || '<tr><td colspan="6">Choose candidates above to see their points.</td></tr>'}</tbody></table></div>`;
|
||||
} catch (error) { if (version === previewVersion) element('preview').textContent = String(error); }
|
||||
}
|
||||
async function start() {
|
||||
try {
|
||||
const [ruleData, catalogData] = await Promise.all([api<Rules>('rules'), api<{ documents: Document[] }>('catalog')]);
|
||||
rules = ruleData;
|
||||
documents = catalogData.documents;
|
||||
catalog = documents.find(document => document.kind === 'catalog' && document.mode === 'demo') as Catalog ?? documents.find(document => document.kind === 'catalog') as Catalog;
|
||||
if (!catalog) { root.innerHTML = '<section class="intro"><h1>The field is empty.</h1><p>Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.</p></section>'; return; }
|
||||
ratingId = ratings()[0]?.id ?? '';
|
||||
render();
|
||||
} catch (error) {
|
||||
root.innerHTML = `<section class="intro"><h1>Unable to load the league.</h1><p role="alert">${escape(error)}</p><p>Check that the C++ backend is running, then try again.</p><button id="retry">Retry</button></section>`;
|
||||
element('retry').onclick = () => void start();
|
||||
}
|
||||
}
|
||||
void start();
|
||||
|
||||
+69
-54
@@ -1,58 +1,73 @@
|
||||
:root {
|
||||
font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif;
|
||||
color: #213632;
|
||||
background: #f6f4ee;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--muted: #596760;
|
||||
--line: #d6dbd2;
|
||||
}
|
||||
|
||||
:root { font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; color: #213632; background: #f6f4ee; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; --muted: #596760; --line: #d6dbd2; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
a { color: inherit; }
|
||||
a:focus-visible { outline: 3px solid #9a5a21; outline-offset: 6px; }
|
||||
.site-header, main, footer { width: min(1120px, calc(100% - 80px)); margin-inline: auto; }
|
||||
.site-header { min-height: 112px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
|
||||
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -0.03em; }
|
||||
a { color: inherit; text-underline-offset: 3px; }
|
||||
:focus-visible { outline: 3px solid #98602c; outline-offset: 3px; }
|
||||
.site-header, main, footer { width: min(1200px, calc(100% - 64px)); margin-inline: auto; }
|
||||
.site-header { min-height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
|
||||
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -.03em; }
|
||||
.monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; }
|
||||
.edition { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.13em; }
|
||||
.intro { padding: 75px 0 70px; }
|
||||
.eyebrow { margin: 0 0 28px; color: var(--muted); font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.12em; }
|
||||
.eyebrow span { color: #a36324; margin-right: 9px; font-size: 16px; }
|
||||
h1 { margin: 0; font-family: Georgia, 'Times New Roman', serif; font-size: clamp(48px, 6.5vw, 82px); font-weight: 400; line-height: 1.04; letter-spacing: -0.055em; }
|
||||
.edition, .eyebrow { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .13em; }
|
||||
.intro { padding: 56px 0 36px; }
|
||||
.eyebrow { margin: 0 0 14px; font-weight: 650; }
|
||||
h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(38px, 5.5vw, 70px); font-weight: 400; line-height: 1.08; letter-spacing: -.05em; }
|
||||
h1 em { font-weight: 400; color: #67765e; }
|
||||
.description { max-width: 520px; margin: 30px 0 26px; font-size: 17px; line-height: 1.75; color: var(--muted); }
|
||||
.status { display: inline-flex; align-items: center; gap: 10px; margin: 0; padding: 12px 16px; background: #e9ece3; border-radius: 3px; font-size: 12px; line-height: 1.6; }
|
||||
.status-dot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; background: #67765e; }
|
||||
.overview { padding-top: 25px; border-top: 1px solid var(--line); }
|
||||
.overview-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; }
|
||||
.overview-heading h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.14em; margin: 0; }
|
||||
.overview-heading > span { font-size: 12px; color: var(--muted); }
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 42px; margin: 30px 0 62px; padding: 0; list-style: none; }
|
||||
.step-number { color: #7c8973; font-family: Georgia, serif; font-size: 18px; }
|
||||
.steps h3 { margin: 16px 0 8px; font-size: 17px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.steps p { max-width: 285px; margin: 0; font-size: 14px; line-height: 1.7; color: var(--muted); }
|
||||
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); }
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.intro { position: relative; }
|
||||
.intro::after { content: '26'; position: absolute; top: 78px; right: 30px; font-family: Georgia, serif; font-size: 285px; line-height: 1; letter-spacing: -0.09em; color: #e3e7dc; z-index: -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.site-header, main, footer { width: calc(100% - 40px); }
|
||||
.site-header { min-height: 88px; }
|
||||
.wordmark { font-size: 15px; gap: 10px; }
|
||||
.edition { max-width: 85px; text-align: right; font-size: 10px; line-height: 1.6; }
|
||||
.intro { padding: 48px 0; }
|
||||
.eyebrow { max-width: 290px; line-height: 1.8; }
|
||||
.description { font-size: 16px; }
|
||||
.steps { grid-template-columns: 1fr; gap: 28px; margin-bottom: 42px; }
|
||||
.steps li { display: grid; grid-template-columns: 28px 1fr; gap: 0 15px; }
|
||||
.step-number { grid-row: span 2; padding-top: 1px; }
|
||||
.steps h3 { margin-top: 0; }
|
||||
.steps p { max-width: none; }
|
||||
footer { flex-wrap: wrap; gap: 8px 20px; }
|
||||
}
|
||||
.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; line-height: 1.7; color: var(--muted); }
|
||||
h2 { font: 400 32px Georgia, serif; margin: 0; letter-spacing: -.02em; }
|
||||
p { line-height: 1.65; }
|
||||
.small, .muted, small { font-size: 12px; color: var(--muted); }
|
||||
.workspace { padding: 24px; background: #e9ece3; border: 1px solid var(--line); }
|
||||
.workspace > label { max-width: 510px; }
|
||||
label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; }
|
||||
select, input { font: inherit; font-size: 14px; background: #fffefa; border: 1px solid #b3bdb0; border-radius: 4px; color: #213632; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 42px; }
|
||||
select { width: 100%; }
|
||||
button { font: inherit; font-size: 13px; font-weight: 600; color: #fffefa; background: #294d41; padding: 11px 17px; border: 1px solid #294d41; border-radius: 4px; cursor: pointer; min-height: 42px; }
|
||||
button:hover { background: #3f6150; }
|
||||
button.secondary { background: transparent; color: #294d41; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.notice { border-left: 3px solid #8d784f; padding: 10px 14px; background: #f6f1e4; font-size: 13px; }
|
||||
.notice.real { border-color: #9b5d2d; }
|
||||
.notice strong { margin-right: 6px; }
|
||||
.section-nav { display: flex; flex-wrap: wrap; gap: 30px; padding: 25px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
.section-nav a { text-decoration: none; }
|
||||
.panel { padding: 38px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 24px; }
|
||||
.filters { display: grid; grid-template-columns: 2fr repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
.table-scroll { overflow: auto; max-height: 440px; border: 1px solid var(--line); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
|
||||
th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: #e9ece3; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
|
||||
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||
tbody tr:last-child td { border: 0; }
|
||||
td strong { font-weight: 600; }
|
||||
.cell-note { display: block; font-size: 11px; color: var(--muted); margin-top: 5px; }
|
||||
.tag { display: inline-block; border: 1px solid var(--line); padding: 4px 7px; font-size: 11px; white-space: nowrap; border-radius: 3px; background: #eeefe7; }
|
||||
details { margin-top: 22px; }
|
||||
summary { cursor: pointer; font-size: 14px; padding: 8px 0; }
|
||||
.capacity-controls { display: flex; align-items: end; gap: 22px; margin-top: 15px; }
|
||||
.capacity-controls label { min-width: 145px; }
|
||||
.warning { color: #914518; font-weight: 600; }
|
||||
.actions { display: flex; gap: 10px; }
|
||||
.roster-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 25px; }
|
||||
fieldset { margin: 0; padding: 22px; border: 1px solid var(--line); background: #fdfcf7; min-width: 0; }
|
||||
legend { font: 23px Georgia, serif; padding: 0 8px; }
|
||||
fieldset label + label { margin-top: 15px; }
|
||||
#roster-status { font-size: 13px; }
|
||||
.formula { font: 23px Georgia, serif; line-height: 1.6; }
|
||||
.rating-key { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; }
|
||||
.rating-key strong { margin-left: 6px; }
|
||||
.replay-controls { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: start; gap: 24px; margin-top: 25px; }
|
||||
.replay-controls button { margin-top: 21px; }
|
||||
input[type=range] { width: 100%; accent-color: #294d41; padding: 0; border: 0; background: transparent; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin: 25px 0; }
|
||||
.metrics > div { background: #e9ece3; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.metrics span { font-size: 12px; }
|
||||
.metrics strong { font: 42px Georgia, serif; font-variant-numeric: tabular-nums; }
|
||||
.metrics small { line-height: 1.5; }
|
||||
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 20px 0; font-size: 12px; }
|
||||
.comparison i { display: block; height: 8px; min-width: 2px; background: #8a987b; margin-top: 10px; transition: width .3s; }
|
||||
.comparison > div:last-child i { background: #294d41; }
|
||||
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; font-size: 11px; color: var(--muted); }
|
||||
.loading { padding: 60px 0; }
|
||||
@media (max-width: 800px) { .filters { grid-template-columns: 1fr 1fr; } .search-label { grid-column: 1 / -1; } .roster-grid { grid-template-columns: 1fr; } .replay-controls { grid-template-columns: 1fr; gap: 16px; } .replay-controls button { margin: 0; } .section-heading { align-items: start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 500px) { .site-header, main, footer { width: calc(100% - 32px); } .edition { max-width: 90px; text-align: right; line-height: 1.6; } .wordmark { gap: 9px; font-size: 14px; } .workspace { padding: 16px; } .section-nav { gap: 15px; font-size: 12px; } .capacity-controls { align-items: stretch; flex-direction: column; gap: 0; } footer { flex-direction: column; gap: 8px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .comparison i { transition: none; } }
|
||||
|
||||
Reference in New Issue
Block a user