diff --git a/package-lock.json b/package-lock.json index e1cc3d8..7117f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "framer-motion": "^11.18.2", "lucide-react": ">=0.400.0", "react-dropzone": "^14.4.1", - "react-easy-crop": "^6.2.2" + "react-easy-crop": "^6.2.2", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@dnd-kit/core": "^6.0.0", @@ -22,6 +24,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.7.0", @@ -1583,6 +1586,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1707,11 +1724,52 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/react": { @@ -1734,6 +1792,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -1741,6 +1805,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1979,6 +2049,16 @@ "node": ">=4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", @@ -2087,6 +2167,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2104,6 +2194,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2153,6 +2283,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2372,7 +2512,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2400,6 +2539,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2424,12 +2576,24 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2591,6 +2755,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2618,6 +2804,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2835,6 +3027,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2848,6 +3080,16 @@ "node": ">=18" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2910,6 +3152,12 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2920,6 +3168,62 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3041,6 +3345,16 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3054,63 +3368,906 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.400.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", + "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.400.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", - "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3178,7 +4335,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3245,6 +4401,31 @@ "node": ">=0.10.0" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3430,6 +4611,16 @@ "dev": true, "license": "MIT" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3444,6 +4635,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3453,6 +4645,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -3499,6 +4692,33 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -3609,6 +4829,72 @@ "redux": "^5.0.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -3702,6 +4988,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -3741,6 +5028,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3755,6 +5052,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3768,6 +5079,24 @@ "node": ">=8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3945,6 +5274,26 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4033,6 +5382,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4074,6 +5510,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -4813,6 +6277,16 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index c20ea86..6a82930 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "framer-motion": "^11.18.2", "lucide-react": ">=0.400.0", "react-dropzone": "^14.4.1", - "react-easy-crop": "^6.2.2" + "react-easy-crop": "^6.2.2", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "peerDependencies": { "@dnd-kit/core": "^6.0.0", @@ -72,6 +74,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.7.0", diff --git a/src/components/announcements/AnnouncementBell.tsx b/src/components/announcements/AnnouncementBell.tsx new file mode 100644 index 0000000..b1a1954 --- /dev/null +++ b/src/components/announcements/AnnouncementBell.tsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } from 'react'; +import { Bell } from 'lucide-react'; +import type { ClientAnnouncement } from './types.js'; +import { AnnouncementRow } from './AnnouncementRow.js'; + +export interface AnnouncementBellProps { + items: ClientAnnouncement[]; + unreadCount: number; + prefs: { autoOpenEnabled: boolean }; + onMarkRead: (id: string, via: 'modal' | 'popover') => void; + onMarkAllRead: () => void; + onSetAutoOpen: (value: boolean) => void; + onOpenItem: (announcement: ClientAnnouncement) => void; + /** Footer link to the full announcement centre. Footer is omitted when absent. */ + onViewAll?: () => void; + /** + * Which edge the popover hangs from. Defaults to 'right' (today's + * behaviour, the bell's own right edge). Pass 'left' when the bell sits + * near the left edge of a narrow container (e.g. a sidebar) and a + * right-anchored `w-80` popover would render partly or entirely + * off-screen. Both `left-0`/`right-0` below are complete literal class + * names selected by a ternary, never built by interpolation — this + * library ships class STRINGS the consumer's Tailwind build compiles, and + * a scanner can only see whole names present verbatim in source. + */ + align?: 'left' | 'right'; +} + +/** + * The bell that sits in every portal's header, and the popover behind it. + * + * Purely prop-driven — this never calls `useAnnouncements` itself. That hook + * keeps a module-level "one modal per session" flag built for exactly one + * call per app; a header component that called it directly would risk a + * second call (a remount on navigation, a second header on a wider layout) + * silently breaking that invariant. Every value here — including the two + * write actions the toggle needs — arrives through props instead, so the + * host app owns the single hook call and this component just renders it. + * + * The popover always holds every announcement, read or not: it is a mailbox, + * not a notification tray that empties itself. The auto-open toggle only + * governs whether critical/major announcements interrupt the user with a + * modal (Task 3) on load — it never hides anything from this list. + */ +export function AnnouncementBell({ + items, unreadCount, prefs, onMarkRead, onMarkAllRead, onSetAutoOpen, onOpenItem, onViewAll, align = 'right', +}: AnnouncementBellProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Escape and an outside click both close the popover — standard dropdown + // behaviour, listened for only while it's actually open. + useEffect(() => { + if (!open) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + const onPointerDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('mousedown', onPointerDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('mousedown', onPointerDown); + }; + }, [open]); + + const handleRowClick = (announcement: ClientAnnouncement) => { + // One gesture: opening a row and dismissing its unread state are the + // same click, not two. + onOpenItem(announcement); + onMarkRead(announcement.id, 'popover'); + // The row click is inside rootRef, so the outside-click handler above + // never fires for it — without this, the popover is left open behind + // the modal onOpenItem just triggered, and dismissing the modal reveals + // it still hanging over the page. + setOpen(false); + }; + + const handleViewAll = () => { + // Exactly the hazard handleRowClick guards against, one button lower: the + // footer is inside rootRef, so neither the outside-click handler nor + // Escape ever fires for it. And navigating does not clean it up either — + // every portal routes its announcement centre under the same layout as + // this bell, so the bell stays mounted across the navigation and the + // popover hangs over the destination page until something unrelated + // happens to close it. Close first, then hand off. + setOpen(false); + onViewAll?.(); + }; + + const badge = unreadCount > 0 ? (unreadCount > 99 ? '99+' : String(unreadCount)) : null; + const bellLabel = badge ? `Announcements, ${unreadCount} unread` : 'Announcements'; + + return ( +
+ + + {open && ( +
+
+ {/* Anton only reads cleanly at 16px+ — 1.05rem clears that, matching FormModal's header treatment. */} + + Announcements + + +
+ +
+ {items.length === 0 ? ( +
+ Nothing new here yet. +
+ ) : ( + items.map(item => ( + + )) + )} +
+ +
+ + Auto-open important announcements + + +
+ + {onViewAll && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/announcements/AnnouncementModal.tsx b/src/components/announcements/AnnouncementModal.tsx new file mode 100644 index 0000000..1bcb372 --- /dev/null +++ b/src/components/announcements/AnnouncementModal.tsx @@ -0,0 +1,203 @@ +import { useEffect, useRef } from 'react'; +import { TriangleAlert } from 'lucide-react'; +import type { ClientAnnouncement } from './types.js'; +import { priorityTheme } from './theme.js'; +import { Markdown } from './Markdown.js'; + +export interface AnnouncementModalProps { + announcement: ClientAnnouncement; + onDismiss: () => void; + onCtaClick: (announcement: ClientAnnouncement) => void; +} + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * The one surface in this library that interrupts a user rather than waiting + * to be noticed. Two flavours, chosen by priority, not by a prop the host + * could get wrong: + * + * - Editorial (major/normal): hero image, an Anton headline (22px+, the only + * size this library lets Anton run below its usual dashboard-heading size), + * the full markdown body, an accent CTA, "Later" as the quiet way out. + * - Alert (critical): no image — a screenshot of a release is exciting, a + * screenshot of an outage is not, and the image would cost a beat of + * reading time an incident notice can't spend — a warning glyph, a short + * body, an alert-red CTA, "Got it". + * + * Both trap focus while open and hand it back to whatever had it before the + * modal opened, on the theory that an announcement interrupts the page but + * must not lose the user's place on it. + */ +export function AnnouncementModal({ announcement, onDismiss, onCtaClick }: AnnouncementModalProps) { + const theme = priorityTheme(announcement.priority); + const isAlert = announcement.priority === 'critical'; + + const dialogRef = useRef(null); + const returnFocusTo = useRef(null); + + // `onDismiss` is read through a ref, not captured by the effect below. + // Consumers overwhelmingly pass an inline arrow — that's the pattern + // Tasks 4/5 use — which gets a new identity on every parent render. If + // that identity were in the focus effect's dependency array, the whole + // effect would tear down and re-run on every parent re-render while the + // modal is open: cleanup yanks focus back to the pre-open element, then + // setup immediately re-steals it into the dialog. Visible flicker and a + // repeat screen-reader announcement, in the one component whose job is to + // interrupt people. Keeping the callback in a ref means the effect below + // never needs it in its dependency array, so its lifetime is the modal's + // lifetime, not the callback's identity. + const onDismissRef = useRef(onDismiss); + useEffect(() => { + onDismissRef.current = onDismiss; + }, [onDismiss]); + + useEffect(() => { + returnFocusTo.current = document.activeElement as HTMLElement | null; + + const dialog = dialogRef.current; + const focusables = dialog?.querySelectorAll(FOCUSABLE_SELECTOR); + (focusables?.[0] ?? dialog)?.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onDismissRef.current(); + return; + } + if (e.key !== 'Tab' || !dialog) return; + + const nodes = Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)); + if (nodes.length === 0) { + e.preventDefault(); + return; + } + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + const current = document.activeElement; + + if (e.shiftKey) { + if (current === first || !dialog.contains(current)) { + e.preventDefault(); + last.focus(); + } + } else if (current === last || !dialog.contains(current)) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + returnFocusTo.current?.focus?.(); + }; + // Deliberately empty: this effect's lifetime is the modal's mount + // lifetime. It must run exactly once on mount and clean up exactly once + // on unmount — see the comment above `onDismissRef`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleCta = () => { + onCtaClick(announcement); + onDismiss(); + }; + + return ( +
+
+ {!isAlert && announcement.heroUrl && ( + // A non-empty alt is required here, not just nice-to-have: an empty + // alt gives an the "presentation" accessibility role instead + // of "img", which is also why an empty alt would fail this + // component's own hero-image test. + {announcement.title} + )} + +
+ {isAlert ? ( +
+ + + {theme.label} + +
+ ) : ( + + {theme.label} + + )} + + {isAlert ? ( +

+ {announcement.title} +

+ ) : ( + // Anton reads cleanly at 16px+ only; this hero headline sits well + // above that floor, unlike the smaller UI labels elsewhere that + // stay on the body font instead. +

+ {announcement.title} +

+ )} + + {isAlert ? ( +

+ {announcement.summary} +

+ ) : ( +
+ {announcement.body} +
+ )} + +
+ + {announcement.cta && ( + + )} +
+
+
+
+ ); +} diff --git a/src/components/announcements/AnnouncementRow.tsx b/src/components/announcements/AnnouncementRow.tsx new file mode 100644 index 0000000..6418f50 --- /dev/null +++ b/src/components/announcements/AnnouncementRow.tsx @@ -0,0 +1,97 @@ +import type { ClientAnnouncement } from './types.js'; +import { priorityTheme } from './theme.js'; +import { iconForAnnouncement } from './icons.js'; + +export interface AnnouncementRowProps { + announcement: ClientAnnouncement; + /** Fires from any part of the row — opening and marking read are one gesture, not two. */ + onClick: (announcement: ClientAnnouncement) => void; +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const WEEK = 7 * DAY; +const MONTH = 30 * DAY; + +/** Coarse "time ago" — good enough for a mailbox row, not a precise countdown. */ +function formatRelativeTime(iso: string): string { + const elapsed = Date.now() - new Date(iso).getTime(); + if (elapsed < MINUTE) return 'Just now'; + if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`; + if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`; + if (elapsed < WEEK) return `${Math.floor(elapsed / DAY)}d ago`; + if (elapsed < MONTH) return `${Math.floor(elapsed / WEEK)}w ago`; + return `${Math.floor(elapsed / MONTH)}mo ago`; +} + +/** + * One row in the bell's popover (and, later, the announcement centre). + * Priority is read entirely from `priorityTheme()` (Task 1) for the pill and + * the left edge — this file owns no colour map of its own. Read state is + * this row's own concern: an unread row gets an accent dot and a tinted + * background; the coloured left edge comes from the theme for every + * priority, but only reads as an accent for critical/major since `normal`'s + * `borderClass` is the same neutral token as the page border. + * + * The whole row is a single button: clicking anywhere on it both opens the + * announcement and marks it read, since a mailbox row is scanned and acted + * on in one gesture, not two. + * + * Every row renders the same small glyph tile, never the hero image: the + * hero is authored as a 3:1 banner, and centre-cropping it into a square + * here showed readers a different framing than the one the author picked, + * with no way for them to control it. The banner keeps its job in + * `AnnouncementModal`, which has the width to show it uncropped — this row + * is a scannable list, not a second place to display it. Structurally + * identical rows also mean the eye runs down one column of title/summary + * text instead of a column of mismatched thumbnails. + */ +export function AnnouncementRow({ announcement, onClick }: AnnouncementRowProps) { + const theme = priorityTheme(announcement.priority); + const TypeIcon = iconForAnnouncement(announcement.type, announcement.icon); + + return ( + + ); +} diff --git a/src/components/announcements/Markdown.tsx b/src/components/announcements/Markdown.tsx new file mode 100644 index 0000000..b2c9946 --- /dev/null +++ b/src/components/announcements/Markdown.tsx @@ -0,0 +1,61 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { ComponentPropsWithoutRef } from 'react'; + +export interface MarkdownProps { + children: string; +} + +/** + * The one place in this library that renders announcement body text. Deliberately + * carries no `rehype-raw` and no `dangerouslySetInnerHTML` — react-markdown parses + * the body into a syntax tree and renders each node as a real React element, so an + * `` written into the body is emitted as inert text, never as an + * element the DOM would execute. That is the whole security model here: there is + * nothing to sanitise because raw HTML is never turned into elements in the first + * place. Do not add `rehype-raw` to this file. + */ +export function Markdown({ children }: MarkdownProps) { + return ( +
+

, + h2: props =>

, + h3: props =>

, + p: props =>

, + ul: props =>

    , + ol: props =>
      , + li: props =>
    1. , + strong: props => , + code: props => ( + + ), + a: ({ href, ...props }: ComponentPropsWithoutRef<'a'>) => { + // Absolute links open in a new tab so an announcement can never + // navigate the host app away from itself; relative in-app links + // (e.g. a quest deep link) stay in place. + const isAbsolute = !!href && /^[a-z][a-z0-9+.-]*:\/\//i.test(href); + return ( + + ); + }, + }} + > + {children} + +

+ ); +} diff --git a/src/components/announcements/__tests__/AnnouncementBell.test.tsx b/src/components/announcements/__tests__/AnnouncementBell.test.tsx new file mode 100644 index 0000000..4df2942 --- /dev/null +++ b/src/components/announcements/__tests__/AnnouncementBell.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AnnouncementBell } from '../AnnouncementBell.js'; +import type { ClientAnnouncement } from '../types.js'; + +function item(over: Partial = {}): ClientAnnouncement { + return { + id: 'a1', priority: 'major', type: 'release', title: 'Season 3 quests are open', + summary: 'Forty-two new quests went live.', body: 'Body', heroUrl: null, cta: null, + publishAt: '2026-07-01T00:00:00.000Z', expiresAt: null, read: false, ...over, + }; +} + +const noop = () => {}; +function props(over: Record = {}) { + return { + items: [item()], unreadCount: 1, prefs: { autoOpenEnabled: true }, + onMarkRead: noop, onMarkAllRead: noop, onSetAutoOpen: noop, onOpenItem: noop, + ...over, + }; +} + +describe('AnnouncementBell', () => { + it('shows the unread count on the bell', () => { + render(); + expect(screen.getByRole('button', { name: /announcements/i }).textContent).toContain('1'); + }); + + it('hides the badge entirely when nothing is unread', () => { + render(); + expect(screen.getByRole('button', { name: /announcements/i }).textContent).not.toContain('0'); + }); + + it('keeps the popover closed until the bell is clicked', async () => { + render(); + expect(screen.queryByText('Season 3 quests are open')).toBeNull(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.getByText('Season 3 quests are open')).toBeTruthy(); + }); + + it('opens an announcement and marks it read in one click', async () => { + const onOpenItem = vi.fn(); + const onMarkRead = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.click(screen.getByText('Season 3 quests are open')); + expect(onOpenItem).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' })); + expect(onMarkRead).toHaveBeenCalledWith('a1', 'popover'); + }); + + it('closes the popover when a row is clicked, not just when opening its modal', async () => { + // A row click is inside rootRef, so the outside-click handler never + // fires for it — without an explicit close, the popover is left open + // behind whatever modal onOpenItem triggers, and dismissing that modal + // reveals it still hanging over the page. + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.click(screen.getByText('Season 3 quests are open')); + expect(screen.queryByText('Season 3 quests are open')).toBeNull(); + }); + + it('omits the View all footer entirely when no onViewAll is given', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.queryByRole('button', { name: /view all announcements/i })).toBeNull(); + }); + + it('closes the popover when View all is clicked, not just when navigating away', async () => { + // Same reasoning as the row-click case above, and the same blind spot: the + // footer is inside rootRef, so neither the outside-click handler nor + // Escape fires for it. Navigation does not save it either — the + // announcement centre lives under the same layout route as the bell, so + // the bell survives the navigation and the popover would hang over the + // page the user just asked to see. + const onViewAll = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.click(screen.getByRole('button', { name: /view all announcements/i })); + expect(onViewAll).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Season 3 quests are open')).toBeNull(); + }); + + it('marks everything read from the footer', async () => { + const onMarkAllRead = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.click(screen.getByRole('button', { name: /mark all read/i })); + expect(onMarkAllRead).toHaveBeenCalled(); + }); + + it('exposes the auto-open toggle and reports changes', async () => { + const onSetAutoOpen = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + const toggle = screen.getByRole('switch', { name: /auto-open/i }); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + await userEvent.click(toggle); + expect(onSetAutoOpen).toHaveBeenCalledWith(false); + }); + + it('renders an empty state rather than a bare panel', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.getByText(/nothing new/i)).toBeTruthy(); + }); + + it('always renders the type icon', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.getByTestId('announcement-type-icon')).toBeTruthy(); + }); + + it('renders no even when the announcement has a heroUrl — the row never shows the hero', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.getByTestId('announcement-type-icon')).toBeTruthy(); + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('announces its popover state to assistive tech via aria-haspopup/aria-expanded', async () => { + render(); + const trigger = screen.getByRole('button', { name: /announcements/i }); + expect(trigger.getAttribute('aria-haspopup')).toBe('true'); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + await userEvent.click(trigger); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + }); + + it('closes on Escape', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.keyboard('{Escape}'); + expect(screen.queryByText('Season 3 quests are open')).toBeNull(); + }); + + it('hangs the popover from its right edge by default', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + const popover = screen.getByText('Season 3 quests are open').closest('[class*="absolute"]'); + expect(popover?.className).toContain('right-0'); + expect(popover?.className).not.toContain('left-0'); + }); + + it('hangs the popover from its left edge when align="left"', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + const popover = screen.getByText('Season 3 quests are open').closest('[class*="absolute"]'); + expect(popover?.className).toContain('left-0'); + expect(popover?.className).not.toContain('right-0'); + }); +}); diff --git a/src/components/announcements/__tests__/AnnouncementModal.test.tsx b/src/components/announcements/__tests__/AnnouncementModal.test.tsx new file mode 100644 index 0000000..5f43d0d --- /dev/null +++ b/src/components/announcements/__tests__/AnnouncementModal.test.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AnnouncementModal } from '../AnnouncementModal.js'; +import type { ClientAnnouncement } from '../types.js'; + +function item(over: Partial = {}): ClientAnnouncement { + return { + id: 'a1', priority: 'major', type: 'release', title: 'Season 3 quests are open', + summary: 'S', body: 'Forty-two **new** quests.', heroUrl: 'https://cdn.example/hero.png', + cta: { label: 'Browse quests', url: '/quests' }, + publishAt: '2026-07-01T00:00:00.000Z', expiresAt: null, read: false, ...over, + }; +} + +describe('AnnouncementModal', () => { + it('renders markdown rather than its syntax', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.getByText('new').tagName).toBe('STRONG'); + expect(screen.queryByText(/\*\*new\*\*/)).toBeNull(); + }); + + it('shows the hero image for an editorial announcement', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.getByRole('img').getAttribute('src')).toBe('https://cdn.example/hero.png'); + }); + + it('hides the hero image for a critical one, which must be read first', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('reports a cta click and dismisses', async () => { + const onCtaClick = vi.fn(); + const onDismiss = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /browse quests/i })); + expect(onCtaClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' })); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('offers only a dismiss action when there is no cta', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.getByRole('button', { name: /got it/i })).toBeTruthy(); + }); + + it('dismisses on Escape', async () => { + const onDismiss = vi.fn(); + render( {}} />); + await userEvent.keyboard('{Escape}'); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('never renders raw html from the body', () => { + const evil = item({ body: 'before after' }); + const { container } = render( {}} onCtaClick={() => {}} />); + expect(container.querySelector('img[onerror]')).toBeNull(); + expect(container.textContent).toContain('after'); + }); + + it('always shows "Got it" for a critical announcement, even when it has a cta', () => { + const critical = item({ priority: 'critical', cta: { label: 'View status', url: '/status' } }); + render( {}} onCtaClick={() => {}} />); + expect(screen.getByRole('button', { name: /got it/i })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /^later$/i })).toBeNull(); + }); + + describe('focus management', () => { + it('moves focus inside the dialog on mount', () => { + render( {}} onCtaClick={() => {}} />); + const dialog = screen.getByRole('dialog'); + expect(dialog.contains(document.activeElement)).toBe(true); + }); + + it('traps focus: Tab from the last focusable wraps to the first, Shift+Tab from the first wraps to the last', async () => { + render( {}} onCtaClick={() => {}} />); + const later = screen.getByRole('button', { name: /^later$/i }); + const cta = screen.getByRole('button', { name: /browse quests/i }); + + // Initial focus lands on the first focusable element in the dialog. + expect(document.activeElement).toBe(later); + + await userEvent.tab(); + expect(document.activeElement).toBe(cta); + + // Tab from the last focusable wraps around to the first. + await userEvent.tab(); + expect(document.activeElement).toBe(later); + + // Shift+Tab from the first focusable wraps around to the last. + await userEvent.tab({ shift: true }); + expect(document.activeElement).toBe(cta); + }); + + it('returns focus to whatever opened it, once it closes', () => { + const opener = document.createElement('button'); + opener.textContent = 'Open announcement'; + document.body.appendChild(opener); + opener.focus(); + expect(document.activeElement).toBe(opener); + + const { unmount } = render( {}} onCtaClick={() => {}} />); + expect(document.activeElement).not.toBe(opener); + + unmount(); + expect(document.activeElement).toBe(opener); + + opener.remove(); + }); + + it('does not steal focus back to the first element when the parent re-renders with a fresh onDismiss', async () => { + // Regression test: the focus/Escape effect used to depend on + // `onDismiss`, so a parent passing a new inline arrow on every render + // (the common case, and what Tasks 4/5 do) tore the effect down and + // rebuilt it while the modal stayed open — snapping focus back to the + // first focusable element even though the user had moved it elsewhere. + function Host() { + const [tick, setTick] = useState(0); + return ( + <> + + {/* A fresh arrow every render, on purpose: this is the shape a + consumer almost always writes, not a contrived worst case. */} + {}} onCtaClick={() => {}} /> + {tick} + + ); + } + + render(); + const cta = screen.getByRole('button', { name: /browse quests/i }); + cta.focus(); + expect(document.activeElement).toBe(cta); + + // `fireEvent.click`, not `userEvent.click`: userEvent's click also + // focuses the clicked element as part of simulating a real user + // interaction, which would move focus to the "bump" button itself and + // defeat the point of this test. `fireEvent` only dispatches the click + // event and runs the handler, isolating the re-render this test cares + // about from an unrelated focus change. + fireEvent.click(screen.getByRole('button', { name: /^bump$/i })); + expect(screen.getByTestId('tick').textContent).toBe('1'); + + // Focus must still be exactly where the user left it. + expect(document.activeElement).toBe(cta); + }); + }); +}); diff --git a/src/components/announcements/__tests__/icons.test.ts b/src/components/announcements/__tests__/icons.test.ts new file mode 100644 index 0000000..0ef7485 --- /dev/null +++ b/src/components/announcements/__tests__/icons.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { ANNOUNCEMENT_ICON_KEYS, ANNOUNCEMENT_ICONS, iconForAnnouncement } from '../icons.js'; +import type { AnnouncementType } from '../types.js'; + +// types.ts exports `AnnouncementType` as a union only, no runtime array — this +// literal must be kept in step with it, same as icons.ts's own comment on why +// the first five ANNOUNCEMENT_ICON_KEYS entries double as type values. +const ANNOUNCEMENT_TYPES: readonly AnnouncementType[] = ['release', 'update', 'event', 'maintenance', 'security']; + +describe('ANNOUNCEMENT_ICON_KEYS / ANNOUNCEMENT_ICONS', () => { + it('pins the closed set of keys exactly — a change here must be deliberate, since sphere-api mirrors this list', () => { + expect([...ANNOUNCEMENT_ICON_KEYS]).toEqual([ + 'release', 'update', 'event', 'maintenance', 'security', + 'megaphone', 'gift', 'sparkles', 'alert-triangle', 'info', 'key', 'coins', 'party', + ]); + }); + + it('has exactly one glyph per key, with no gaps', () => { + for (const key of ANNOUNCEMENT_ICON_KEYS) { + expect(ANNOUNCEMENT_ICONS[key]).toBeTruthy(); + } + }); + + it('gives every announcement type its own icon key', () => { + for (const type of ANNOUNCEMENT_TYPES) { + expect((ANNOUNCEMENT_ICON_KEYS as readonly string[])).toContain(type); + } + }); +}); + +describe('iconForAnnouncement', () => { + it('falls back to the type default when no icon is set', () => { + expect(iconForAnnouncement('security', null)).toBe(ANNOUNCEMENT_ICONS.security); + expect(iconForAnnouncement('release', undefined)).toBe(ANNOUNCEMENT_ICONS.release); + }); + + it('uses the explicit override when it names a valid key', () => { + expect(iconForAnnouncement('release', 'gift')).toBe(ANNOUNCEMENT_ICONS.gift); + }); + + it('falls back to the type default for an unrecognised key rather than rendering nothing', () => { + expect(iconForAnnouncement('event', 'not-a-real-icon')).toBe(ANNOUNCEMENT_ICONS.event); + }); + + it('falls back to the type default for an empty-string icon', () => { + expect(iconForAnnouncement('maintenance', '')).toBe(ANNOUNCEMENT_ICONS.maintenance); + }); +}); diff --git a/src/components/announcements/__tests__/theme.test.ts b/src/components/announcements/__tests__/theme.test.ts new file mode 100644 index 0000000..a684075 --- /dev/null +++ b/src/components/announcements/__tests__/theme.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { priorityTheme } from '../theme.js'; +// Raw text of this module's own source, for the source-scanning test below. +// `?raw` is a Vite import suffix that resolves to the file's literal text at +// transform time — used instead of node:fs/node:url because this repo has +// no @types/node installed for src/. Mirrors +// sphere-backoffice/src/lib/__tests__/announcementTheme.test.ts. +import themeSource from '../theme.ts?raw'; + +describe('priorityTheme', () => { + it('pins critical exactly, including the left-edge border', () => { + expect(priorityTheme('critical')).toEqual({ + label: 'Critical', + accent: 'var(--announcement-alert)', + pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', + borderClass: 'border-[var(--announcement-alert-border)]', + opensModal: true, + }); + }); + + it('pins major exactly, including the left-edge border', () => { + expect(priorityTheme('major')).toEqual({ + label: 'Major', + accent: 'var(--accent)', + pillClass: 'bg-[rgba(255,111,0,0.14)] text-[var(--accent)]', + borderClass: 'border-[rgba(255,111,0,0.28)]', + opensModal: true, + }); + }); + + it('pins normal exactly — no accent colour, per product rule', () => { + expect(priorityTheme('normal')).toEqual({ + label: 'Normal', + accent: 'var(--text-secondary)', + pillClass: 'bg-white/6 text-white/62', + borderClass: 'border-[var(--border)]', + opensModal: false, + }); + }); + + // Regression guard, mirrors sphere-backoffice's announcementTheme.test.ts: + // a template literal that interpolates a constant into a Tailwind bracket + // class (`` `border-[${ALERT}]/28` ``) evaluates to a normal-looking string + // at runtime — the pins above can't tell it apart from a literal — but + // Tailwind's build-time scanner only reads the *source text*, so the + // interpolated form never gets a rule and the border silently renders with + // no colour. This reads the source text itself, the same thing the scanner + // reads. + it('never builds pillClass or borderClass by interpolation — Tailwind cannot scan the result', () => { + const classLines = themeSource + .split('\n') + .filter((line: string) => line.includes('pillClass:') || line.includes('borderClass:')); + expect(classLines.length).toBeGreaterThan(0); + for (const line of classLines) { + expect(line).not.toContain('${'); + } + }); + + // Regression guard: an opacity modifier applied to a `var()` arbitrary + // value (`border-[var(--x)]/28`) has no precedent in this codebase and its + // support isn't guaranteed across every consumer's Tailwind version — + // colours with alpha belong baked into the token (`--announcement-alert-border` + // in tokens.css) instead. This pins that choice so nobody reintroduces the + // modifier form for a "cleaner" one-liner later. + it('never applies an opacity modifier to a borderClass value', () => { + for (const priority of ['critical', 'major', 'normal'] as const) { + expect(priorityTheme(priority).borderClass).not.toMatch(/\]\/\d/); + } + }); +}); diff --git a/src/components/announcements/__tests__/useAnnouncements.test.tsx b/src/components/announcements/__tests__/useAnnouncements.test.tsx new file mode 100644 index 0000000..e547cdd --- /dev/null +++ b/src/components/announcements/__tests__/useAnnouncements.test.tsx @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StrictMode } from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAnnouncements, __resetSessionModalFlag } from '../useAnnouncements.js'; +import type { AnnouncementsClient, AnnouncementFeed } from '../types.js'; + +function feed(over: Partial = {}): AnnouncementFeed { + return { + items: [{ + id: 'a1', priority: 'major', type: 'release', title: 'T', summary: 'S', body: 'B', + heroUrl: null, cta: null, publishAt: '2026-07-01T00:00:00.000Z', expiresAt: null, read: false, + }], + unreadCount: 1, + autoOpen: 'a1', + prefs: { autoOpenEnabled: true }, + ...over, + }; +} + +function makeClient(over: Partial = {}): AnnouncementsClient { + return { + getFeed: vi.fn().mockResolvedValue(feed()), + markRead: vi.fn().mockResolvedValue(undefined), + markAllRead: vi.fn().mockResolvedValue(undefined), + recordClick: vi.fn().mockResolvedValue(undefined), + setPrefs: vi.fn().mockResolvedValue(undefined), + getArchive: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), + ...over, + }; +} + +/** 30 minutes, mirrored from the hook's own `REFRESH_INTERVAL_MS` rather than imported, so the test still pins the contract if that constant is ever renamed. */ +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; + +function setVisibility(state: 'visible' | 'hidden'): void { + Object.defineProperty(document, 'visibilityState', { value: state, configurable: true }); +} + +beforeEach(() => { + localStorage.clear(); + __resetSessionModalFlag(); + setVisibility('visible'); +}); + +describe('useAnnouncements', () => { + it('loads the feed and exposes the unread count', async () => { + const { result } = renderHook(() => useAnnouncements(makeClient())); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.items).toHaveLength(1); + expect(result.current.unreadCount).toBe(1); + }); + + it('surfaces the server\'s auto-open choice', async () => { + const { result } = renderHook(() => useAnnouncements(makeClient())); + await waitFor(() => expect(result.current.autoOpenId).toBe('a1')); + }); + + it('shows at most one modal per session even across remounts', async () => { + const client = makeClient(); + const first = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(first.result.current.autoOpenId).toBe('a1')); + act(() => { first.result.current.dismissModal(); }); + first.unmount(); + + const second = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(second.result.current.isLoading).toBe(false)); + expect(second.result.current.autoOpenId).toBeNull(); + }); + + it('does not re-open a modal the user already dismissed on this device', async () => { + const client = makeClient(); + const first = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(first.result.current.autoOpenId).toBe('a1')); + await act(async () => { await first.result.current.markRead('a1', 'modal'); }); + first.unmount(); + __resetSessionModalFlag(); + + const second = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(second.result.current.isLoading).toBe(false)); + expect(second.result.current.autoOpenId).toBeNull(); + }); + + it('marks read optimistically and tells the server', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.markRead('a1', 'popover'); }); + expect(client.markRead).toHaveBeenCalledWith('a1', 'popover'); + expect(result.current.unreadCount).toBe(0); + expect(result.current.items[0].read).toBe(true); + }); + + it('keeps the optimistic read when the server call fails', async () => { + const client = makeClient({ markRead: vi.fn().mockRejectedValue(new Error('offline')) }); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.markRead('a1', 'popover'); }); + // A read that silently comes back is worse than one that is lost — the + // local mirror is what makes the dismissal stick. + expect(result.current.unreadCount).toBe(0); + }); + + it('marks everything read at once', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.markAllRead(); }); + expect(client.markAllRead).toHaveBeenCalled(); + expect(result.current.unreadCount).toBe(0); + }); + + it('persists the auto-open preference and reflects it immediately', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.setAutoOpen(false); }); + expect(client.setPrefs).toHaveBeenCalledWith(false); + expect(result.current.prefs.autoOpenEnabled).toBe(false); + }); + + it('never surfaces a modal when the feed offers none', async () => { + const client = makeClient({ getFeed: vi.fn().mockResolvedValue(feed({ autoOpen: null })) }); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.autoOpenId).toBeNull(); + }); + + it('reports an error without throwing, so a portal never fails to render', async () => { + const client = makeClient({ getFeed: vi.fn().mockRejectedValue(new Error('500')) }); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBeTruthy(); + expect(result.current.items).toEqual([]); + expect(result.current.unreadCount).toBe(0); + }); + + it('does nothing at all when disabled', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client, { enabled: false })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(client.getFeed).not.toHaveBeenCalled(); + expect(result.current.items).toEqual([]); + }); + + it('still loads under React StrictMode', async () => { + // StrictMode's dev-only mount -> cleanup -> mount cycle is exactly what + // regressed: a cleanup-only `alive` effect never re-arms after the + // synthetic first cleanup, so `getFeed()`'s resolution finds + // `alive.current === false` forever and `isLoading` never clears. Every + // other test in this file renders unwrapped, which is how that hid. + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client), { wrapper: StrictMode }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.items).toHaveLength(1); + }); + + describe('background refresh', () => { + afterEach(() => { vi.useRealTimers(); }); + + it('refetches when the tab becomes visible again', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(client.getFeed).toHaveBeenCalledTimes(1); + + const refreshed = feed({ + items: [{ ...feed().items[0], id: 'a2', read: false }], + unreadCount: 2, + autoOpen: null, + }); + (client.getFeed as ReturnType).mockResolvedValueOnce(refreshed); + + setVisibility('visible'); + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + + await waitFor(() => expect(result.current.unreadCount).toBe(2)); + expect(result.current.items[0].id).toBe('a2'); + expect(client.getFeed).toHaveBeenCalledTimes(2); + }); + + it('a refresh with a fresh candidate does not open a modal or burn the session flag', async () => { + // The mount call finds nothing to auto-open; only a later refresh sees + // a candidate. This isolates the refresh path from the mount path's + // own (already-covered) auto-open behaviour. + const client = makeClient({ getFeed: vi.fn().mockResolvedValue(feed({ autoOpen: null })) }); + const first = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(first.result.current.isLoading).toBe(false)); + expect(first.result.current.autoOpenId).toBeNull(); + + (client.getFeed as ReturnType).mockResolvedValue(feed({ autoOpen: 'a1' })); + await act(async () => { await first.result.current.refresh(); }); + + // Fresh data landed (unreadCount came from the new feed)... + expect(first.result.current.unreadCount).toBe(1); + // ...but the candidate it carried was never allowed to open. + expect(first.result.current.autoOpenId).toBeNull(); + + first.unmount(); + + // A genuine remount (the mount path) still gets its modal for the same + // still-unread candidate — proof the refresh above never spent the + // one-modal-per-session flag. + const second = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(second.result.current.autoOpenId).toBe('a1')); + }); + + it('refreshes on the 30-minute interval while the tab is visible', async () => { + vi.useFakeTimers(); + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + // Flush the mount call's own microtasks only — do NOT run pending + // timers here, since the interval itself is already a pending timer + // (scheduled synchronously by the same mount effect) and would fire + // early, defeating the point of the assertion below. + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(result.current.isLoading).toBe(false); + expect(client.getFeed).toHaveBeenCalledTimes(1); + + setVisibility('visible'); + await act(async () => { await vi.advanceTimersByTimeAsync(REFRESH_INTERVAL_MS); }); + + expect(client.getFeed).toHaveBeenCalledTimes(2); + }); + + it('does not refresh on the interval while the tab is hidden', async () => { + vi.useFakeTimers(); + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(result.current.isLoading).toBe(false); + expect(client.getFeed).toHaveBeenCalledTimes(1); + + setVisibility('hidden'); + await act(async () => { await vi.advanceTimersByTimeAsync(REFRESH_INTERVAL_MS); }); + + // The tick ran (the timer fired) but the hidden check inside it skipped + // the fetch — still just the one call from mount. + expect(client.getFeed).toHaveBeenCalledTimes(1); + }); + + it('removes the visibility listener and clears the interval on unmount', async () => { + const removeSpy = vi.spyOn(document, 'removeEventListener'); + const client = makeClient(); + const { result, unmount } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + + // With the listener really gone, a subsequent visibility change must not + // reach a fetch — the strongest proof cleanup worked, not just that the + // API was called. + const callsBeforeDispatch = (client.getFeed as ReturnType).mock.calls.length; + setVisibility('visible'); + document.dispatchEvent(new Event('visibilitychange')); + expect(client.getFeed).toHaveBeenCalledTimes(callsBeforeDispatch); + }); + + it('installs neither the listener nor the interval, and never refetches, when disabled', async () => { + vi.useFakeTimers(); + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client, { enabled: false })); + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); + expect(result.current.isLoading).toBe(false); + expect(client.getFeed).not.toHaveBeenCalled(); + + setVisibility('visible'); + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + await act(async () => { await vi.advanceTimersByTimeAsync(REFRESH_INTERVAL_MS); }); + + expect(client.getFeed).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/announcements/icons.ts b/src/components/announcements/icons.ts new file mode 100644 index 0000000..dbf3844 --- /dev/null +++ b/src/components/announcements/icons.ts @@ -0,0 +1,68 @@ +import { + Rocket, RefreshCw, Calendar, Wrench, ShieldAlert, + Megaphone, Gift, Sparkles, AlertTriangle, Info, Key, Coins, PartyPopper, +} from 'lucide-react'; +import type { AnnouncementType } from './types.js'; + +/** + * The closed set of icon keys an announcement can be tagged with. A stored + * `icon` is validated against exactly this list, never accepted as a + * free-form string — a key outside it has nothing to look up in + * `ANNOUNCEMENT_ICONS` and would otherwise render as an empty square. + * + * The first five keys double as `AnnouncementType` values on purpose: they + * are every type's own default glyph, so `iconForAnnouncement` below reads + * the default straight out of this one map (`ANNOUNCEMENT_ICONS[type]`) + * instead of keeping a second type -> icon table that could drift from it. + * + * Mirrors sphere-api's `ANNOUNCEMENT_ICON_KEYS` in src/lib/announcements.ts — + * the two must stay in step, since the value this picker writes is what that + * server-side list validates on write. sphere-api owns its own copy rather + * than importing this one (an API must never depend on a UI package), the + * same split as `MEDIA_OWNER_TYPES` (src/components/media/types.ts) and + * sphere-api's `OWNER_TYPES`. + */ +export const ANNOUNCEMENT_ICON_KEYS = [ + 'release', 'update', 'event', 'maintenance', 'security', + 'megaphone', 'gift', 'sparkles', 'alert-triangle', 'info', 'key', 'coins', 'party', +] as const; + +export type AnnouncementIconKey = typeof ANNOUNCEMENT_ICON_KEYS[number]; + +/** + * One lucide-react component per key. Every consumer — this row, and + * sphere-backoffice's icon picker — reads a glyph from this single map, so + * there is exactly one place that decides what each key looks like. + */ +export const ANNOUNCEMENT_ICONS: Record = { + release: Rocket, + update: RefreshCw, + event: Calendar, + maintenance: Wrench, + security: ShieldAlert, + megaphone: Megaphone, + gift: Gift, + sparkles: Sparkles, + 'alert-triangle': AlertTriangle, + info: Info, + key: Key, + coins: Coins, + party: PartyPopper, +}; + +function isAnnouncementIconKey(value: string): value is AnnouncementIconKey { + return (ANNOUNCEMENT_ICON_KEYS as readonly string[]).includes(value); +} + +/** + * The glyph to render for one announcement: the explicit override when it is + * a recognised key, the type's own default otherwise. Applies the same + * "unrecognised falls back, never renders nothing" rule to `icon` as it does + * to an absent one — old data written before this existed, or a value that + * somehow isn't in `ANNOUNCEMENT_ICON_KEYS`, still resolves to a real glyph + * rather than an empty square. + */ +export function iconForAnnouncement(type: AnnouncementType, icon?: string | null): typeof Rocket { + if (icon && isAnnouncementIconKey(icon)) return ANNOUNCEMENT_ICONS[icon]; + return ANNOUNCEMENT_ICONS[type]; +} diff --git a/src/components/announcements/index.ts b/src/components/announcements/index.ts new file mode 100644 index 0000000..fec3d2e --- /dev/null +++ b/src/components/announcements/index.ts @@ -0,0 +1,14 @@ +// Announcements — types, port, theme, icons, hook and the surfaces built on them. +export * from './types.js'; +export * from './theme.js'; +export * from './icons.js'; +export { useAnnouncements, __resetSessionModalFlag } from './useAnnouncements.js'; +export type { UseAnnouncementsOptions, UseAnnouncementsResult } from './useAnnouncements.js'; +export { AnnouncementBell } from './AnnouncementBell.js'; +export type { AnnouncementBellProps } from './AnnouncementBell.js'; +export { AnnouncementRow } from './AnnouncementRow.js'; +export type { AnnouncementRowProps } from './AnnouncementRow.js'; +export { Markdown } from './Markdown.js'; +export type { MarkdownProps } from './Markdown.js'; +export { AnnouncementModal } from './AnnouncementModal.js'; +export type { AnnouncementModalProps } from './AnnouncementModal.js'; diff --git a/src/components/announcements/theme.ts b/src/components/announcements/theme.ts new file mode 100644 index 0000000..4589121 --- /dev/null +++ b/src/components/announcements/theme.ts @@ -0,0 +1,73 @@ +import type { AnnouncementPriority } from './types.js'; + +/** + * Alert red — the brand palette (src/styles/tokens.css) defines no alert + * colour, so `--announcement-alert` and `--announcement-alert-text` are + * declared there purely for this one case. `ALERT` gives this module one + * place to read the base tone for `accent`. + * + * Every class string below — `pillClass` and `borderClass` alike — is + * written out as a literal, never built by interpolating a constant into a + * template (e.g. `` text-[${SOME_CONST}] ``): Tailwind's build-time scanner + * reads source text and does not execute code, so an interpolated class name + * is invisible to it and never gets a rule generated. That is the exact bug + * this map's counterpart in sphere-backoffice/src/lib/announcementTheme.ts + * shipped once already. + */ +const ALERT = 'var(--announcement-alert)'; + +export interface PriorityTheme { + label: string; + /** CSS colour value — a brand token where one exists, a literal only for the alert red. */ + accent: string; + pillClass: string; + /** Coloured left edge — the same theme read by the pill and the CTA, so a bell/modal border can't drift from them. */ + borderClass: string; + /** Whether this priority interrupts the user with a modal. */ + opensModal: boolean; +} + +/** + * Priority is the announcement's theme, not a badge on it: the same value + * colours the pill, the row's left edge and the CTA across every surface + * that reads it. Kept identical to sphere-backoffice's `announcementTheme.ts` + * so the admin composer's preview and the real thing on wallet/quest/ + * dev-portal can never disagree. + * + * Orange means "needs your attention" product-wide, so `normal` deliberately + * gets a neutral tone rather than a dimmer orange — otherwise everything + * would end up orange and the signal would be lost. + */ +const THEMES: Record = { + critical: { + label: 'Critical', + accent: ALERT, + pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', + // --announcement-alert-border bakes the 0.28 alpha into the token itself + // (see tokens.css), rather than applying a Tailwind opacity modifier to + // a `var()` arbitrary value here — that form has no precedent in either + // repo and isn't worth trusting sight-unseen after this plan already + // lost a day to a class that looked fine in source and was never + // emitted. No modifier, no question. + borderClass: 'border-[var(--announcement-alert-border)]', + opensModal: true, + }, + major: { + label: 'Major', + accent: 'var(--accent)', + pillClass: 'bg-[rgba(255,111,0,0.14)] text-[var(--accent)]', + borderClass: 'border-[rgba(255,111,0,0.28)]', + opensModal: true, + }, + normal: { + label: 'Normal', + accent: 'var(--text-secondary)', + pillClass: 'bg-white/6 text-white/62', + borderClass: 'border-[var(--border)]', + opensModal: false, + }, +}; + +export function priorityTheme(priority: AnnouncementPriority): PriorityTheme { + return THEMES[priority]; +} diff --git a/src/components/announcements/types.ts b/src/components/announcements/types.ts new file mode 100644 index 0000000..fd273b9 --- /dev/null +++ b/src/components/announcements/types.ts @@ -0,0 +1,45 @@ +export const ANNOUNCEMENT_CLIENT_IDS = ['sphere', 'quest', 'developer'] as const; +export type AnnouncementClientId = typeof ANNOUNCEMENT_CLIENT_IDS[number]; + +export type AnnouncementPriority = 'critical' | 'major' | 'normal'; +export type AnnouncementType = 'release' | 'update' | 'event' | 'maintenance' | 'security'; + +export interface ClientAnnouncement { + id: string; + priority: AnnouncementPriority; + type: AnnouncementType; + title: string; + summary: string; + body: string; + heroUrl: string | null; + /** Explicit override of the type's default glyph, or null/undefined when + * the announcement never overrode it. Validated server-side against the + * same closed set `iconForAnnouncement` (./icons.ts) reads — see that + * file for why an unrecognised value still resolves to a real glyph. */ + icon?: string | null; + /** Already flattened to this portal by the server. */ + cta: { label: string; url: string } | null; + publishAt: string; + expiresAt: string | null; + read: boolean; +} + +export interface AnnouncementFeed { + items: ClientAnnouncement[]; + unreadCount: number; + autoOpen: string | null; + prefs: { autoOpenEnabled: boolean }; +} + +/** + * The port each portal implements. This library never learns how any app + * authenticates — it is handed six functions and nothing else. + */ +export interface AnnouncementsClient { + getFeed(): Promise; + getArchive(cursor?: string): Promise<{ items: ClientAnnouncement[]; nextCursor: string | null }>; + markRead(id: string, via: 'modal' | 'popover'): Promise; + markAllRead(): Promise; + recordClick(id: string): Promise; + setPrefs(autoOpenEnabled: boolean): Promise; +} diff --git a/src/components/announcements/useAnnouncements.ts b/src/components/announcements/useAnnouncements.ts new file mode 100644 index 0000000..5896bfa --- /dev/null +++ b/src/components/announcements/useAnnouncements.ts @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { AnnouncementsClient, AnnouncementFeed, ClientAnnouncement } from './types.js'; + +/** + * One modal per app session. Module-level rather than component state on + * purpose: a portal may mount the bell in more than one place, or remount it + * during navigation, and neither should earn the user a second interruption. + */ +let sessionModalShown = false; + +/** Test-only escape hatch — a module-level flag would otherwise leak between cases. */ +export function __resetSessionModalFlag(): void { + sessionModalShown = false; +} + +const DISMISSED_KEY = 'announcements:dismissed'; + +function readDismissed(): Set { + try { + const raw = localStorage.getItem(DISMISSED_KEY); + return new Set(raw ? (JSON.parse(raw) as string[]) : []); + } catch { + return new Set(); + } +} + +function rememberDismissed(id: string): void { + try { + const next = readDismissed(); + next.add(id); + // Bounded: the server is the real record, this only stops a flash on a + // cold start before the feed arrives. + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next].slice(-100))); + } catch { + // A blocked or full localStorage must never break the portal. + } +} + +export interface UseAnnouncementsOptions { + /** Pass false while the portal has no identity yet — before a wallet exists. */ + enabled?: boolean; +} + +/** How long a foreground tab is allowed to go without a background refresh. */ +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; + +/** + * `'mount'` is the page-load path: it may open the modal, exactly as before. + * `'refresh'` is everything that fires later on its own — tab-visible and the + * interval — and it is not allowed to interrupt anyone. See `load` below. + */ +type LoadMode = 'mount' | 'refresh'; + +export interface UseAnnouncementsResult { + items: ClientAnnouncement[]; + unreadCount: number; + prefs: { autoOpenEnabled: boolean }; + autoOpenId: string | null; + isLoading: boolean; + error: Error | null; + markRead: (id: string, via: 'modal' | 'popover') => Promise; + markAllRead: () => Promise; + setAutoOpen: (value: boolean) => Promise; + dismissModal: () => void; + refresh: () => Promise; +} + +/** Call this hook once per app and pass its values down to the bell and the modal — see `sessionModalShown` above for why. */ +export function useAnnouncements( + client: AnnouncementsClient, + opts: UseAnnouncementsOptions = {}, +): UseAnnouncementsResult { + const enabled = opts.enabled ?? true; + + const [feed, setFeed] = useState(null); + const [isLoading, setLoad] = useState(enabled); + const [error, setError] = useState(null); + const [autoOpenId, setAuto] = useState(null); + const alive = useRef(true); + + // Re-arm on every mount, not just on the initial one: React 18/19 dev-mode + // StrictMode deliberately mounts, cleans up, then mounts again to surface + // effects that don't tolerate it. A cleanup-only effect leaves `alive` + // permanently false after that synthetic first cleanup, so the in-flight + // `getFeed()` from the (also synthetic) first mount — and every load after + // it — hits `if (!alive.current) return` before `setLoad(false)`, and + // `isLoading` never leaves `true`. Setting it back to `true` on mount keeps + // the ref in sync with the component's actual lifecycle instead of only + // its first one. + useEffect(() => { + alive.current = true; + return () => { alive.current = false; }; + }, []); + + // Single fetch path for both the initial page-load and every later + // background refresh — the two only diverge on what they're allowed to do + // with the auto-open candidate. `mode === 'mount'` is the sole path that + // may claim `sessionModalShown` and set `autoOpenId`; a `'refresh'` call + // updates `items` / `unreadCount` / `prefs` and otherwise leaves the modal + // state exactly as it found it, on the product rule that a background + // refresh must never open (or re-arm) a modal — the user may be mid-send. + const load = useCallback(async (mode: LoadMode) => { + if (!enabled) { if (mode === 'mount') setLoad(false); return; } + try { + const next = await client.getFeed(); + if (!alive.current) return; + setFeed(next); + setError(null); + + if (mode === 'mount') { + const dismissed = readDismissed(); + const candidate = next.autoOpen; + if (candidate && !sessionModalShown && !dismissed.has(candidate)) { + sessionModalShown = true; + setAuto(candidate); + } + } + } catch (e) { + if (!alive.current) return; + // Announcements are never a reason a portal fails to render. + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + // Only the mount path drives `isLoading` — a background refresh must + // stay invisible, not flip a spinner on for a tab nobody is watching. + if (mode === 'mount' && alive.current) setLoad(false); + } + }, [client, enabled]); + + useEffect(() => { void load('mount'); }, [load]); + + // Background refresh: catches up a long-lived tab without ever surfacing a + // modal (see `load` above). Two triggers, both gated on `enabled` so + // neither the listener nor the interval is even installed while disabled: + // - the tab regaining visibility, for a user who switched away and back; + // - a 30-minute interval, for a tab that stays foregrounded all day and + // so never fires a visibilitychange on its own. + // The interval tick itself is skipped while hidden — a background tab has + // no one to show a badge to, and the listener above already refreshes the + // moment it comes back. + useEffect(() => { + if (!enabled) return; + + function handleVisibilityChange(): void { + if (document.visibilityState === 'visible') void load('refresh'); + } + + document.addEventListener('visibilitychange', handleVisibilityChange); + + const intervalId = setInterval(() => { + if (document.visibilityState === 'visible') void load('refresh'); + }, REFRESH_INTERVAL_MS); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + clearInterval(intervalId); + }; + }, [enabled, load]); + + const applyRead = useCallback((ids: string[]) => { + setFeed(prev => { + if (!prev) return prev; + const set = new Set(ids); + const items: ClientAnnouncement[] = prev.items.map(i => (set.has(i.id) ? { ...i, read: true } : i)); + return { ...prev, items, unreadCount: items.filter(i => !i.read).length }; + }); + }, []); + + const markRead = useCallback(async (id: string, via: 'modal' | 'popover') => { + applyRead([id]); + rememberDismissed(id); + if (autoOpenId === id) setAuto(null); + try { + await client.markRead(id, via); + } catch { + // Deliberately swallowed: the optimistic state stands. A dismissal that + // silently comes back is worse than one the server has not recorded yet. + } + }, [applyRead, autoOpenId, client]); + + const markAllRead = useCallback(async () => { + setFeed(prev => prev && ({ ...prev, items: prev.items.map(i => ({ ...i, read: true })), unreadCount: 0 })); + try { await client.markAllRead(); } catch { /* as above */ } + }, [client]); + + const setAutoOpen = useCallback(async (value: boolean) => { + setFeed(prev => prev && ({ ...prev, prefs: { autoOpenEnabled: value } })); + try { await client.setPrefs(value); } catch { /* as above */ } + }, [client]); + + const dismissModal = useCallback(() => { setAuto(null); }, []); + + // A caller invoking `refresh()` explicitly is asking for fresh data, not to + // be interrupted — it gets the same non-opening semantics as the automatic + // triggers above, not the mount path's modal selection. + const refresh = useCallback(() => load('refresh'), [load]); + + return { + items: feed?.items ?? [], + unreadCount: feed?.unreadCount ?? 0, + prefs: feed?.prefs ?? { autoOpenEnabled: true }, + autoOpenId, + isLoading, + error, + markRead, + markAllRead, + setAutoOpen, + dismissModal, + refresh, + }; +} diff --git a/src/components/media/MediaUploader.tsx b/src/components/media/MediaUploader.tsx index dc352a9..a47f403 100644 --- a/src/components/media/MediaUploader.tsx +++ b/src/components/media/MediaUploader.tsx @@ -9,14 +9,14 @@ import { sourceSizeLimit, humanSize, } from './media-limits.js'; -import type { MediaKind, MediaUploadFn, MediaLimit } from './types.js'; +import type { MediaKind, MediaUploadFn, MediaLimit, MediaOwnerType } from './types.js'; import { Input } from '../Input.js'; import { BannerCropEditor } from './BannerCropEditor.js'; import type { CropRect } from './crop-rect.js'; export interface MediaUploaderProps { kind: MediaKind; - ownerType: 'project' | 'organization' | 'quest' | 'achievement' | 'track'; + ownerType: MediaOwnerType; ownerId: string; value?: string | null; onChange: (url: string | null) => void; diff --git a/src/components/media/__tests__/media-owner-type.test.ts b/src/components/media/__tests__/media-owner-type.test.ts new file mode 100644 index 0000000..f969333 --- /dev/null +++ b/src/components/media/__tests__/media-owner-type.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { MEDIA_OWNER_TYPES } from '../types.js'; + +describe('MEDIA_OWNER_TYPES', () => { + it('lists every owner type the API accepts, including announcement', () => { + expect([...MEDIA_OWNER_TYPES]).toEqual([ + 'project', 'organization', 'quest', 'achievement', 'track', 'announcement', + ]); + }); +}); diff --git a/src/components/media/index.ts b/src/components/media/index.ts index 204c9d4..fa5ea68 100644 --- a/src/components/media/index.ts +++ b/src/components/media/index.ts @@ -13,4 +13,5 @@ export { type AchievementPreviewSummary, } from './ProjectPagePreview.js'; export { MEDIA_LIMITS, isMimeAllowed, isSizeAllowed, humanSize } from './media-limits.js'; -export type { MediaKind, MediaMime, MediaLimit, MediaUploadFn, MediaUploadResult } from './types.js'; +export type { MediaKind, MediaMime, MediaLimit, MediaUploadFn, MediaUploadResult, MediaOwnerType } from './types.js'; +export { MEDIA_OWNER_TYPES } from './types.js'; diff --git a/src/components/media/types.ts b/src/components/media/types.ts index dc248ee..ba27df8 100644 --- a/src/components/media/types.ts +++ b/src/components/media/types.ts @@ -1,6 +1,17 @@ export type MediaKind = 'logo' | 'banner' | 'screenshot' | 'image' | 'background'; export type MediaMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/svg+xml'; +/** + * Every entity that can own an uploaded asset. Mirrors OWNER_TYPES in + * sphere-api's src/lib/media-limits.ts — the two must stay in step, since the + * value is sent verbatim to /api/upload/presign. + */ +export const MEDIA_OWNER_TYPES = [ + 'project', 'organization', 'quest', 'achievement', 'track', 'announcement', +] as const; + +export type MediaOwnerType = typeof MEDIA_OWNER_TYPES[number]; + export interface MediaLimit { mimes: readonly MediaMime[]; maxSize: number; @@ -18,7 +29,7 @@ export interface MediaUploadResult { export interface MediaUploadFn { (file: File, opts: { kind: MediaKind; - ownerType: 'project' | 'organization' | 'quest' | 'achievement' | 'track'; + ownerType: MediaOwnerType; ownerId: string; onProgress?: (pct: number) => void; signal?: AbortSignal; diff --git a/src/index.ts b/src/index.ts index 7cc9174..07d3e99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,5 +36,8 @@ export * from './components/Icons'; // Media components (uploader, gallery, marketplace preview) export * from './components/media/index.js'; +// Announcements (types, port, theme, hook, bell/popover, modal, markdown) +export * from './components/announcements/index.js'; + // Types export * from './types'; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 0b9008c..9620980 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -56,6 +56,24 @@ --radius-sm: 6px; --radius-md: 10px; --radius-lg: 14px; + + /* Alert red — the brand palette above defines no alert colour, so it lives + here as its own pair rather than being invented per-consumer. Used by the + announcements priority theme (src/components/announcements/theme.ts) for + "critical"; referenced from TypeScript as `var(--announcement-alert)` / + `var(--announcement-alert-text)` rather than a hex literal, so a + Tailwind class built from it (`text-[var(--announcement-alert-text)]`) + stays a literal string the build-time scanner can see. `-text` is a + lighter tint, for contrast on the tinted pill background. `-border` bakes + in the alpha (same rgba(229,72,77,0.28) value sphere-backoffice uses for + its own critical border) rather than relying on a Tailwind opacity + modifier applied to a `var()` arbitrary value — that modifier form has no + precedent elsewhere in either repo, and this plan already lost a day to + a class that looked fine in source and was never emitted; baking the + alpha into the token removes the question instead of trusting it. */ + --announcement-alert: #E5484D; + --announcement-alert-text: #FF8B8E; + --announcement-alert-border: rgba(229, 72, 77, 0.28); } /* ─── Tailwind 4 theme map ────────────────────────────────────────────────