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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
174
apps/web/src/lib/components/ChangelogModal.svelte
Normal file
174
apps/web/src/lib/components/ChangelogModal.svelte
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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}
|
||||
77
apps/web/src/lib/components/ChangelogModal.test.ts
Normal file
77
apps/web/src/lib/components/ChangelogModal.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user