diff --git a/craft/src/components/basic/HtmlBlock.security.test.ts b/craft/src/components/basic/HtmlBlock.security.test.ts
new file mode 100644
index 0000000..ddfcca7
--- /dev/null
+++ b/craft/src/components/basic/HtmlBlock.security.test.ts
@@ -0,0 +1,260 @@
+import { describe, test, expect } from 'vitest';
+import { purifyHtml } from './HtmlBlock';
+// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
+// string, declared by node_modules/vite/client.d.ts. This is a checked-in
+// copy of the reference acceptance fixture used for Task 24 (widening the
+// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
+// external fixture used to drive this task so these tests cannot silently
+// drift from the thing they are supposed to be testing against.
+import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
+
+/**
+ * Task 24: the site owner tested a broad HTML fixture against the shipped
+ * sanitiser config and found 38% of it silently deleted -- merged table
+ * cells collapsing (colspan/rowspan/scope stripped),
///
+ * inline // dropped wholesale, lang/dir/role stripped
+ * (breaking RTL rendering), flattened. The fix widens
+ * ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
+ * reference fixture through the *actual* purifyHtml() and assert the
+ * previously-broken constructs now survive with their meaningful
+ * attributes intact, while re-confirming (with attack payloads spliced
+ * into the newly-widened surface -- forms, media, inline svg) that the
+ * four non-negotiable security properties still hold.
+ */
+
+describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
+ const out = purifyHtml(fixtureHtml);
+
+ test('table merged cells keep colspan/rowspan/scope', () => {
+ expect(out).toContain('');
+ expect(out).toContain(' ');
+ expect(out).toContain(' ');
+ expect(out).toContain(' ');
+ });
+
+ test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
+ expect(out).toMatch(/[\s\S]*Term one<\/dt>[\s\S]* Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
+ });
+
+ test('menu list survives with nested buttons', () => {
+ expect(out).toMatch(/[\s\S]*Copy<\/button>[\s\S]*<\/menu>/);
+ });
+
+ test('sub/sup survive (was flattened to "H2O")', () => {
+ expect(out).toContain('H2 O');
+ expect(out).toContain('x2 ');
+ });
+
+ test('details/summary survive with the open attribute (was flattened)', () => {
+ expect(out).toContain('Collapsed disclosure ');
+ expect(out).toContain('');
+ });
+
+ test('hgroup survives', () => {
+ expect(out).toMatch(/[\s\S]*Grouped heading<\/h2>/);
+ });
+
+ test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
+ expect(out).toMatch(/]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
+ expect(out).toMatch(/]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
+ expect(out).toMatch(/]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
+ expect(out).toMatch(/]*text-anchor="middle"[^>]*>svg<\/text>/);
+ });
+
+ test('picture/source with media+srcset survive', () => {
+ expect(out).toContain('');
+ expect(out).toContain('');
+ });
+
+ test('video/audio survive with source/track children (was deleted entirely)', () => {
+ expect(out).toMatch(/]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
+ expect(out).toContain('');
+ expect(out).toContain('');
+ expect(out).toMatch(/]*controls=""[^>]*>/);
+ });
+
+ test('canvas survives with its fallback text', () => {
+ expect(out).toContain('Canvas fallback text ');
+ });
+
+ test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
+ expect(out).toContain('mark ');
+ expect(out).toContain('small ');
+ expect(out).toContain('del');
+ expect(out).toContain('ins ');
+ });
+
+ test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
+ expect(out).toContain('lang="ar" dir="rtl"');
+ expect(out).toContain('lang="he" dir="rtl"');
+ });
+
+ test('role attribute preserved alongside aria-* (role was stripped)', () => {
+ expect(out).toMatch(//);
+ expect(out).toMatch(/role="img"/);
+ });
+
+ test('ol start/reversed preserved (was flattened to plain )', () => {
+ expect(out).toContain('');
+ });
+
+ test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
+ expect(out).toContain('HTML ');
+ expect(out).toContain('Ctrl ');
+ expect(out).toContain('output text ');
+ expect(out).toContain('variable ');
+ expect(out).toContain('definition term ');
+ expect(out).toContain('');
+ expect(out).toContain('');
+ expect(out).toContain('');
+ expect(out).toContain('');
+ expect(out).toContain('kan ');
+ expect(out).toContain('');
+ expect(out).toContain('');
+ });
+
+ test('wbr survives (word-break opportunity)', () => {
+ expect(out).toContain('supercalifragilistic');
+ });
+
+ test('hidden attribute survives', () => {
+ expect(out).toContain('');
+ });
+
+ test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
+ expect(out).toContain('
');
+ expect(out).not.toMatch(/