feat(fase-17): ChangelogModal component + unit tests

ChangelogModal renders the repo-root CHANGELOG.md (bundled at build
time via Vite's ?raw query, 5 levels up from the component file) in
an overlay+panel modal. Markdown is parsed inline (`#`/`##`/`###`
headers, `-`/`*` lists, `**bold**`) with HTML-escape on every input
line — input is static repo content so the surface is closed.

Esc and overlay-click both call `onClose`. The keydown listener is
attached only while `open` is true via a $effect.

Three new Paraglide messages (en/es): `settings_about_view_changelog`,
`changelog_modal_title`, `changelog_modal_close`.

Unit tests (CHM-U-01..04) follow the Spinner.test.ts pattern (Svelte 5
mount/unmount + flushSync for the Escape spec).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 01:17:08 +02:00
parent cb223a0c29
commit d11cf6ccef
4 changed files with 257 additions and 0 deletions

View File

@@ -180,6 +180,9 @@
"sync_conflicts_back": "Back to settings",
"settings_about": "About",
"settings_about_version": "v{version} · {commit} · {date}",
"settings_about_view_changelog": "View changelog",
"changelog_modal_title": "Changelog",
"changelog_modal_close": "Close",
"sync_offline": "You're offline — changes will sync when you reconnect",
"sync_syncing": "Syncing…",
"undo": "Undo",

View File

@@ -180,6 +180,9 @@
"sync_conflicts_back": "Volver a ajustes",
"settings_about": "Acerca de",
"settings_about_version": "v{version} · {commit} · {date}",
"settings_about_view_changelog": "Ver historial",
"changelog_modal_title": "Historial de cambios",
"changelog_modal_close": "Cerrar",
"sync_offline": "Sin conexión — los cambios se sincronizarán al reconectar",
"sync_syncing": "Sincronizando…",
"undo": "Deshacer",

View File

@@ -0,0 +1,174 @@
<!--
Fase 17.2 — About-modal that renders the repo-root CHANGELOG.md.
Architecture notes:
* CHANGELOG is imported via Vite's `?raw` query so the file is
bundled at build time — no fetch at runtime, no SSR + adapter-node
filesystem read, no service-worker miss. Bundle size is ~6 KB
today; revisit (dynamic import, marked, etc.) when it grows past
~30 KB or starts using non-trivial markdown features (tables,
links, code blocks).
* The path is 5 levels up from this file:
components → lib → src → web → apps → repo-root. The plan
estimated 3; documenting the off-by-two so anyone moving the
component up/down the tree adjusts the import accordingly.
* Markdown is parsed inline (`renderMarkdown` below). Input is
static repo content, never user-supplied, so we're not exposed to
the usual sanitisation concerns — we still HTML-escape every
input line before adding our own tags to keep the surface clean
and to defend against accidentally putting `<` in a future entry.
* Esc + overlay-click both call `onClose`. We attach the keydown
listener to `window` only while the modal is open; otherwise
typing Escape outside the modal would still call back.
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import * as m from '$lib/paraglide/messages';
import changelogRaw from '../../../../../CHANGELOG.md?raw';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Tailwind class strings for each rendered element. Living here so
// JIT scans them at build time — putting them inside a template
// string in renderMarkdown also works, but keeping them as named
// constants makes them grep-able and theme-able.
const CLS_H1 = 'mb-3 text-xl font-bold text-slate-900 dark:text-slate-50';
const CLS_H2 = 'mt-6 mb-2 text-base font-semibold text-slate-900 dark:text-slate-50';
const CLS_H3 = 'mt-4 mb-1 text-sm font-semibold text-slate-800 dark:text-slate-100';
const CLS_UL = 'mt-1 mb-3 list-disc pl-5 space-y-0.5';
const CLS_LI = 'leading-relaxed';
const CLS_P = 'my-2 leading-relaxed';
const CLS_STRONG = 'font-semibold text-slate-900 dark:text-slate-50';
function renderMarkdown(src: string): string {
const lines = src.split('\n');
const out: string[] = [];
let inList = false;
const closeList = () => {
if (inList) {
out.push('</ul>');
inList = false;
}
};
const inlineStrong = (s: string): string =>
s.replace(/\*\*([^*]+)\*\*/g, `<strong class="${CLS_STRONG}">$1</strong>`);
for (const raw of lines) {
const line = raw.trimEnd();
if (line.startsWith('### ')) {
closeList();
out.push(`<h3 class="${CLS_H3}">${inlineStrong(escapeHtml(line.slice(4)))}</h3>`);
} else if (line.startsWith('## ')) {
closeList();
out.push(`<h2 class="${CLS_H2}">${inlineStrong(escapeHtml(line.slice(3)))}</h2>`);
} else if (line.startsWith('# ')) {
closeList();
out.push(`<h1 class="${CLS_H1}">${inlineStrong(escapeHtml(line.slice(2)))}</h1>`);
} else if (/^[-*]\s+/.test(line)) {
if (!inList) {
out.push(`<ul class="${CLS_UL}">`);
inList = true;
}
const body = line.replace(/^[-*]\s+/, '');
out.push(`<li class="${CLS_LI}">${inlineStrong(escapeHtml(body))}</li>`);
} else if (line === '') {
closeList();
} else {
closeList();
out.push(`<p class="${CLS_P}">${inlineStrong(escapeHtml(line))}</p>`);
}
}
closeList();
return out.join('\n');
}
// Pre-render once on module evaluation — the source is build-time
// static, so there's no point re-running per modal open. Placed
// after the const class strings + renderMarkdown declaration so the
// TDZ doesn't kick in.
const html = renderMarkdown(changelogRaw);
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
// Attach the global keydown listener as soon as `open` flips true.
// $effect runs after mount and re-runs on each prop change, so this
// gives us subscribe-on-open and unsubscribe-on-close without an
// extra `mounted` gate (which delayed subscription past the first
// dispatched keydown in jsdom).
$effect(() => {
if (typeof window === 'undefined') return;
if (!open) return;
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKey);
}
});
function onOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
{#if open}
<div
data-testid="changelog-modal-overlay"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onclick={onOverlayClick}
role="presentation"
>
<div
data-testid="changelog-modal"
class="flex w-full max-w-2xl flex-col rounded-lg bg-surface shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised"
role="dialog"
aria-modal="true"
aria-label={m.changelog_modal_title()}
>
<header
class="flex items-center justify-between border-b border-slate-200 px-5 py-3 dark:border-slate-700"
>
<h2 class="text-base font-semibold text-slate-900 dark:text-slate-50">
{m.changelog_modal_title()}
</h2>
<button
type="button"
data-testid="changelog-modal-close"
onclick={onClose}
aria-label={m.changelog_modal_close()}
class="rounded-md p-1 text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</header>
<div
data-testid="changelog-modal-body"
class="max-h-[70vh] overflow-y-auto px-5 py-4 text-sm text-slate-700 dark:text-slate-200"
>
{@html html}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,77 @@
/**
* Fase 17.4.1 — ChangelogModal unit tests.
*
* Mirrors the Spinner.test.ts pattern (Svelte 5 `mount`/`unmount`, no
* @testing-library dependency). The modal imports CHANGELOG.md via Vite's
* `?raw` query — vitest runs against the same Vite config (see
* apps/web/vitest.config.ts) so the import resolves to the file contents at
* test time.
*
* Specs:
* CHM-U-01 — `open={false}` renders nothing.
* CHM-U-02 — `open={true}` renders the CHANGELOG `<h1>`.
* CHM-U-03 — click on the overlay fires `onClose`.
* CHM-U-04 — pressing Escape fires `onClose`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import ChangelogModal from './ChangelogModal.svelte';
let host: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let instance: any;
function render(props: Record<string, unknown>) {
host = document.createElement('div');
document.body.appendChild(host);
instance = mount(ChangelogModal, { target: host, props });
return host;
}
beforeEach(() => {
host = document.createElement('div');
});
afterEach(() => {
if (instance) unmount(instance);
if (host?.parentNode) host.parentNode.removeChild(host);
instance = null;
});
describe('ChangelogModal component', () => {
it('CHM-U-01: open=false renders nothing visible', () => {
const root = render({ open: false, onClose: () => {} });
expect(root.querySelector('[data-testid="changelog-modal"]')).toBeNull();
});
it('CHM-U-02: open=true renders the CHANGELOG h1 heading', () => {
const root = render({ open: true, onClose: () => {} });
const modal = root.querySelector('[data-testid="changelog-modal"]');
expect(modal).not.toBeNull();
const h1 = modal?.querySelector('h1');
expect(h1).not.toBeNull();
expect(h1?.textContent?.trim()).toBe('Changelog');
});
it('CHM-U-03: click on overlay fires onClose', () => {
const onClose = vi.fn();
const root = render({ open: true, onClose });
const overlay = root.querySelector('[data-testid="changelog-modal-overlay"]') as
| HTMLElement
| null;
expect(overlay).not.toBeNull();
overlay?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('CHM-U-04: Escape key fires onClose', () => {
const onClose = vi.fn();
render({ open: true, onClose });
// $effect runs in a microtask after mount. flushSync drains pending
// reactive effects so the keydown listener is attached before we
// dispatch the event.
flushSync();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});