feat(faith/apologetik): voice routing + Alex's choice chip

- contra/pro detail pages move from #voice-X hash to /[argId]/[archId]
  (and /[posArgId]/[voiceId]) optional path segments. SSR renders the
  selected voice directly — no hydration flash on deep links.
- Tab onclick uses replaceState to update path without a load roundtrip.
- Add Alex's choice chip on contra detail tabs: small circular pfp on
  picks, expanded label on the active tab. ALEX_PICKS map per argument.
- Answer-rail pills on contra index extend past 760px column into the
  right viewport gutter when space allows; wrap otherwise.
This commit is contained in:
2026-04-29 21:32:02 +02:00
parent 538b70d139
commit 70506e169a
9 changed files with 165 additions and 55 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.49.3",
"version": "1.50.0",
"private": true,
"type": "module",
"scripts": {
+26
View File
@@ -1581,6 +1581,32 @@ export function findArgument(id: string): Argument | undefined {
return ARGUMENTS.find((a) => a.id === id);
}
// Alex's curated picks per argument (archetype IDs). Same across locales.
export const ALEX_PICKS: Record<string, string[]> = {
evil: ["aquinas"],
evidence: ["logician", "pascal"],
science: ["logician", "lewis"],
morality: ["logician"],
"religion-violence": ["logician"],
miracles: ["logician", "pascal"],
hiddenness: ["pascal"],
hell: ["aquinas", "lewis"],
birth: ["lewis", "justin"],
bible: ["augustine", "newman"],
scale: ["chesterton"],
"natural-evil": ["lewis", "catechism"],
"many-gods": ["newman"],
neuroscience: ["mystic"],
prayer: ["lewis", "aquinas"],
pleasure: ["catechism"],
projection: ["lewis", "chesterton"],
"faith-reason": ["aquinas", "lewis"],
mythicism: ["historian"],
corruption: ["pastor", "chesterton"],
intelligence: ["logician"],
submission: ["lewis"],
};
// Locale-aware accessors. DE comes from auto-generated apologetik.de.ts;
// EN is the source of truth. Latin falls back to EN since DeepL doesn't
// support it — fill in apologetik.la.ts manually if/when desired.
@@ -201,7 +201,7 @@
{@const a = ARCHETYPES[archId]}
<a
class="archetype-badge"
href="/{faithLang}/{slug}/contra/{arg.id}#voice-{archId}"
href="/{faithLang}/{slug}/contra/{arg.id}/{archId}"
title="{a.name}{a.sub}"
>
<span class="glyph" aria-hidden="true" style="background:{a.color};">
@@ -496,6 +496,15 @@
gap: 8px;
align-items: center;
margin-top: 12px;
width: max-content;
max-width: 100%;
}
@media (min-width: 760px) {
.answer-rail {
/* Extend past the 760px content column into the right gutter when space allows.
arg-body left = 50vw - 278px; available right width capped 24px from viewport edge. */
max-width: min(calc(100vw - 126px), calc(50vw + 254px));
}
}
.answer-rail .label {
font-size: 0.72rem;
@@ -1,16 +0,0 @@
import { error } from '@sveltejs/kit';
import { findArgumentLang, getArchetypes, getArguments } from '$lib/data/apologetik';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, parent }) => {
const { lang } = await parent();
const [arg, archetypes, args] = await Promise.all([
findArgumentLang(params.argId, lang),
getArchetypes(lang),
getArguments(lang)
]);
if (!arg) {
error(404, 'Argument not found');
}
return { argument: arg, archetypes, args };
};
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
let { data } = $props();
@@ -9,6 +8,10 @@
const isGerman = $derived(data?.lang === 'de');
const arg = $derived(data.argument);
const ARCHETYPES = $derived(data.archetypes);
const alexPicks = $derived<string[]>(data.alexPicks ?? []);
const alexChoiceLabel = $derived(
isLatin ? 'Alexandri electio' : isGerman ? "Alex' Wahl" : "Alex's choice"
);
const tocLabel = $derived(
isLatin ? 'Obiectiones' : isGerman ? 'Einwände' : 'Objections'
@@ -24,10 +27,13 @@
);
const archIds = $derived(Object.keys(arg.counters));
let userSelected = $state<string | null>(null);
const activeId = $derived(
userSelected && archIds.includes(userSelected) ? userSelected : (archIds[0] ?? '')
);
let selectedByArg = $state<Record<string, string>>({});
const activeId = $derived.by(() => {
const sel = selectedByArg[arg.id];
if (sel && archIds.includes(sel)) return sel;
if (data.initialArchId && archIds.includes(data.initialArchId)) return data.initialArchId;
return archIds[0] ?? '';
});
const arch = $derived(ARCHETYPES[activeId]);
const counter = $derived(arg.counters[activeId]);
@@ -72,21 +78,12 @@
}
function selectArch(id: string) {
userSelected = id;
selectedByArg = { ...selectedByArg, [arg.id]: id };
if (typeof window !== 'undefined') {
history.replaceState(null, '', `#voice-${id}`);
history.replaceState(null, '', `/${faithLang}/${slug}/contra/${arg.id}/${id}`);
}
}
onMount(() => {
const hash = window.location.hash;
if (hash.startsWith('#voice-')) {
const id = hash.slice('#voice-'.length);
if (archIds.includes(id)) {
userSelected = id;
}
}
});
</script>
<svelte:head>
@@ -123,12 +120,14 @@
{#each archIds as id (id)}
{@const a = ARCHETYPES[id]}
{@const isActive = id === activeId}
{@const isPick = alexPicks.includes(id)}
<a
href="#voice-{id}"
href="/{faithLang}/{slug}/contra/{arg.id}/{id}"
role="tab"
aria-selected={isActive}
class="tab"
class:active={isActive}
class:has-pick={isPick}
style:border-bottom-color={isActive ? a.colorHex : 'transparent'}
onclick={(e) => {
e.preventDefault();
@@ -137,6 +136,20 @@
>
<span class="glyph" aria-hidden="true" style="background:{a.color};">{a.glyph}</span>
<span>{a.name}</span>
{#if isPick}
<span class="alex-mark" aria-label={alexChoiceLabel}>
<img
class="alex-pfp"
src="https://bocken.org/static/user/thumb/alexander.webp"
alt=""
loading="lazy"
decoding="async"
width="14"
height="14"
/>
<span class="alex-label">{alexChoiceLabel}</span>
</span>
{/if}
</a>
{/each}
</div>
@@ -345,6 +358,59 @@
color: var(--color-text-primary);
}
.alex-mark {
position: absolute;
top: -4px;
right: -6px;
z-index: 2;
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
padding: 1px;
box-shadow: var(--shadow-sm);
pointer-events: none;
transform: translateY(0);
transition:
padding var(--transition-normal),
transform var(--transition-normal);
}
.alex-pfp {
width: 14px;
height: 14px;
border-radius: 50%;
object-fit: cover;
display: block;
flex: none;
}
.alex-label {
display: inline-block;
white-space: nowrap;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
max-width: 0;
opacity: 0;
margin-left: 0;
overflow: hidden;
color: var(--color-text-primary);
transition:
max-width var(--transition-normal),
opacity var(--transition-fast),
margin-left var(--transition-normal);
}
.tab.active.has-pick .alex-mark {
padding: 1px 8px 1px 1px;
transform: translateY(-8px);
}
.tab.active.has-pick .alex-label {
max-width: 140px;
opacity: 1;
margin-left: 5px;
}
.glyph {
display: inline-flex;
align-items: center;
@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { ALEX_PICKS, findArgumentLang, getArchetypes, getArguments } from '$lib/data/apologetik';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, parent }) => {
const { lang } = await parent();
const [arg, archetypes, args] = await Promise.all([
findArgumentLang(params.argId, lang),
getArchetypes(lang),
getArguments(lang)
]);
if (!arg) {
error(404, 'Argument not found');
}
const archIds = Object.keys(arg.counters);
if (params.archId && !archIds.includes(params.archId)) {
error(404, 'Voice not found');
}
const initialArchId = params.archId ?? null;
return {
argument: arg,
archetypes,
args,
alexPicks: ALEX_PICKS[params.argId] ?? [],
initialArchId
};
};
@@ -286,7 +286,7 @@
{@const v = POS_VOICES[vid]}
<a
class="archetype-badge"
href="/{faithLang}/{slug}/pro/{arg.id}#voice-{vid}"
href="/{faithLang}/{slug}/pro/{arg.id}/{vid}"
title="{v.name}{v.sub}"
>
<span class="glyph" aria-hidden="true" style="background:{v.color};"
@@ -21,6 +21,12 @@ export const load: PageServerLoad = async ({ params, parent }) => {
error(404, 'Argument not found');
}
const voiceIds = Object.keys(arg.voices);
if (params.voiceId && !voiceIds.includes(params.voiceId)) {
error(404, 'Voice not found');
}
const initialVoiceId = params.voiceId ?? null;
const lng: 'en' | 'de' = lang === 'de' ? 'de' : 'en';
const enArg = EN_POS_ARGUMENTS.find((x) => x.id === arg.id);
const argument = enArg
@@ -40,5 +46,5 @@ export const load: PageServerLoad = async ({ params, parent }) => {
return { ...a, scripture: resolved.text ? resolved : a.scripture };
});
return { argument, voices, layers, args: argsWithScripture };
return { argument, voices, layers, args: argsWithScripture, initialVoiceId };
};
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
let { data } = $props();
@@ -35,10 +34,13 @@
);
const voiceIds = $derived(Object.keys(arg.voices));
let userSelected = $state<string | null>(null);
const activeId = $derived(
userSelected && voiceIds.includes(userSelected) ? userSelected : (voiceIds[0] ?? '')
);
let selectedByArg = $state<Record<string, string>>({});
const activeId = $derived.by(() => {
const sel = selectedByArg[arg.id];
if (sel && voiceIds.includes(sel)) return sel;
if (data.initialVoiceId && voiceIds.includes(data.initialVoiceId)) return data.initialVoiceId;
return voiceIds[0] ?? '';
});
const voice = $derived(POS_VOICES[activeId]);
const counter = $derived(arg.voices[activeId]);
@@ -83,21 +85,11 @@
}
function selectVoice(id: string) {
userSelected = id;
selectedByArg = { ...selectedByArg, [arg.id]: id };
if (typeof window !== 'undefined') {
history.replaceState(null, '', `#voice-${id}`);
history.replaceState(null, '', `/${faithLang}/${slug}/pro/${arg.id}/${id}`);
}
}
onMount(() => {
const hash = window.location.hash;
if (hash.startsWith('#voice-')) {
const id = hash.slice('#voice-'.length);
if (voiceIds.includes(id)) {
userSelected = id;
}
}
});
</script>
<svelte:head>
@@ -150,7 +142,7 @@
{@const v = POS_VOICES[id]}
{@const isActive = id === activeId}
<a
href="#voice-{id}"
href="/{faithLang}/{slug}/pro/{arg.id}/{id}"
role="tab"
aria-selected={isActive}
class="tab"