579 lines
14 KiB
Svelte
579 lines
14 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import type { PageProps } from './$types';
|
||
|
|
|
||
|
|
const { data }: PageProps = $props();
|
||
|
|
const { article, audioUrl } = data;
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Body parsing: split into paragraphs → sentences → tokens
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
type WordToken = { type: 'word'; text: string; wordIdx: number };
|
||
|
|
type OtherToken = { type: 'other'; text: string };
|
||
|
|
type Token = WordToken | OtherToken;
|
||
|
|
|
||
|
|
type Sentence = {
|
||
|
|
tokens: Token[];
|
||
|
|
idx: number; // global sentence index
|
||
|
|
startWordIdx: number;
|
||
|
|
endWordIdx: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
type Paragraph = { sentences: Sentence[] };
|
||
|
|
|
||
|
|
function parseBody(text: string): { paragraphs: Paragraph[]; totalWords: number } {
|
||
|
|
const paragraphs: Paragraph[] = [];
|
||
|
|
let wordIdx = 0;
|
||
|
|
let sentenceIdx = 0;
|
||
|
|
|
||
|
|
for (const paraText of text.split(/\n\n+/)) {
|
||
|
|
if (!paraText.trim()) continue;
|
||
|
|
|
||
|
|
// Split into alternating word / non-word tokens
|
||
|
|
const rawTokens = paraText.match(/[\p{L}\p{N}\u2019'''-]+|[^\p{L}\p{N}\u2019'''-]+/gu) ?? [];
|
||
|
|
|
||
|
|
const sentences: Sentence[] = [];
|
||
|
|
let currentTokens: Token[] = [];
|
||
|
|
let startWordIdx = wordIdx;
|
||
|
|
let hasWord = false;
|
||
|
|
|
||
|
|
for (const raw of rawTokens) {
|
||
|
|
if (/[\p{L}\p{N}]/u.test(raw)) {
|
||
|
|
currentTokens.push({ type: 'word', text: raw, wordIdx: wordIdx++ });
|
||
|
|
hasWord = true;
|
||
|
|
} else {
|
||
|
|
currentTokens.push({ type: 'other', text: raw });
|
||
|
|
// Flush sentence on sentence-ending punctuation
|
||
|
|
if (hasWord && /[.!?]/.test(raw)) {
|
||
|
|
sentences.push({
|
||
|
|
tokens: [...currentTokens],
|
||
|
|
idx: sentenceIdx++,
|
||
|
|
startWordIdx,
|
||
|
|
endWordIdx: wordIdx - 1
|
||
|
|
});
|
||
|
|
currentTokens = [];
|
||
|
|
startWordIdx = wordIdx;
|
||
|
|
hasWord = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentTokens.length > 0) {
|
||
|
|
sentences.push({
|
||
|
|
tokens: currentTokens,
|
||
|
|
idx: sentenceIdx++,
|
||
|
|
startWordIdx,
|
||
|
|
endWordIdx: wordIdx - 1
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (sentences.length > 0) {
|
||
|
|
paragraphs.push({ sentences });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { paragraphs, totalWords: wordIdx };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { paragraphs } = parseBody(article.target_body);
|
||
|
|
|
||
|
|
// Flat sentence list for O(n) audio-time lookup
|
||
|
|
const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = [];
|
||
|
|
for (const para of paragraphs) {
|
||
|
|
for (const s of para.sentences) {
|
||
|
|
allSentences.push({ idx: s.idx, startWordIdx: s.startWordIdx, endWordIdx: s.endWordIdx });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Transcript: extract per-word timings from Deepgram response
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
type WordTiming = { start: number; end: number };
|
||
|
|
|
||
|
|
function extractWordTimings(transcript: Record<string, unknown> | null): WordTiming[] {
|
||
|
|
if (!transcript) return [];
|
||
|
|
try {
|
||
|
|
const words = (transcript as any)?.results?.channels?.[0]?.alternatives?.[0]?.words;
|
||
|
|
if (!Array.isArray(words)) return [];
|
||
|
|
return words.map((w: any) => ({ start: Number(w.start), end: Number(w.end) }));
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const wordTimings = extractWordTimings(article.target_body_transcript);
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Reactive state
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
let audioEl: HTMLAudioElement | null = $state(null);
|
||
|
|
let activeSentenceIdx = $state(-1);
|
||
|
|
let selectedWord: WordToken | null = $state(null);
|
||
|
|
let translatedText: string | null = $state(null);
|
||
|
|
let translating = $state(false);
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Audio: sentence highlighting
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function handleTimeUpdate() {
|
||
|
|
if (!audioEl || wordTimings.length === 0) return;
|
||
|
|
const t = audioEl.currentTime;
|
||
|
|
|
||
|
|
// Find the word index at current playback time
|
||
|
|
let wordIdx = -1;
|
||
|
|
for (let i = 0; i < wordTimings.length; i++) {
|
||
|
|
if (wordTimings[i].start <= t && t <= wordTimings[i].end) {
|
||
|
|
wordIdx = i;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
// Between words: use the most recently started word
|
||
|
|
if (wordTimings[i].start > t) {
|
||
|
|
wordIdx = i - 1;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (wordIdx < 0) return;
|
||
|
|
|
||
|
|
for (const s of allSentences) {
|
||
|
|
if (s.startWordIdx <= wordIdx && wordIdx <= s.endWordIdx) {
|
||
|
|
activeSentenceIdx = s.idx;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Word click: fetch translation
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
async function handleWordClick(token: WordToken) {
|
||
|
|
selectedWord = token;
|
||
|
|
translatedText = null;
|
||
|
|
translating = true;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
text: token.text,
|
||
|
|
target_language: article.source_language
|
||
|
|
});
|
||
|
|
const res = await fetch(`/app/translate?${params}`);
|
||
|
|
if (res.ok) {
|
||
|
|
const body = await res.json();
|
||
|
|
translatedText = body.translated_text ?? null;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
translatedText = null;
|
||
|
|
} finally {
|
||
|
|
translating = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function closePanel() {
|
||
|
|
selectedWord = null;
|
||
|
|
translatedText = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Display helpers
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const languageNames: Record<string, string> = {
|
||
|
|
en: 'English',
|
||
|
|
fr: 'French',
|
||
|
|
es: 'Spanish',
|
||
|
|
it: 'Italian',
|
||
|
|
de: 'German',
|
||
|
|
pt: 'Portuguese',
|
||
|
|
ja: 'Japanese',
|
||
|
|
zh: 'Chinese',
|
||
|
|
ko: 'Korean'
|
||
|
|
};
|
||
|
|
|
||
|
|
const targetLang =
|
||
|
|
languageNames[article.target_language] ?? article.target_language.toUpperCase();
|
||
|
|
|
||
|
|
const publishedDate = new Intl.DateTimeFormat('en-GB', {
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric'
|
||
|
|
}).format(new Date(article.published_at));
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<!-- Reading progress bar (CSS scroll-driven animation) -->
|
||
|
|
<div class="progress-bar" aria-hidden="true"></div>
|
||
|
|
|
||
|
|
<div class="page">
|
||
|
|
<nav class="breadcrumb">
|
||
|
|
<a href="/app/articles" class="link">← Articles</a>
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
<header class="article-header">
|
||
|
|
<p class="article-eyebrow label-md">{targetLang} · {publishedDate}</p>
|
||
|
|
<h1 class="article-title">{article.target_title}</h1>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div class="article-layout">
|
||
|
|
<!-- Main content: audio + body -->
|
||
|
|
<div class="article-main">
|
||
|
|
{#if audioUrl}
|
||
|
|
<div class="audio-section">
|
||
|
|
<audio
|
||
|
|
bind:this={audioEl}
|
||
|
|
src={audioUrl}
|
||
|
|
controls
|
||
|
|
ontimeupdate={handleTimeUpdate}
|
||
|
|
class="audio-player"
|
||
|
|
>
|
||
|
|
Your browser does not support the audio element.
|
||
|
|
</audio>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
<div class="article-body" lang={article.target_language}>
|
||
|
|
{#each paragraphs as para}
|
||
|
|
<p class="paragraph">
|
||
|
|
{#each para.sentences as sentence}<span
|
||
|
|
class="sentence"
|
||
|
|
class:sentence--active={activeSentenceIdx === sentence.idx}
|
||
|
|
>{#each sentence.tokens as token}{#if token.type === 'word'}<button
|
||
|
|
class="word"
|
||
|
|
class:word--selected={selectedWord?.wordIdx === token.wordIdx}
|
||
|
|
onclick={() => handleWordClick(token)}>{token.text}</button
|
||
|
|
>{:else}{token.text}{/if}{/each}</span
|
||
|
|
>{/each}
|
||
|
|
</p>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
|
||
|
|
<aside
|
||
|
|
class="translation-panel"
|
||
|
|
class:is-open={selectedWord !== null}
|
||
|
|
aria-label="Word translation"
|
||
|
|
>
|
||
|
|
{#if selectedWord}
|
||
|
|
<div class="panel-header">
|
||
|
|
<p class="panel-word">{selectedWord.text}</p>
|
||
|
|
<button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel">
|
||
|
|
✕
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if translating}
|
||
|
|
<div class="panel-loading">
|
||
|
|
<div class="spinner" aria-hidden="true"></div>
|
||
|
|
<span>Translating…</span>
|
||
|
|
</div>
|
||
|
|
{:else if translatedText}
|
||
|
|
<p class="panel-translation">{translatedText}</p>
|
||
|
|
<button class="btn btn-secondary panel-save" disabled aria-disabled="true">
|
||
|
|
Add to flashcard
|
||
|
|
</button>
|
||
|
|
{:else}
|
||
|
|
<p class="panel-error">Could not load translation.</p>
|
||
|
|
{/if}
|
||
|
|
{:else}
|
||
|
|
<p class="panel-hint">Tap any word for a translation</p>
|
||
|
|
{/if}
|
||
|
|
</aside>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Mobile backdrop: closes the drawer when tapped outside -->
|
||
|
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||
|
|
<div
|
||
|
|
class="drawer-backdrop"
|
||
|
|
class:is-visible={selectedWord !== null}
|
||
|
|
onclick={closePanel}
|
||
|
|
aria-hidden="true"
|
||
|
|
></div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
/* --- Reading progress bar (CSS scroll-driven animation) --- */
|
||
|
|
/* Sits at the bottom edge of the sticky topnav (3.25rem) */
|
||
|
|
.progress-bar {
|
||
|
|
position: fixed;
|
||
|
|
top: 3.25rem;
|
||
|
|
left: 0;
|
||
|
|
right: 0;
|
||
|
|
height: 2px;
|
||
|
|
background: var(--color-primary);
|
||
|
|
transform-origin: left;
|
||
|
|
transform: scaleX(0);
|
||
|
|
animation: reading-progress linear both;
|
||
|
|
animation-timeline: scroll(root);
|
||
|
|
z-index: 99;
|
||
|
|
pointer-events: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes reading-progress {
|
||
|
|
from {
|
||
|
|
transform: scaleX(0);
|
||
|
|
}
|
||
|
|
to {
|
||
|
|
transform: scaleX(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Page shell --- */
|
||
|
|
.page {
|
||
|
|
padding: var(--space-8) var(--space-6);
|
||
|
|
max-width: 82rem;
|
||
|
|
margin: 0 auto;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: var(--space-5);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Breadcrumb --- */
|
||
|
|
.breadcrumb {
|
||
|
|
font-family: var(--font-label);
|
||
|
|
font-size: var(--text-label-lg);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Header --- */
|
||
|
|
.article-header {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: var(--space-2);
|
||
|
|
padding-top: var(--space-2);
|
||
|
|
}
|
||
|
|
|
||
|
|
.article-eyebrow {
|
||
|
|
color: var(--color-on-surface-variant);
|
||
|
|
}
|
||
|
|
|
||
|
|
.article-title {
|
||
|
|
font-family: var(--font-display);
|
||
|
|
font-size: var(--text-display-md);
|
||
|
|
font-weight: var(--weight-bold);
|
||
|
|
line-height: var(--leading-tight);
|
||
|
|
letter-spacing: var(--tracking-tight);
|
||
|
|
color: var(--color-on-surface);
|
||
|
|
max-width: 38rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Two-column layout --- */
|
||
|
|
.article-layout {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
gap: var(--space-6);
|
||
|
|
align-items: start;
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (min-width: 768px) {
|
||
|
|
.article-layout {
|
||
|
|
grid-template-columns: 1fr 22rem;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Audio --- */
|
||
|
|
.audio-section {
|
||
|
|
margin-bottom: var(--space-5);
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-player {
|
||
|
|
width: 100%;
|
||
|
|
accent-color: var(--color-primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Article body --- */
|
||
|
|
.article-body {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: var(--space-4);
|
||
|
|
}
|
||
|
|
|
||
|
|
.paragraph {
|
||
|
|
font-family: var(--font-body);
|
||
|
|
font-size: var(--text-body-xl);
|
||
|
|
line-height: 2;
|
||
|
|
color: var(--color-on-surface);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Sentence: highlighted when audio is at that point */
|
||
|
|
.sentence {
|
||
|
|
border-radius: var(--radius-xs);
|
||
|
|
transition: background-color var(--duration-normal) var(--ease-standard);
|
||
|
|
}
|
||
|
|
|
||
|
|
.sentence--active {
|
||
|
|
background-color: var(--color-primary-container);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Word buttons --- */
|
||
|
|
.word {
|
||
|
|
display: inline;
|
||
|
|
background: none;
|
||
|
|
border: none;
|
||
|
|
padding: 0 0.05em;
|
||
|
|
margin: 0;
|
||
|
|
font: inherit;
|
||
|
|
color: inherit;
|
||
|
|
cursor: pointer;
|
||
|
|
border-radius: var(--radius-xs);
|
||
|
|
transition:
|
||
|
|
background-color var(--duration-fast) var(--ease-standard),
|
||
|
|
color var(--duration-fast) var(--ease-standard);
|
||
|
|
}
|
||
|
|
|
||
|
|
.word:hover {
|
||
|
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||
|
|
color: var(--color-primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.word--selected {
|
||
|
|
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||
|
|
color: var(--color-primary);
|
||
|
|
font-weight: var(--weight-medium);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Translation panel: Desktop (sticky sidebar) --- */
|
||
|
|
@media (min-width: 768px) {
|
||
|
|
.translation-panel {
|
||
|
|
position: sticky;
|
||
|
|
top: var(--space-6);
|
||
|
|
background-color: var(--color-surface-container-lowest);
|
||
|
|
border-radius: var(--radius-xl);
|
||
|
|
padding: var(--space-5);
|
||
|
|
min-height: 16rem;
|
||
|
|
box-shadow: var(--shadow-tonal-sm);
|
||
|
|
}
|
||
|
|
|
||
|
|
.drawer-backdrop {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Translation panel: Mobile (bottom drawer) --- */
|
||
|
|
@media (max-width: 767px) {
|
||
|
|
.translation-panel {
|
||
|
|
position: fixed;
|
||
|
|
bottom: 0;
|
||
|
|
left: 0;
|
||
|
|
right: 0;
|
||
|
|
z-index: 300;
|
||
|
|
background-color: var(--color-surface-container-lowest);
|
||
|
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||
|
|
padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom));
|
||
|
|
max-height: 55vh;
|
||
|
|
overflow-y: auto;
|
||
|
|
transform: translateY(100%);
|
||
|
|
transition: transform var(--duration-slow) var(--ease-standard);
|
||
|
|
box-shadow: 0 -8px 32px color-mix(in srgb, var(--color-on-surface) 8%, transparent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.translation-panel.is-open {
|
||
|
|
transform: translateY(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
.drawer-backdrop {
|
||
|
|
position: fixed;
|
||
|
|
inset: 0;
|
||
|
|
z-index: 200;
|
||
|
|
background: color-mix(in srgb, var(--color-on-surface) 20%, transparent);
|
||
|
|
opacity: 0;
|
||
|
|
pointer-events: none;
|
||
|
|
transition: opacity var(--duration-slow) var(--ease-standard);
|
||
|
|
}
|
||
|
|
|
||
|
|
.drawer-backdrop.is-visible {
|
||
|
|
opacity: 1;
|
||
|
|
pointer-events: auto;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Panel internals --- */
|
||
|
|
.panel-header {
|
||
|
|
display: flex;
|
||
|
|
align-items: flex-start;
|
||
|
|
justify-content: space-between;
|
||
|
|
gap: var(--space-2);
|
||
|
|
margin-bottom: var(--space-4);
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-word {
|
||
|
|
font-family: var(--font-display);
|
||
|
|
font-size: var(--text-headline-md);
|
||
|
|
font-weight: var(--weight-semibold);
|
||
|
|
line-height: var(--leading-snug);
|
||
|
|
color: var(--color-on-surface);
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-close {
|
||
|
|
flex-shrink: 0;
|
||
|
|
color: var(--color-on-surface-variant);
|
||
|
|
font-size: var(--text-body-lg);
|
||
|
|
line-height: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-translation {
|
||
|
|
font-family: var(--font-body);
|
||
|
|
font-size: var(--text-body-lg);
|
||
|
|
line-height: var(--leading-relaxed);
|
||
|
|
color: var(--color-on-surface-variant);
|
||
|
|
font-style: italic;
|
||
|
|
margin-bottom: var(--space-4);
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-save {
|
||
|
|
width: 100%;
|
||
|
|
padding-block: var(--space-2);
|
||
|
|
opacity: 0.6;
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-hint {
|
||
|
|
font-family: var(--font-label);
|
||
|
|
font-size: var(--text-body-sm);
|
||
|
|
color: var(--color-on-surface-variant);
|
||
|
|
text-align: center;
|
||
|
|
padding: var(--space-4) 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-error {
|
||
|
|
font-family: var(--font-label);
|
||
|
|
font-size: var(--text-body-sm);
|
||
|
|
color: var(--color-on-surface-variant);
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-loading {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: var(--space-3);
|
||
|
|
font-family: var(--font-label);
|
||
|
|
font-size: var(--text-body-sm);
|
||
|
|
color: var(--color-on-surface-variant);
|
||
|
|
}
|
||
|
|
|
||
|
|
.spinner {
|
||
|
|
flex-shrink: 0;
|
||
|
|
width: 1rem;
|
||
|
|
height: 1rem;
|
||
|
|
border: 2px solid var(--color-outline-variant);
|
||
|
|
border-top-color: var(--color-primary);
|
||
|
|
border-radius: 50%;
|
||
|
|
animation: spin 0.9s linear infinite;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes spin {
|
||
|
|
to {
|
||
|
|
transform: rotate(360deg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* --- Responsive --- */
|
||
|
|
@media (max-width: 640px) {
|
||
|
|
.page {
|
||
|
|
padding: var(--space-6) var(--space-4);
|
||
|
|
}
|
||
|
|
|
||
|
|
.article-title {
|
||
|
|
font-size: var(--text-headline-lg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|