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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.49.3",
|
||||
"version": "1.50.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
-16
@@ -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 };
|
||||
};
|
||||
+83
-17
@@ -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;
|
||||
+27
@@ -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};"
|
||||
|
||||
+7
-1
@@ -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 };
|
||||
};
|
||||
+10
-18
@@ -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"
|
||||
Reference in New Issue
Block a user