Prechádzať zdrojové kódy

Add UI tests by Claude Code

jerryliao 3 dní pred
rodič
commit
4e04308a4e

+ 8 - 0
.vite/deps/_metadata.json

@@ -0,0 +1,8 @@
+{
+  "hash": "e45d4bf7",
+  "configHash": "ca3204d3",
+  "lockfileHash": "e3b0c442",
+  "browserHash": "76df7e8b",
+  "optimized": {},
+  "chunks": {}
+}

+ 3 - 0
.vite/deps/package.json

@@ -0,0 +1,3 @@
+{
+  "type": "module"
+}

+ 3 - 1
deno.json

@@ -6,7 +6,7 @@
     "build": "vite build",
     "start": "deno serve -A _fresh/server.js",
     "update": "deno run -A -r jsr:@fresh/update .",
-    "test": "deno test -A"
+    "test": "deno test -A --ignore=tests/ui && deno test -A --no-check --config tests/ui/deno.json tests/ui/"
   },
   "lint": {
     "rules": {
@@ -37,6 +37,8 @@
     "vite": "npm:vite@^7.1.3",
     "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.2.2",
     "tailwindcss": "npm:tailwindcss@^4.2.2",
+    "jsdom": "npm:jsdom@^26.0.0",
+    "@testing-library/preact": "npm:@testing-library/preact@^3.2.4",
     "utils/": "./utils/"
   },
   "compilerOptions": {

+ 709 - 1
deno.lock

@@ -45,11 +45,13 @@
     "npm:@preact/signals@^2.5.1": "2.9.0_preact@10.29.0",
     "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.0_vite@7.3.1__@types+node@25.5.2_@types+node@25.5.2",
     "npm:@tailwindcss/vite@^4.2.2": "4.2.2_vite@7.3.1__@types+node@25.5.2_@types+node@25.5.2",
+    "npm:@testing-library/preact@^3.2.4": "3.2.4_preact@10.29.0",
     "npm:@types/node@^25.5.2": "25.5.2",
     "npm:@types/showdown@^2.0.6": "2.0.6",
     "npm:esbuild-wasm@~0.25.11": "0.25.12",
     "npm:esbuild@0.25.7": "0.25.7",
     "npm:esbuild@~0.25.5": "0.25.12",
+    "npm:jsdom@26": "26.1.0",
     "npm:preact-render-to-string@^6.6.3": "6.6.7_preact@10.29.0",
     "npm:preact@^10.27.2": "10.29.0",
     "npm:preact@^10.28.2": "10.29.0",
@@ -208,6 +210,16 @@
     }
   },
   "npm": {
+    "@asamuzakjp/css-color@3.2.0": {
+      "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+      "dependencies": [
+        "@csstools/css-calc",
+        "@csstools/css-color-parser",
+        "@csstools/css-parser-algorithms",
+        "@csstools/css-tokenizer",
+        "lru-cache@10.4.3"
+      ]
+    },
     "@babel/code-frame@7.29.0": {
       "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
       "dependencies": [
@@ -261,7 +273,7 @@
         "@babel/compat-data",
         "@babel/helper-validator-option",
         "browserslist",
-        "lru-cache",
+        "lru-cache@5.1.1",
         "semver"
       ]
     },
@@ -362,6 +374,9 @@
         "@babel/plugin-transform-react-pure-annotations"
       ]
     },
+    "@babel/runtime@7.29.2": {
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="
+    },
     "@babel/template@7.28.6": {
       "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
       "dependencies": [
@@ -389,6 +404,34 @@
         "@babel/helper-validator-identifier"
       ]
     },
+    "@csstools/color-helpers@5.1.0": {
+      "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="
+    },
+    "@csstools/css-calc@2.1.4_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
+      "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+      "dependencies": [
+        "@csstools/css-parser-algorithms",
+        "@csstools/css-tokenizer"
+      ]
+    },
+    "@csstools/css-color-parser@3.1.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
+      "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+      "dependencies": [
+        "@csstools/color-helpers",
+        "@csstools/css-calc",
+        "@csstools/css-parser-algorithms",
+        "@csstools/css-tokenizer"
+      ]
+    },
+    "@csstools/css-parser-algorithms@3.0.5_@csstools+css-tokenizer@3.0.4": {
+      "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+      "dependencies": [
+        "@csstools/css-tokenizer"
+      ]
+    },
+    "@csstools/css-tokenizer@3.0.4": {
+      "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="
+    },
     "@esbuild/aix-ppc64@0.25.12": {
       "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
       "os": ["aix"],
@@ -1075,6 +1118,29 @@
         "vite"
       ]
     },
+    "@testing-library/dom@8.20.1": {
+      "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
+      "dependencies": [
+        "@babel/code-frame",
+        "@babel/runtime",
+        "@types/aria-query",
+        "aria-query",
+        "chalk",
+        "dom-accessibility-api",
+        "lz-string",
+        "pretty-format"
+      ]
+    },
+    "@testing-library/preact@3.2.4_preact@10.29.0": {
+      "integrity": "sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==",
+      "dependencies": [
+        "@testing-library/dom",
+        "preact"
+      ]
+    },
+    "@types/aria-query@5.0.4": {
+      "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
+    },
     "@types/estree@1.0.8": {
       "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
     },
@@ -1087,6 +1153,40 @@
     "@types/showdown@2.0.6": {
       "integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="
     },
+    "agent-base@7.1.4": {
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
+    },
+    "ansi-regex@5.0.1": {
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
+    },
+    "ansi-styles@4.3.0": {
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dependencies": [
+        "color-convert"
+      ]
+    },
+    "ansi-styles@5.2.0": {
+      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
+    },
+    "aria-query@5.1.3": {
+      "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
+      "dependencies": [
+        "deep-equal"
+      ]
+    },
+    "array-buffer-byte-length@1.0.2": {
+      "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+      "dependencies": [
+        "call-bound",
+        "is-array-buffer"
+      ]
+    },
+    "available-typed-arrays@1.0.7": {
+      "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+      "dependencies": [
+        "possible-typed-array-names"
+      ]
+    },
     "baseline-browser-mapping@2.10.12": {
       "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==",
       "bin": true
@@ -1102,24 +1202,130 @@
       ],
       "bin": true
     },
+    "call-bind-apply-helpers@1.0.2": {
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dependencies": [
+        "es-errors",
+        "function-bind"
+      ]
+    },
+    "call-bind@1.0.9": {
+      "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+      "dependencies": [
+        "call-bind-apply-helpers",
+        "es-define-property",
+        "get-intrinsic",
+        "set-function-length"
+      ]
+    },
+    "call-bound@1.0.4": {
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dependencies": [
+        "call-bind-apply-helpers",
+        "get-intrinsic"
+      ]
+    },
     "caniuse-lite@1.0.30001782": {
       "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="
     },
+    "chalk@4.1.2": {
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dependencies": [
+        "ansi-styles@4.3.0",
+        "supports-color"
+      ]
+    },
+    "color-convert@2.0.1": {
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dependencies": [
+        "color-name"
+      ]
+    },
+    "color-name@1.1.4": {
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
     "commander@9.5.0": {
       "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="
     },
     "convert-source-map@2.0.0": {
       "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
     },
+    "cssstyle@4.6.0": {
+      "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+      "dependencies": [
+        "@asamuzakjp/css-color",
+        "rrweb-cssom"
+      ]
+    },
+    "data-urls@5.0.0": {
+      "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+      "dependencies": [
+        "whatwg-mimetype",
+        "whatwg-url"
+      ]
+    },
     "debug@4.4.3": {
       "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
       "dependencies": [
         "ms"
       ]
     },
+    "decimal.js@10.6.0": {
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
+    },
+    "deep-equal@2.2.3": {
+      "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
+      "dependencies": [
+        "array-buffer-byte-length",
+        "call-bind",
+        "es-get-iterator",
+        "get-intrinsic",
+        "is-arguments",
+        "is-array-buffer",
+        "is-date-object",
+        "is-regex",
+        "is-shared-array-buffer",
+        "isarray",
+        "object-is",
+        "object-keys",
+        "object.assign",
+        "regexp.prototype.flags",
+        "side-channel",
+        "which-boxed-primitive",
+        "which-collection",
+        "which-typed-array"
+      ]
+    },
+    "define-data-property@1.1.4": {
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "dependencies": [
+        "es-define-property",
+        "es-errors",
+        "gopd"
+      ]
+    },
+    "define-properties@1.2.1": {
+      "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+      "dependencies": [
+        "define-data-property",
+        "has-property-descriptors",
+        "object-keys"
+      ]
+    },
     "detect-libc@2.1.2": {
       "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
     },
+    "dom-accessibility-api@0.5.16": {
+      "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
+    },
+    "dunder-proto@1.0.1": {
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dependencies": [
+        "call-bind-apply-helpers",
+        "es-errors",
+        "gopd"
+      ]
+    },
     "electron-to-chromium@1.5.328": {
       "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="
     },
@@ -1130,6 +1336,35 @@
         "tapable"
       ]
     },
+    "entities@6.0.1": {
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="
+    },
+    "es-define-property@1.0.1": {
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
+    },
+    "es-errors@1.3.0": {
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
+    },
+    "es-get-iterator@1.1.3": {
+      "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
+      "dependencies": [
+        "call-bind",
+        "get-intrinsic",
+        "has-symbols",
+        "is-arguments",
+        "is-map",
+        "is-set",
+        "is-string",
+        "isarray",
+        "stop-iteration-iterator"
+      ]
+    },
+    "es-object-atoms@1.1.1": {
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dependencies": [
+        "es-errors"
+      ]
+    },
     "esbuild-wasm@0.25.12": {
       "integrity": "sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==",
       "bin": true
@@ -1248,17 +1483,212 @@
         "picomatch@4.0.4"
       ]
     },
+    "for-each@0.3.5": {
+      "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+      "dependencies": [
+        "is-callable"
+      ]
+    },
     "fsevents@2.3.3": {
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
       "os": ["darwin"],
       "scripts": true
     },
+    "function-bind@1.1.2": {
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
+    },
+    "functions-have-names@1.2.3": {
+      "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="
+    },
     "gensync@1.0.0-beta.2": {
       "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="
     },
+    "get-intrinsic@1.3.0": {
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dependencies": [
+        "call-bind-apply-helpers",
+        "es-define-property",
+        "es-errors",
+        "es-object-atoms",
+        "function-bind",
+        "get-proto",
+        "gopd",
+        "has-symbols",
+        "hasown",
+        "math-intrinsics"
+      ]
+    },
+    "get-proto@1.0.1": {
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dependencies": [
+        "dunder-proto",
+        "es-object-atoms"
+      ]
+    },
+    "gopd@1.2.0": {
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
+    },
     "graceful-fs@4.2.11": {
       "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
     },
+    "has-bigints@1.1.0": {
+      "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="
+    },
+    "has-flag@4.0.0": {
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
+    },
+    "has-property-descriptors@1.0.2": {
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "dependencies": [
+        "es-define-property"
+      ]
+    },
+    "has-symbols@1.1.0": {
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
+    },
+    "has-tostringtag@1.0.2": {
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dependencies": [
+        "has-symbols"
+      ]
+    },
+    "hasown@2.0.2": {
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dependencies": [
+        "function-bind"
+      ]
+    },
+    "html-encoding-sniffer@4.0.0": {
+      "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+      "dependencies": [
+        "whatwg-encoding"
+      ]
+    },
+    "http-proxy-agent@7.0.2": {
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+      "dependencies": [
+        "agent-base",
+        "debug"
+      ]
+    },
+    "https-proxy-agent@7.0.6": {
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "dependencies": [
+        "agent-base",
+        "debug"
+      ]
+    },
+    "iconv-lite@0.6.3": {
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dependencies": [
+        "safer-buffer"
+      ]
+    },
+    "internal-slot@1.1.0": {
+      "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+      "dependencies": [
+        "es-errors",
+        "hasown",
+        "side-channel"
+      ]
+    },
+    "is-arguments@1.2.0": {
+      "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+      "dependencies": [
+        "call-bound",
+        "has-tostringtag"
+      ]
+    },
+    "is-array-buffer@3.0.5": {
+      "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+      "dependencies": [
+        "call-bind",
+        "call-bound",
+        "get-intrinsic"
+      ]
+    },
+    "is-bigint@1.1.0": {
+      "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+      "dependencies": [
+        "has-bigints"
+      ]
+    },
+    "is-boolean-object@1.2.2": {
+      "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+      "dependencies": [
+        "call-bound",
+        "has-tostringtag"
+      ]
+    },
+    "is-callable@1.2.7": {
+      "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="
+    },
+    "is-date-object@1.1.0": {
+      "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+      "dependencies": [
+        "call-bound",
+        "has-tostringtag"
+      ]
+    },
+    "is-map@2.0.3": {
+      "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="
+    },
+    "is-number-object@1.1.1": {
+      "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+      "dependencies": [
+        "call-bound",
+        "has-tostringtag"
+      ]
+    },
+    "is-potential-custom-element-name@1.0.1": {
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
+    },
+    "is-regex@1.2.1": {
+      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+      "dependencies": [
+        "call-bound",
+        "gopd",
+        "has-tostringtag",
+        "hasown"
+      ]
+    },
+    "is-set@2.0.3": {
+      "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="
+    },
+    "is-shared-array-buffer@1.0.4": {
+      "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+      "dependencies": [
+        "call-bound"
+      ]
+    },
+    "is-string@1.1.1": {
+      "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+      "dependencies": [
+        "call-bound",
+        "has-tostringtag"
+      ]
+    },
+    "is-symbol@1.1.1": {
+      "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+      "dependencies": [
+        "call-bound",
+        "has-symbols",
+        "safe-regex-test"
+      ]
+    },
+    "is-weakmap@2.0.2": {
+      "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="
+    },
+    "is-weakset@2.0.4": {
+      "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+      "dependencies": [
+        "call-bound",
+        "get-intrinsic"
+      ]
+    },
+    "isarray@2.0.5": {
+      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
+    },
     "jiti@2.6.1": {
       "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
       "bin": true
@@ -1266,6 +1696,31 @@
     "js-tokens@4.0.0": {
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
+    "jsdom@26.1.0": {
+      "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+      "dependencies": [
+        "cssstyle",
+        "data-urls",
+        "decimal.js",
+        "html-encoding-sniffer",
+        "http-proxy-agent",
+        "https-proxy-agent",
+        "is-potential-custom-element-name",
+        "nwsapi",
+        "parse5",
+        "rrweb-cssom",
+        "saxes",
+        "symbol-tree",
+        "tough-cookie",
+        "w3c-xmlserializer",
+        "webidl-conversions",
+        "whatwg-encoding",
+        "whatwg-mimetype",
+        "whatwg-url",
+        "ws",
+        "xml-name-validator"
+      ]
+    },
     "jsesc@3.1.0": {
       "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
       "bin": true
@@ -1348,18 +1803,28 @@
         "lightningcss-win32-x64-msvc"
       ]
     },
+    "lru-cache@10.4.3": {
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
+    },
     "lru-cache@5.1.1": {
       "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
       "dependencies": [
         "yallist"
       ]
     },
+    "lz-string@1.5.0": {
+      "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+      "bin": true
+    },
     "magic-string@0.30.21": {
       "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
       "dependencies": [
         "@jridgewell/sourcemap-codec"
       ]
     },
+    "math-intrinsics@1.1.0": {
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
+    },
     "ms@2.1.3": {
       "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
     },
@@ -1373,6 +1838,39 @@
     "numesis@1.1.0": {
       "integrity": "sha512-RC0mwPJ2kwWEnJejogMCadOlBKZF+iHshtkSQyRqLioBORPTujIOVtdP6BGKua55oImtYKVzt9b4t3smIwA5Gg=="
     },
+    "nwsapi@2.2.23": {
+      "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="
+    },
+    "object-inspect@1.13.4": {
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
+    },
+    "object-is@1.1.6": {
+      "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+      "dependencies": [
+        "call-bind",
+        "define-properties"
+      ]
+    },
+    "object-keys@1.1.1": {
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
+    },
+    "object.assign@4.1.7": {
+      "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+      "dependencies": [
+        "call-bind",
+        "call-bound",
+        "define-properties",
+        "es-object-atoms",
+        "has-symbols",
+        "object-keys"
+      ]
+    },
+    "parse5@7.3.0": {
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "dependencies": [
+        "entities"
+      ]
+    },
     "picocolors@1.1.1": {
       "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
     },
@@ -1382,6 +1880,9 @@
     "picomatch@4.0.4": {
       "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
     },
+    "possible-typed-array-names@1.1.0": {
+      "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="
+    },
     "postcss@8.5.8": {
       "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
       "dependencies": [
@@ -1399,6 +1900,31 @@
     "preact@10.29.0": {
       "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="
     },
+    "pretty-format@27.5.1": {
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+      "dependencies": [
+        "ansi-regex",
+        "ansi-styles@5.2.0",
+        "react-is"
+      ]
+    },
+    "punycode@2.3.1": {
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
+    },
+    "react-is@17.0.2": {
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
+    },
+    "regexp.prototype.flags@1.5.4": {
+      "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+      "dependencies": [
+        "call-bind",
+        "define-properties",
+        "es-errors",
+        "get-proto",
+        "gopd",
+        "set-function-name"
+      ]
+    },
     "rollup@4.60.1": {
       "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
       "dependencies": [
@@ -1434,10 +1960,50 @@
       ],
       "bin": true
     },
+    "rrweb-cssom@0.8.0": {
+      "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="
+    },
+    "safe-regex-test@1.1.0": {
+      "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+      "dependencies": [
+        "call-bound",
+        "es-errors",
+        "is-regex"
+      ]
+    },
+    "safer-buffer@2.1.2": {
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+    },
+    "saxes@6.0.0": {
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dependencies": [
+        "xmlchars"
+      ]
+    },
     "semver@6.3.1": {
       "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "bin": true
     },
+    "set-function-length@1.2.2": {
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "dependencies": [
+        "define-data-property",
+        "es-errors",
+        "function-bind",
+        "get-intrinsic",
+        "gopd",
+        "has-property-descriptors"
+      ]
+    },
+    "set-function-name@2.0.2": {
+      "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+      "dependencies": [
+        "define-data-property",
+        "es-errors",
+        "functions-have-names",
+        "has-property-descriptors"
+      ]
+    },
     "showdown@2.1.0": {
       "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
       "dependencies": [
@@ -1445,9 +2011,61 @@
       ],
       "bin": true
     },
+    "side-channel-list@1.0.1": {
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "dependencies": [
+        "es-errors",
+        "object-inspect"
+      ]
+    },
+    "side-channel-map@1.0.1": {
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dependencies": [
+        "call-bound",
+        "es-errors",
+        "get-intrinsic",
+        "object-inspect"
+      ]
+    },
+    "side-channel-weakmap@1.0.2": {
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dependencies": [
+        "call-bound",
+        "es-errors",
+        "get-intrinsic",
+        "object-inspect",
+        "side-channel-map"
+      ]
+    },
+    "side-channel@1.1.0": {
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dependencies": [
+        "es-errors",
+        "object-inspect",
+        "side-channel-list",
+        "side-channel-map",
+        "side-channel-weakmap"
+      ]
+    },
     "source-map-js@1.2.1": {
       "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
     },
+    "stop-iteration-iterator@1.1.0": {
+      "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+      "dependencies": [
+        "es-errors",
+        "internal-slot"
+      ]
+    },
+    "supports-color@7.2.0": {
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dependencies": [
+        "has-flag"
+      ]
+    },
+    "symbol-tree@3.2.4": {
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
+    },
     "tailwindcss@4.2.2": {
       "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
     },
@@ -1461,6 +2079,28 @@
         "picomatch@4.0.4"
       ]
     },
+    "tldts-core@6.1.86": {
+      "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
+    },
+    "tldts@6.1.86": {
+      "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+      "dependencies": [
+        "tldts-core"
+      ],
+      "bin": true
+    },
+    "tough-cookie@5.1.2": {
+      "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+      "dependencies": [
+        "tldts"
+      ]
+    },
+    "tr46@5.1.1": {
+      "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+      "dependencies": [
+        "punycode"
+      ]
+    },
     "undici-types@7.18.2": {
       "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="
     },
@@ -1498,6 +2138,72 @@
       ],
       "bin": true
     },
+    "w3c-xmlserializer@5.0.0": {
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "dependencies": [
+        "xml-name-validator"
+      ]
+    },
+    "webidl-conversions@7.0.0": {
+      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
+    },
+    "whatwg-encoding@3.1.1": {
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "dependencies": [
+        "iconv-lite"
+      ],
+      "deprecated": true
+    },
+    "whatwg-mimetype@4.0.0": {
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="
+    },
+    "whatwg-url@14.2.0": {
+      "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+      "dependencies": [
+        "tr46",
+        "webidl-conversions"
+      ]
+    },
+    "which-boxed-primitive@1.1.1": {
+      "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+      "dependencies": [
+        "is-bigint",
+        "is-boolean-object",
+        "is-number-object",
+        "is-string",
+        "is-symbol"
+      ]
+    },
+    "which-collection@1.0.2": {
+      "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+      "dependencies": [
+        "is-map",
+        "is-set",
+        "is-weakmap",
+        "is-weakset"
+      ]
+    },
+    "which-typed-array@1.1.20": {
+      "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+      "dependencies": [
+        "available-typed-arrays",
+        "call-bind",
+        "call-bound",
+        "for-each",
+        "get-proto",
+        "gopd",
+        "has-tostringtag"
+      ]
+    },
+    "ws@8.20.0": {
+      "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="
+    },
+    "xml-name-validator@5.0.0": {
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="
+    },
+    "xmlchars@2.2.0": {
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
+    },
     "yallist@3.1.1": {
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
     }
@@ -1521,8 +2227,10 @@
       "jsr:@std/http@^1.0.25",
       "npm:@preact/signals@^2.5.0",
       "npm:@tailwindcss/vite@^4.2.2",
+      "npm:@testing-library/preact@^3.2.4",
       "npm:@types/node@^25.5.2",
       "npm:@types/showdown@^2.0.6",
+      "npm:jsdom@26",
       "npm:preact@^10.27.2",
       "npm:showdown@^2.1.0",
       "npm:tailwindcss@^4.2.2",

+ 119 - 0
tests/ui/button_test.tsx

@@ -0,0 +1,119 @@
+import { cleanup, assertEquals, render, screen, fireEvent } from "./setup.ts";
+import Button from "../../components/form/Button.tsx";
+
+Deno.test({
+  name: "Button - renders children text",
+  fn() {
+    render(<Button>Click me</Button>);
+    assertEquals(screen.getByText("Click me").tagName, "BUTTON");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - applies default variant classes",
+  fn() {
+    render(<Button>Default</Button>);
+    const btn = screen.getByText("Default");
+    assertEquals(btn.className.includes("bg-white"), true);
+    assertEquals(btn.className.includes("text-gray-800"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - applies primary variant classes",
+  fn() {
+    render(<Button variant="primary">Primary</Button>);
+    const btn = screen.getByText("Primary");
+    assertEquals(btn.className.includes("bg-blue-600"), true);
+    assertEquals(btn.className.includes("text-white"), true);
+    assertEquals(btn.className.includes("border-blue-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - applies danger variant classes",
+  fn() {
+    render(<Button variant="danger">Danger</Button>);
+    const btn = screen.getByText("Danger");
+    assertEquals(btn.className.includes("bg-red-600"), true);
+    assertEquals(btn.className.includes("text-white"), true);
+    assertEquals(btn.className.includes("border-red-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - applies sm size classes",
+  fn() {
+    render(<Button size="sm">Small</Button>);
+    const btn = screen.getByText("Small");
+    assertEquals(btn.className.includes("px-2"), true);
+    assertEquals(btn.className.includes("py-1"), true);
+    assertEquals(btn.className.includes("text-xs"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - applies lg size classes",
+  fn() {
+    render(<Button size="lg">Large</Button>);
+    const btn = screen.getByText("Large");
+    assertEquals(btn.className.includes("px-4"), true);
+    assertEquals(btn.className.includes("py-2"), true);
+    assertEquals(btn.className.includes("text-base"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - passes through disabled attribute",
+  fn() {
+    render(<Button disabled>Disabled</Button>);
+    const btn = screen.getByText("Disabled") as HTMLButtonElement;
+    assertEquals(btn.disabled, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - fires onClick handler",
+  fn() {
+    let clicked = false;
+    render(<Button onClick={() => { clicked = true; }}>Clickable</Button>);
+    fireEvent.click(screen.getByText("Clickable"));
+    assertEquals(clicked, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Button - appends custom className",
+  fn() {
+    render(<Button className="my-custom">Custom</Button>);
+    const btn = screen.getByText("Custom");
+    assertEquals(btn.className.includes("my-custom"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 91 - 0
tests/ui/checkbox_test.tsx

@@ -0,0 +1,91 @@
+import { cleanup, assertEquals, render, screen, fireEvent } from "./setup.ts";
+import Checkbox from "../../components/form/Checkbox.tsx";
+
+Deno.test({
+  name: "Checkbox - renders bare checkbox without label",
+  fn() {
+    render(<Checkbox />);
+    const checkbox = screen.getByRole("checkbox");
+    assertEquals(checkbox.tagName, "INPUT");
+    assertEquals((checkbox as HTMLInputElement).type, "checkbox");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Checkbox - renders with label text",
+  fn() {
+    render(<Checkbox label="Accept terms" />);
+    const label = screen.getByText("Accept terms");
+    assertEquals(label.tagName, "SPAN");
+    const checkbox = screen.getByRole("checkbox");
+    assertEquals(checkbox.tagName, "INPUT");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Checkbox - wraps in label element when label prop given",
+  fn() {
+    const { container } = render(<Checkbox label="Wrap" />);
+    const labelEl = container.querySelector("label");
+    assertEquals(labelEl !== null, true);
+    assertEquals(labelEl!.className.includes("flex"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Checkbox - passes through checked attribute",
+  fn() {
+    render(<Checkbox checked />);
+    const checkbox = screen.getByRole("checkbox") as HTMLInputElement;
+    assertEquals(checkbox.checked, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Checkbox - passes through disabled attribute",
+  fn() {
+    render(<Checkbox disabled />);
+    const checkbox = screen.getByRole("checkbox") as HTMLInputElement;
+    assertEquals(checkbox.disabled, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Checkbox - fires onChange handler",
+  fn() {
+    let changed = false;
+    render(<Checkbox onChange={() => { changed = true; }} />);
+    fireEvent.click(screen.getByRole("checkbox"));
+    assertEquals(changed, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Checkbox - appends custom className",
+  fn() {
+    render(<Checkbox className="my-check" />);
+    const checkbox = screen.getByRole("checkbox");
+    assertEquals(checkbox.className.includes("my-check"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 35 - 0
tests/ui/deno.json

@@ -0,0 +1,35 @@
+{
+  "nodeModulesDir": "manual",
+  "imports": {
+    "@std/async": "jsr:@std/async@^1.2.0",
+    "@std/assert": "jsr:@std/assert@^1.0.19",
+    "@std/crypto": "jsr:@std/crypto@^1.0.5",
+    "@std/dotenv": "jsr:@std/dotenv@^0.225.6",
+    "@std/encoding": "jsr:@std/encoding@^1.0.10",
+    "@std/http": "jsr:@std/http@^1.0.25",
+    "@types/node": "npm:@types/node@^25.5.2",
+    "@types/showdown": "npm:@types/showdown@^2.0.6",
+    "fresh": "jsr:@fresh/core@^2.2.2",
+    "fresh/runtime": "jsr:@fresh/core@^2.2.2/runtime",
+    "preact": "npm:preact@^10.27.2",
+    "preact/hooks": "npm:preact@^10.27.2/hooks",
+    "preact/test-utils": "npm:preact@^10.27.2/test-utils",
+    "@preact/signals": "npm:@preact/signals@^2.5.0",
+    "showdown": "npm:showdown@^2.1.0",
+    "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8",
+    "usid": "npm:usid@^2.0.0",
+    "jsdom": "npm:jsdom@^26.0.0",
+    "@testing-library/preact": "npm:@testing-library/preact@^3.2.4",
+    "utils/": "../../utils/"
+  },
+  "compilerOptions": {
+    "lib": [
+      "dom",
+      "dom.asynciterable",
+      "dom.iterable",
+      "deno.ns"
+    ],
+    "jsx": "react-jsx",
+    "jsxImportSource": "preact"
+  }
+}

+ 38 - 0
tests/ui/deno.lock

@@ -0,0 +1,38 @@
+{
+  "version": "5",
+  "specifiers": {
+    "jsr:@std/assert@^1.0.19": "1.0.19",
+    "jsr:@std/internal@^1.0.12": "1.0.12"
+  },
+  "jsr": {
+    "@std/assert@1.0.19": {
+      "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
+      "dependencies": [
+        "jsr:@std/internal"
+      ]
+    },
+    "@std/internal@1.0.12": {
+      "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
+    }
+  },
+  "workspace": {
+    "dependencies": [
+      "jsr:@fresh/core@^2.2.2",
+      "jsr:@fresh/plugin-vite@^1.0.8",
+      "jsr:@std/assert@^1.0.19",
+      "jsr:@std/async@^1.2.0",
+      "jsr:@std/crypto@^1.0.5",
+      "jsr:@std/dotenv@~0.225.6",
+      "jsr:@std/encoding@^1.0.10",
+      "jsr:@std/http@^1.0.25",
+      "npm:@preact/signals@^2.5.0",
+      "npm:@testing-library/preact@^3.2.4",
+      "npm:@types/node@^25.5.2",
+      "npm:@types/showdown@^2.0.6",
+      "npm:jsdom@26",
+      "npm:preact@^10.27.2",
+      "npm:showdown@^2.1.0",
+      "npm:usid@2"
+    ]
+  }
+}

+ 44 - 0
tests/ui/dom_shim.ts

@@ -0,0 +1,44 @@
+/**
+ * Side-effect module: initializes JSDOM globals BEFORE any other imports.
+ * Must be imported before @testing-library/preact to ensure `document` exists
+ * when @testing-library/dom's `screen` is initialized at module load time.
+ */
+import { JSDOM } from "jsdom";
+
+const jsdom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
+  url: "http://localhost",
+  pretendToBeVisual: true,
+});
+
+const win = jsdom.window;
+
+// deno-lint-ignore no-explicit-any
+(globalThis as any).document = win.document;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).window = win;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).navigator = win.navigator;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).HTMLElement = win.HTMLElement;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).HTMLInputElement = win.HTMLInputElement;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).HTMLTextAreaElement = win.HTMLTextAreaElement;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).HTMLButtonElement = win.HTMLButtonElement;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).Event = win.Event;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).CustomEvent = win.CustomEvent;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).MouseEvent = win.MouseEvent;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).InputEvent = win.InputEvent;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).Node = win.Node;
+// deno-lint-ignore no-explicit-any
+(globalThis as any).requestAnimationFrame = win.requestAnimationFrame.bind(win);
+// deno-lint-ignore no-explicit-any
+(globalThis as any).cancelAnimationFrame = win.cancelAnimationFrame.bind(win);
+// deno-lint-ignore no-explicit-any
+(globalThis as any).MutationObserver = win.MutationObserver;

+ 91 - 0
tests/ui/input_test.tsx

@@ -0,0 +1,91 @@
+import { cleanup, assertEquals, render, screen } from "./setup.ts";
+import Input from "../../components/form/Input.tsx";
+
+Deno.test({
+  name: "Input - renders bare input without label",
+  fn() {
+    render(<Input placeholder="Enter text" />);
+    const input = screen.getByPlaceholderText("Enter text");
+    assertEquals(input.tagName, "INPUT");
+    // No wrapping div or label
+    assertEquals(input.parentElement?.tagName, "DIV"); // render container
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Input - renders with label wrapper",
+  fn() {
+    render(<Input label="Email" placeholder="email" />);
+    const label = screen.getByText("Email");
+    assertEquals(label.tagName, "LABEL");
+    const input = screen.getByPlaceholderText("email");
+    assertEquals(input.tagName, "INPUT");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Input - applies error border when error=true",
+  fn() {
+    render(<Input error={true} placeholder="err" />);
+    const input = screen.getByPlaceholderText("err");
+    assertEquals(input.className.includes("border-red-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Input - applies normal border when error=false",
+  fn() {
+    render(<Input error={false} placeholder="ok" />);
+    const input = screen.getByPlaceholderText("ok");
+    assertEquals(input.className.includes("border-gray-300"), true);
+    assertEquals(input.className.includes("border-red-600"), false);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Input - passes through type attribute",
+  fn() {
+    render(<Input type="password" placeholder="pw" />);
+    const input = screen.getByPlaceholderText("pw") as HTMLInputElement;
+    assertEquals(input.type, "password");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Input - passes through disabled attribute",
+  fn() {
+    render(<Input disabled placeholder="disabled" />);
+    const input = screen.getByPlaceholderText("disabled") as HTMLInputElement;
+    assertEquals(input.disabled, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Input - appends custom className",
+  fn() {
+    render(<Input className="my-input" placeholder="custom" />);
+    const input = screen.getByPlaceholderText("custom");
+    assertEquals(input.className.includes("my-input"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 72 - 0
tests/ui/loading_test.tsx

@@ -0,0 +1,72 @@
+import { cleanup, assertEquals, render, act } from "./setup.ts";
+import Loading from "../../islands/Loading.tsx";
+
+Deno.test({
+  name: "Loading - initially hidden",
+  fn() {
+    const { container } = render(<Loading />);
+    const overlay = container.firstElementChild!;
+    assertEquals(overlay.className.includes("hidden"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Loading - registers globalThis.$loading",
+  fn() {
+    render(<Loading />);
+    assertEquals(typeof globalThis.$loading, "object");
+    assertEquals(typeof globalThis.$loading!.show, "function");
+    assertEquals(typeof globalThis.$loading!.hide, "function");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Loading - show() makes visible",
+  fn() {
+    const { container } = render(<Loading />);
+    act(() => {
+      globalThis.$loading!.show();
+    });
+    const overlay = container.firstElementChild!;
+    assertEquals(overlay.className.includes("hidden"), false);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Loading - hide() hides again",
+  fn() {
+    const { container } = render(<Loading />);
+    act(() => {
+      globalThis.$loading!.show();
+    });
+    act(() => {
+      globalThis.$loading!.hide();
+    });
+    const overlay = container.firstElementChild!;
+    assertEquals(overlay.className.includes("hidden"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Loading - cleans up globalThis.$loading on unmount",
+  fn() {
+    render(<Loading />);
+    assertEquals(globalThis.$loading !== undefined, true);
+    cleanup();
+    assertEquals(globalThis.$loading, undefined);
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 180 - 0
tests/ui/login_frame_test.tsx

@@ -0,0 +1,180 @@
+import { cleanup, assertEquals, render, screen, fireEvent, act } from "./setup.ts";
+import LoginFrame from "../../islands/LoginFrame.tsx";
+
+// Mock fetch to prevent actual network calls
+const originalFetch = globalThis.fetch;
+
+function mockFetch(response = { success: false }) {
+  globalThis.fetch = () =>
+    Promise.resolve(new Response(JSON.stringify(response), {
+      headers: { "Content-Type": "application/json" },
+    }));
+}
+
+function restoreFetch() {
+  globalThis.fetch = originalFetch;
+}
+
+// Mock $loading to prevent errors
+function mockLoading() {
+  globalThis.$loading = { show: () => {}, hide: () => {} };
+}
+
+function restoreLoading() {
+  delete globalThis.$loading;
+}
+
+Deno.test({
+  name: "LoginFrame - login mode renders email and password inputs",
+  async fn() {
+    mockFetch();
+    const { container } = render(<LoginFrame mode="login" />);
+    // Wait for useEffect to settle
+    await act(async () => {});
+    assertEquals(screen.getByPlaceholderText("Your email") !== null, true);
+    assertEquals(screen.getByPlaceholderText("Your password") !== null, true);
+    cleanup();
+    restoreFetch();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - login mode renders Sign in button",
+  async fn() {
+    mockFetch();
+    const { container } = render(<LoginFrame mode="login" />);
+    await act(async () => {});
+    assertEquals(screen.getByText("Sign in").tagName, "BUTTON");
+    cleanup();
+    restoreFetch();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - login mode renders Go Register button",
+  async fn() {
+    mockFetch();
+    render(<LoginFrame mode="login" />);
+    await act(async () => {});
+    assertEquals(screen.getByText("Go Register").tagName, "BUTTON");
+    cleanup();
+    restoreFetch();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - login mode does not render confirm password",
+  async fn() {
+    mockFetch();
+    render(<LoginFrame mode="login" />);
+    await act(async () => {});
+    const confirmInput = screen.queryByPlaceholderText("Confirm your password");
+    assertEquals(confirmInput, null);
+    cleanup();
+    restoreFetch();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - register mode renders confirm password input",
+  fn() {
+    mockFetch();
+    render(<LoginFrame mode="register" />);
+    assertEquals(screen.getByPlaceholderText("Confirm your password") !== null, true);
+    cleanup();
+    restoreFetch();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - register mode renders Register button",
+  fn() {
+    mockFetch();
+    render(<LoginFrame mode="register" />);
+    assertEquals(screen.getByText("Register").tagName, "BUTTON");
+    cleanup();
+    restoreFetch();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - register mode renders Go Login button",
+  fn() {
+    mockFetch();
+    render(<LoginFrame mode="register" />);
+    assertEquals(screen.getByText("Go Login").tagName, "BUTTON");
+    cleanup();
+    restoreFetch();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - submit with empty fields sets error state",
+  async fn() {
+    mockFetch();
+    mockLoading();
+    render(<LoginFrame mode="login" />);
+    await act(async () => {});
+
+    const submitBtn = screen.getByText("Sign in");
+    await act(async () => {
+      fireEvent.click(submitBtn);
+    });
+
+    // Check that error borders appear on empty inputs
+    const emailInput = screen.getByPlaceholderText("Your email");
+    const passwordInput = screen.getByPlaceholderText("Your password");
+    assertEquals(emailInput.className.includes("border-red-600"), true);
+    assertEquals(passwordInput.className.includes("border-red-600"), true);
+
+    cleanup();
+    restoreFetch();
+    restoreLoading();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "LoginFrame - typing clears error state",
+  async fn() {
+    mockFetch();
+    mockLoading();
+    render(<LoginFrame mode="login" />);
+    await act(async () => {});
+
+    // Trigger error
+    await act(async () => {
+      fireEvent.click(screen.getByText("Sign in"));
+    });
+
+    const emailInput = screen.getByPlaceholderText("Your email");
+    assertEquals(emailInput.className.includes("border-red-600"), true);
+
+    // Type into the email input to clear error
+    await act(async () => {
+      fireEvent.input(emailInput, { target: { value: "test@email.com" } });
+    });
+    assertEquals(emailInput.className.includes("border-red-600"), false);
+
+    cleanup();
+    restoreFetch();
+    restoreLoading();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 129 - 0
tests/ui/modal_test.tsx

@@ -0,0 +1,129 @@
+import { cleanup, assertEquals, render, screen, fireEvent, act } from "./setup.ts";
+import Modal from "../../islands/Modal.tsx";
+
+Deno.test({
+  name: "Modal - initially hidden",
+  fn() {
+    const { container } = render(<Modal />);
+    const overlay = container.firstElementChild!;
+    assertEquals(overlay.className.includes("hidden"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Modal - registers globalThis.$modal",
+  fn() {
+    render(<Modal />);
+    assertEquals(typeof globalThis.$modal, "object");
+    assertEquals(typeof globalThis.$modal!.show, "function");
+    assertEquals(typeof globalThis.$modal!.hide, "function");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Modal - show() displays with title and content",
+  fn() {
+    const { container } = render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Test Title", "Test Content", []);
+    });
+    const overlay = container.firstElementChild!;
+    assertEquals(overlay.className.includes("hidden"), false);
+    assertEquals(screen.getByText("Test Title") !== null, true);
+    assertEquals(screen.getByText("Test Content") !== null, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Modal - renders action buttons",
+  fn() {
+    render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Title", "Content", [
+        { text: "OK" },
+        { text: "Cancel" },
+      ]);
+    });
+    assertEquals(screen.getByText("OK").tagName, "BUTTON");
+    assertEquals(screen.getByText("Cancel").tagName, "BUTTON");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Modal - action button with onClick calls handler",
+  fn() {
+    let clicked = "";
+    render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Title", "Content", [
+        { text: "Confirm", onClick: (text: string) => { clicked = text; } },
+      ]);
+    });
+    fireEvent.click(screen.getByText("Confirm"));
+    assertEquals(clicked, "Confirm");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Modal - action button without onClick hides modal",
+  fn() {
+    const { container } = render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Title", "Content", [{ text: "Close" }]);
+    });
+    act(() => {
+      fireEvent.click(screen.getByText("Close"));
+    });
+    const overlay = container.firstElementChild!;
+    assertEquals(overlay.className.includes("hidden"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Modal - close icon hides modal",
+  fn() {
+    const { container } = render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Title", "Content", []);
+    });
+    const closeIcon = container.querySelector(".bi-x")!;
+    act(() => {
+      fireEvent.click(closeIcon);
+    });
+    const overlay = container.firstElementChild!;
+    assertEquals(overlay.className.includes("hidden"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Modal - cleans up globalThis.$modal on unmount",
+  fn() {
+    render(<Modal />);
+    assertEquals(globalThis.$modal !== undefined, true);
+    cleanup();
+    assertEquals(globalThis.$modal, undefined);
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 66 - 0
tests/ui/page_container_test.tsx

@@ -0,0 +1,66 @@
+import { cleanup, assertEquals, render, screen } from "./setup.ts";
+import PageContainer from "../../components/layout/PageContainer.tsx";
+
+Deno.test({
+  name: "PageContainer - renders children",
+  fn() {
+    render(<PageContainer><span>Child content</span></PageContainer>);
+    assertEquals(screen.getByText("Child content").tagName, "SPAN");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PageContainer - applies base layout classes",
+  fn() {
+    const { container } = render(<PageContainer>Test</PageContainer>);
+    const div = container.firstElementChild!;
+    assertEquals(div.className.includes("w-screen"), true);
+    assertEquals(div.className.includes("h-screen"), true);
+    assertEquals(div.className.includes("flex"), true);
+    assertEquals(div.className.includes("flex-col"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PageContainer - applies centering classes when centered=true",
+  fn() {
+    const { container } = render(<PageContainer centered>Centered</PageContainer>);
+    const div = container.firstElementChild!;
+    assertEquals(div.className.includes("items-center"), true);
+    assertEquals(div.className.includes("justify-center"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PageContainer - no centering when centered=false (default)",
+  fn() {
+    const { container } = render(<PageContainer>Not centered</PageContainer>);
+    const div = container.firstElementChild!;
+    assertEquals(div.className.includes("items-center"), false);
+    assertEquals(div.className.includes("justify-center"), false);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PageContainer - appends custom className",
+  fn() {
+    const { container } = render(<PageContainer className="my-page">Custom</PageContainer>);
+    const div = container.firstElementChild!;
+    assertEquals(div.className.includes("my-page"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 129 - 0
tests/ui/post_list_test.tsx

@@ -0,0 +1,129 @@
+import { cleanup, assertEquals, render, screen, fireEvent } from "./setup.ts";
+import PostList from "../../islands/PostList.tsx";
+
+const mockPosts = [
+  { id: "1", title: "First Post", content: "Hello world", shared: false },
+  { id: "2", title: "Second Post", content: "Another post", shared: true },
+  { id: "3", title: "", content: "", shared: false },
+];
+
+Deno.test({
+  name: "PostList - renders correct number of post cards",
+  fn() {
+    const { container } = render(<PostList posts={mockPosts} />);
+    const cards = container.querySelectorAll(".grid > div");
+    assertEquals(cards.length, 3);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - displays post title",
+  fn() {
+    render(<PostList posts={mockPosts} />);
+    assertEquals(screen.getByText("First Post") !== null, true);
+    assertEquals(screen.getByText("Second Post") !== null, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - shows Untitled for empty title",
+  fn() {
+    render(<PostList posts={mockPosts} />);
+    assertEquals(screen.getByText("Untitled") !== null, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - displays post content snippet",
+  fn() {
+    render(<PostList posts={mockPosts} />);
+    assertEquals(screen.getByText("Hello world") !== null, true);
+    assertEquals(screen.getByText("Another post") !== null, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - shows No content for empty content",
+  fn() {
+    render(<PostList posts={mockPosts} />);
+    assertEquals(screen.getByText("No content") !== null, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - each card has an Edit button",
+  fn() {
+    render(<PostList posts={mockPosts} />);
+    const editButtons = screen.getAllByText("Edit");
+    assertEquals(editButtons.length, 3);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - each card has a delete icon",
+  fn() {
+    const { container } = render(<PostList posts={mockPosts} />);
+    const deleteIcons = container.querySelectorAll(".bi-x");
+    assertEquals(deleteIcons.length, 3);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - delete icon triggers $modal.show",
+  fn() {
+    let modalTitle = "";
+    let modalContent = "";
+    globalThis.$modal = {
+      show: (title: string, content: string | preact.JSX.Element) => {
+        modalTitle = title;
+        modalContent = content as string;
+      },
+      hide: () => {},
+    };
+
+    const { container } = render(<PostList posts={[mockPosts[0]]} />);
+    const deleteIcon = container.querySelector(".bi-x")!;
+    fireEvent.click(deleteIcon);
+
+    assertEquals(modalTitle, "Confirm delete");
+    assertEquals(modalContent.includes("First Post"), true);
+
+    delete globalThis.$modal;
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - renders empty grid with no posts",
+  fn() {
+    const { container } = render(<PostList posts={[]} />);
+    const cards = container.querySelectorAll(".grid > div");
+    assertEquals(cards.length, 0);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 7 - 0
tests/ui/setup.ts

@@ -0,0 +1,7 @@
+// Import dom_shim first — it sets globalThis.document before @testing-library/dom loads
+import "./dom_shim.ts";
+
+// Re-export testing utilities
+export { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
+export { act } from "preact/test-utils";
+export { assertEquals, assertExists, assertStringIncludes } from "@std/assert";

+ 77 - 0
tests/ui/textarea_test.tsx

@@ -0,0 +1,77 @@
+import { cleanup, assertEquals, render, screen } from "./setup.ts";
+import Textarea from "../../components/form/Textarea.tsx";
+
+Deno.test({
+  name: "Textarea - renders bare textarea without label",
+  fn() {
+    render(<Textarea placeholder="Write here" />);
+    const textarea = screen.getByPlaceholderText("Write here");
+    assertEquals(textarea.tagName, "TEXTAREA");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Textarea - renders with label wrapper",
+  fn() {
+    render(<Textarea label="Content" placeholder="content" />);
+    const label = screen.getByText("Content");
+    assertEquals(label.tagName, "LABEL");
+    const textarea = screen.getByPlaceholderText("content");
+    assertEquals(textarea.tagName, "TEXTAREA");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Textarea - applies error border when error=true",
+  fn() {
+    render(<Textarea error={true} placeholder="err" />);
+    const textarea = screen.getByPlaceholderText("err");
+    assertEquals(textarea.className.includes("border-red-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Textarea - applies normal border when error=false",
+  fn() {
+    render(<Textarea error={false} placeholder="ok" />);
+    const textarea = screen.getByPlaceholderText("ok");
+    assertEquals(textarea.className.includes("border-gray-300"), true);
+    assertEquals(textarea.className.includes("border-red-600"), false);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Textarea - passes through disabled attribute",
+  fn() {
+    render(<Textarea disabled placeholder="disabled" />);
+    const textarea = screen.getByPlaceholderText("disabled") as HTMLTextAreaElement;
+    assertEquals(textarea.disabled, true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Textarea - appends custom className",
+  fn() {
+    render(<Textarea className="my-textarea" placeholder="custom" />);
+    const textarea = screen.getByPlaceholderText("custom");
+    assertEquals(textarea.className.includes("my-textarea"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});