From aa0daa0effd3d18cf417f3b141db3242d6d78168 Mon Sep 17 00:00:00 2001 From: George-Taco Date: Mon, 31 Aug 2026 03:56:58 +0000 Subject: [PATCH 1/4] working devcontainer --- .devcontainer/devcontainer.json | 12 +++++++++++ bun.lock | 36 --------------------------------- 2 files changed, 12 insertions(+), 36 deletions(-) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..7b97b126 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +{ + "name": "OpenCode Dev", + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", + + "postCreateCommand": "npm install -g bun", + + "customizations": { + "vscode": { + "extensions": [] + } + } +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 1e3a6425..47da2b1f 100644 --- a/bun.lock +++ b/bun.lock @@ -1442,8 +1442,6 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="], @@ -3002,8 +3000,6 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], @@ -3124,8 +3120,6 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="], - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -3610,8 +3604,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], @@ -4132,12 +4124,6 @@ "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], - "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], - - "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], - - "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], "iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="], @@ -4306,8 +4292,6 @@ "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], - "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -6122,10 +6106,6 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6154,10 +6134,6 @@ "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], - - "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], @@ -6208,8 +6184,6 @@ "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6300,8 +6274,6 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6352,8 +6324,6 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], - "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -6960,18 +6930,12 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], - "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], From 37cf52d3da202a8080dea4ff1f060dbeae696a5c Mon Sep 17 00:00:00 2001 From: George-Taco Date: Mon, 7 Sep 2026 18:44:58 +0000 Subject: [PATCH 2/4] init --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7b97b126..6b96b912 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ "name": "OpenCode Dev", "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", - "postCreateCommand": "npm install -g bun", + "postCreateCommand": "npm install -g bun && curl https://qlty.sh | bash", "customizations": { "vscode": { From aec8f896072cfd7f006d5dd83dadab52a46f6aa8 Mon Sep 17 00:00:00 2001 From: George-Taco Date: Mon, 7 Sep 2026 20:30:23 +0000 Subject: [PATCH 3/4] refactored init --- packages/ui/src/theme/context.tsx | 395 +++++++++++++++++------------- 1 file changed, 228 insertions(+), 167 deletions(-) diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index f225ee33..e1d6e2bf 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ // @refresh reload import { createEffect, onMount } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, type SetStoreFunction } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { createSimpleContext } from "../context/helper" import oc2ThemeJson from "./themes/oc-2.json" @@ -171,147 +171,248 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { } } -export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ - name: "Theme", - init: (props: { - defaultTheme?: string - onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark", scheme: ColorScheme) => void - }) => { - const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" - const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" - const mode = colorScheme === "system" ? getSystemMode() : colorScheme - const [store, setStore] = createStore({ - themes: { - "oc-2": oc2Theme, - } as Record, - themeId, - colorScheme, - mode, - previewThemeId: null as string | null, - previewScheme: null as ColorScheme | null, - }) - - const loads = new Map>() - - const load = (id: string) => { - const next = normalize(id) - if (!next) return Promise.resolve(undefined) - const hit = store.themes[next] - if (hit) return Promise.resolve(hit) - const pending = loads.get(next) - if (pending) return pending - const file = getFiles()[`./themes/${next}.json`] - if (!file) return Promise.resolve(undefined) - const task = file() - .then((mod) => { - const theme = mod.default - setStore("themes", next, theme) - return theme - }) - .finally(() => { - loads.delete(next) - }) - loads.set(next, task) - return task - } - - const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark", scheme: ColorScheme) => { - applyThemeCss(theme, themeId, mode) - props.onThemeApplied?.(theme, mode, scheme) - } - - const ids = () => { - const extra = Object.keys(store.themes) - .filter((id) => !knownThemes().has(id)) - .sort() - const all = themeIDs() - if (extra.length === 0) return all - return [...all, ...extra] - } - - const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) +type ThemeProps = { + defaultTheme?: string + onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark", scheme: ColorScheme) => void +} - const onStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { - const next = normalize(e.newValue) - if (!next) return - if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return - setStore("themeId", next) - if (next === "oc-2") { - clear() - return - } - void load(next).then((theme) => { - if (!theme || store.themeId !== next) return - cacheThemeVariants(theme, next) - }) - } - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { - setStore("colorScheme", e.newValue as ColorScheme) - setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) - } - } +type ThemeStore = { + themes: Record + themeId: string + colorScheme: ColorScheme + mode: "light" | "dark" + previewThemeId: string | null + previewScheme: ColorScheme | null +} - onMount(() => { - makeEventListener(window, "storage", onStorage) +type ThemeLoader = (id: string) => Promise +type ApplyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark", scheme: ColorScheme) => void - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const onMedia = () => { - if (store.colorScheme !== "system") return - setStore("mode", getSystemMode()) - } - makeEventListener(mediaQuery, "change", onMedia) +function isValidThemeId(store: ThemeStore, id: string) { + return id === "oc-2" || knownThemes().has(id) || Boolean(store.themes[id]) +} - const rawTheme = read(STORAGE_KEYS.THEME_ID) - const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" - const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" - if (rawTheme && rawTheme !== savedTheme) { - write(STORAGE_KEYS.THEME_ID, savedTheme) - clear() - } - if (savedTheme !== store.themeId) setStore("themeId", savedTheme) - if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) - setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) - void load(savedTheme).then((theme) => { - if (!theme || store.themeId !== savedTheme) return - cacheThemeVariants(theme, savedTheme) +function createThemeLoader(store: ThemeStore, setStore: SetStoreFunction): ThemeLoader { + const loads = new Map>() + return (id: string) => { + const next = normalize(id) + if (!next) return Promise.resolve(undefined) + const hit = store.themes[next] + if (hit) return Promise.resolve(hit) + const pending = loads.get(next) + if (pending) return pending + const file = getFiles()[`./themes/${next}.json`] + if (!file) return Promise.resolve(undefined) + const task = file() + .then((mod) => { + const theme = mod.default + setStore("themes", next, theme) + return theme }) - }) + .finally(() => { + loads.delete(next) + }) + loads.set(next, task) + return task + } +} - createEffect(() => { - const theme = store.themes[store.themeId] - if (!theme) return - applyTheme(theme, store.themeId, store.mode, store.colorScheme) - }) +function createApplyTheme(props: ThemeProps): ApplyTheme { + return (theme, themeId, mode, scheme) => { + applyThemeCss(theme, themeId, mode) + props.onThemeApplied?.(theme, mode, scheme) + } +} - const setTheme = (id: string) => { - const next = normalize(id) - if (!next) { - console.warn(`Theme "${id}" not found`) - return - } - if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { - console.warn(`Theme "${id}" not found`) - return - } +function createIds(store: ThemeStore) { + return () => { + const extra = Object.keys(store.themes) + .filter((id) => !knownThemes().has(id)) + .sort() + const all = themeIDs() + if (extra.length === 0) return all + return [...all, ...extra] + } +} + +function createOnStorage(store: ThemeStore, setStore: SetStoreFunction, load: ThemeLoader) { + return (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + const next = normalize(e.newValue) + if (!next || !isValidThemeId(store, next)) return setStore("themeId", next) if (next === "oc-2") { - write(STORAGE_KEYS.THEME_ID, next) clear() return } void load(next).then((theme) => { if (!theme || store.themeId !== next) return cacheThemeVariants(theme, next) - write(STORAGE_KEYS.THEME_ID, next) }) } + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) + } + } +} - const setColorScheme = (scheme: ColorScheme) => { - setStore("colorScheme", scheme) - write(STORAGE_KEYS.COLOR_SCHEME, scheme) - setStore("mode", scheme === "system" ? getSystemMode() : scheme) +function setupThemeMount( + store: ThemeStore, + setStore: SetStoreFunction, + load: ThemeLoader, + onStorage: (e: StorageEvent) => void, + props: ThemeProps, +) { + makeEventListener(window, "storage", onStorage) + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const onMedia = () => { + if (store.colorScheme !== "system") return + setStore("mode", getSystemMode()) + } + makeEventListener(mediaQuery, "change", onMedia) + + const rawTheme = read(STORAGE_KEYS.THEME_ID) + const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + if (rawTheme && rawTheme !== savedTheme) { + write(STORAGE_KEYS.THEME_ID, savedTheme) + clear() + } + if (savedTheme !== store.themeId) setStore("themeId", savedTheme) + if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) + setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) + void load(savedTheme).then((theme) => { + if (!theme || store.themeId !== savedTheme) return + cacheThemeVariants(theme, savedTheme) + }) +} + +function createSetTheme(store: ThemeStore, setStore: SetStoreFunction, load: ThemeLoader) { + return (id: string) => { + const next = normalize(id) + if (!next || !isValidThemeId(store, next)) { + console.warn(`Theme "${id}" not found`) + return } + setStore("themeId", next) + if (next === "oc-2") { + write(STORAGE_KEYS.THEME_ID, next) + clear() + return + } + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + write(STORAGE_KEYS.THEME_ID, next) + }) + } +} + +function createSetColorScheme(setStore: SetStoreFunction) { + return (scheme: ColorScheme) => { + setStore("colorScheme", scheme) + write(STORAGE_KEYS.COLOR_SCHEME, scheme) + setStore("mode", scheme === "system" ? getSystemMode() : scheme) + } +} + +function createPreviewTheme(store: ThemeStore, setStore: SetStoreFunction, load: ThemeLoader, applyTheme: ApplyTheme) { + return (id: string) => { + const next = normalize(id) + if (!next || !isValidThemeId(store, next)) return + setStore("previewThemeId", next) + void load(next).then((theme) => { + if (!theme || store.previewThemeId !== next) return + const mode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, mode, store.previewScheme ?? store.colorScheme) + }) + } +} + +function createPreviewColorScheme( + store: ThemeStore, + setStore: SetStoreFunction, + load: ThemeLoader, + applyTheme: ApplyTheme, +) { + return (scheme: ColorScheme) => { + setStore("previewScheme", scheme) + const mode = scheme === "system" ? getSystemMode() : scheme + const id = store.previewThemeId ?? store.themeId + void load(id).then((theme) => { + if (!theme) return + if ((store.previewThemeId ?? store.themeId) !== id) return + if (store.previewScheme !== scheme) return + applyTheme(theme, id, mode, scheme) + }) + } +} + +function createCommitPreview( + store: ThemeStore, + setStore: SetStoreFunction, + setTheme: (id: string) => void, + setColorScheme: (scheme: ColorScheme) => void, +) { + return () => { + if (store.previewThemeId) setTheme(store.previewThemeId) + if (store.previewScheme) setColorScheme(store.previewScheme) + setStore("previewThemeId", null) + setStore("previewScheme", null) + } +} + +function createCancelPreview(store: ThemeStore, setStore: SetStoreFunction, load: ThemeLoader, applyTheme: ApplyTheme) { + return () => { + setStore("previewThemeId", null) + setStore("previewScheme", null) + void load(store.themeId).then((theme) => { + if (!theme) return + applyTheme(theme, store.themeId, store.mode, store.colorScheme) + }) + } +} + +export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ + name: "Theme", + init: (props: ThemeProps) => { + const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + const mode = colorScheme === "system" ? getSystemMode() : colorScheme + const [store, setStore] = createStore({ + themes: { "oc-2": oc2Theme }, + themeId, + colorScheme, + mode, + previewThemeId: null, + previewScheme: null, + }) + + const load = createThemeLoader(store, setStore) + const applyTheme = createApplyTheme(props) + const ids = createIds(store) + const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) + const onStorage = createOnStorage(store, setStore, load) + const setTheme = createSetTheme(store, setStore, load) + const setColorScheme = createSetColorScheme(setStore) + const previewTheme = createPreviewTheme(store, setStore, load, applyTheme) + const previewColorScheme = createPreviewColorScheme(store, setStore, load, applyTheme) + const commitPreview = createCommitPreview(store, setStore, setTheme, setColorScheme) + const cancelPreview = createCancelPreview(store, setStore, load, applyTheme) + + onMount(() => setupThemeMount(store, setStore, load, onStorage, props)) + + createEffect(() => { + const theme = store.themes[store.themeId] + if (!theme) return + applyTheme(theme, store.themeId, store.mode, store.colorScheme) + }) return { themeId: () => store.themeId, @@ -324,50 +425,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setTheme, setColorScheme, registerTheme: (theme: DesktopTheme) => setStore("themes", theme.id, theme), - previewTheme: (id: string) => { - const next = normalize(id) - if (!next) return - if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return - setStore("previewThemeId", next) - void load(next).then((theme) => { - if (!theme || store.previewThemeId !== next) return - const mode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, mode, store.previewScheme ?? store.colorScheme) - }) - }, - previewColorScheme: (scheme: ColorScheme) => { - setStore("previewScheme", scheme) - const mode = scheme === "system" ? getSystemMode() : scheme - const id = store.previewThemeId ?? store.themeId - void load(id).then((theme) => { - if (!theme) return - if ((store.previewThemeId ?? store.themeId) !== id) return - if (store.previewScheme !== scheme) return - applyTheme(theme, id, mode, scheme) - }) - }, - commitPreview: () => { - if (store.previewThemeId) { - setTheme(store.previewThemeId) - } - if (store.previewScheme) { - setColorScheme(store.previewScheme) - } - setStore("previewThemeId", null) - setStore("previewScheme", null) - }, - cancelPreview: () => { - setStore("previewThemeId", null) - setStore("previewScheme", null) - void load(store.themeId).then((theme) => { - if (!theme) return - applyTheme(theme, store.themeId, store.mode, store.colorScheme) - }) - }, + previewTheme, + previewColorScheme, + commitPreview, + cancelPreview, } }, }) From d7506ff5858b5e66cb4003ed43f7bfae91a15134 Mon Sep 17 00:00:00 2001 From: George-Taco Date: Tue, 8 Sep 2026 00:04:38 +0000 Subject: [PATCH 4/4] test coverage for changes --- packages/ui/src/theme/context.test.ts | 294 ++++++++++++++++++++++++++ packages/ui/src/theme/context.tsx | 14 +- 2 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/theme/context.test.ts diff --git a/packages/ui/src/theme/context.test.ts b/packages/ui/src/theme/context.test.ts new file mode 100644 index 00000000..20b25744 --- /dev/null +++ b/packages/ui/src/theme/context.test.ts @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import { createStore } from "solid-js/store" +import { ThemeTesting, type ColorScheme, type ThemeStore } from "./context" +import type { DesktopTheme } from "./types" + +const { + isValidThemeId, + createThemeLoader, + createSetTheme, + createSetColorScheme, + createOnStorage, + createPreviewTheme, + createPreviewColorScheme, + createCommitPreview, + createCancelPreview, +} = ThemeTesting + +const fakeTheme = { id: "oc-2", name: "OC-2", light: {}, dark: {} } as unknown as DesktopTheme + +class MemoryStorage implements Storage { + private values = new Map() + clear() { + this.values.clear() + } + get length() { + return this.values.size + } + key(index: number) { + return Array.from(this.values.keys())[index] ?? null + } + getItem(key: string) { + return this.values.get(key) ?? null + } + setItem(key: string, value: string) { + this.values.set(key, value) + } + removeItem(key: string) { + this.values.delete(key) + } +} + +const storage = new MemoryStorage() + +beforeEach(() => { + storage.clear() + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }) +}) + +function makeStore(overrides: Partial = {}) { + return createStore({ + themes: {}, + themeId: "oc-2", + colorScheme: "system", + mode: "light", + previewThemeId: null, + previewScheme: null, + ...overrides, + } as ThemeStore) +} + +describe("isValidThemeId", () => { + test("the built-in oc-2 theme is always valid", () => { + const [store] = makeStore() + expect(isValidThemeId(store, "oc-2")).toBe(true) + }) +}) + +describe("createSetTheme", () => { + test("warns and does not change state for an empty id", () => { + const [store, setStore] = makeStore({ themeId: "oc-2" }) + const setTheme = createSetTheme(store, setStore, () => Promise.resolve(undefined)) + + setTheme("") + + expect(store.themeId).toBe("oc-2") + }) + + test("selecting oc-2 persists the id and clears cached custom-theme css", () => { + const [store, setStore] = makeStore({ themeId: "dracula" }) + const setTheme = createSetTheme(store, setStore, () => Promise.resolve(undefined)) + storage.setItem("opencode-theme-css-light", "cached") + storage.setItem("opencode-theme-css-dark", "cached") + + setTheme("oc-2") + + expect(store.themeId).toBe("oc-2") + expect(storage.getItem("opencode-theme-id")).toBe("oc-2") + expect(storage.getItem("opencode-theme-css-light")).toBeNull() + expect(storage.getItem("opencode-theme-css-dark")).toBeNull() + }) +}) + +describe("createSetColorScheme", () => { + test("persists the scheme and resolves mode for light/dark", () => { + const [store, setStore] = makeStore() + const setColorScheme = createSetColorScheme(setStore) + + setColorScheme("dark") + + expect(store.colorScheme).toBe("dark") + expect(store.mode).toBe("dark") + expect(storage.getItem("opencode-color-scheme")).toBe("dark") + }) + + test("system scheme falls back to light mode when there is no window", () => { + const [store, setStore] = makeStore() + const setColorScheme = createSetColorScheme(setStore) + + setColorScheme("system") + + expect(store.colorScheme).toBe("system") + expect(store.mode).toBe("light") + }) +}) + +describe("createOnStorage", () => { + test("ignores unrelated storage keys", () => { + const [store, setStore] = makeStore({ colorScheme: "system", mode: "light" }) + const onStorage = createOnStorage(store, setStore, () => Promise.resolve(undefined)) + + onStorage({ key: "some-other-key", newValue: "dark" } as StorageEvent) + + expect(store.colorScheme).toBe("system") + expect(store.mode).toBe("light") + }) + + test("syncs colorScheme and mode from a cross-tab storage event", () => { + const [store, setStore] = makeStore({ colorScheme: "system", mode: "light" }) + const onStorage = createOnStorage(store, setStore, () => Promise.resolve(undefined)) + + onStorage({ key: "opencode-color-scheme", newValue: "dark" as ColorScheme } as StorageEvent) + + expect(store.colorScheme).toBe("dark") + expect(store.mode).toBe("dark") + }) + + test("switching back to oc-2 via storage clears cached custom-theme css", () => { + const [store, setStore] = makeStore({ themeId: "dracula" }) + const onStorage = createOnStorage(store, setStore, () => Promise.resolve(undefined)) + storage.setItem("opencode-theme-css-light", "cached") + + onStorage({ key: "opencode-theme-id", newValue: "oc-2" } as StorageEvent) + + expect(store.themeId).toBe("oc-2") + expect(storage.getItem("opencode-theme-css-light")).toBeNull() + }) +}) + +describe("createThemeLoader", () => { + test("resolves undefined for an empty id without touching the theme registry", () => { + const [store, setStore] = makeStore() + const load = createThemeLoader(store, setStore) + + return load("").then((theme) => { + expect(theme).toBeUndefined() + }) + }) + + test("returns an already-loaded theme from the store without re-fetching", () => { + const [store, setStore] = makeStore({ themes: { "oc-2": fakeTheme } }) + const load = createThemeLoader(store, setStore) + + return load("oc-2").then((theme) => { + expect(theme).toBe(fakeTheme) + }) + }) +}) + +describe("createPreviewTheme", () => { + test("marks the theme as previewing, then applies it once loaded", async () => { + const [store, setStore] = makeStore({ mode: "light", colorScheme: "light" }) + const load = (id: string) => Promise.resolve(id === "oc-2" ? fakeTheme : undefined) + const applied: unknown[] = [] + const previewTheme = createPreviewTheme(store, setStore, load, (...args) => applied.push(args)) + + previewTheme("oc-2") + expect(store.previewThemeId).toBe("oc-2") + + await Promise.resolve() + await Promise.resolve() + + expect(applied).toEqual([[fakeTheme, "oc-2", "light", "light"]]) + }) + + test("does not apply the theme if the preview was changed before it finished loading", async () => { + const [store, setStore] = makeStore() + const load = (id: string) => Promise.resolve(id === "oc-2" ? fakeTheme : undefined) + const applied: unknown[] = [] + const previewTheme = createPreviewTheme(store, setStore, load, (...args) => applied.push(args)) + + previewTheme("oc-2") + setStore("previewThemeId", null) + + await Promise.resolve() + await Promise.resolve() + + expect(applied).toEqual([]) + }) +}) + +describe("createPreviewColorScheme", () => { + test("previews the scheme against the current theme once loaded", async () => { + const [store, setStore] = makeStore({ themeId: "oc-2" }) + const load = (id: string) => Promise.resolve(id === "oc-2" ? fakeTheme : undefined) + const applied: unknown[] = [] + const previewColorScheme = createPreviewColorScheme(store, setStore, load, (...args) => applied.push(args)) + + previewColorScheme("dark") + expect(store.previewScheme).toBe("dark") + + await Promise.resolve() + await Promise.resolve() + + expect(applied).toEqual([[fakeTheme, "oc-2", "dark", "dark"]]) + }) + + test("does not apply a stale preview if the scheme changed again before loading finished", async () => { + const [store, setStore] = makeStore({ themeId: "oc-2" }) + const load = (id: string) => Promise.resolve(id === "oc-2" ? fakeTheme : undefined) + const applied: unknown[] = [] + const previewColorScheme = createPreviewColorScheme(store, setStore, load, (...args) => applied.push(args)) + + previewColorScheme("dark") + previewColorScheme("light") + + await Promise.resolve() + await Promise.resolve() + + expect(applied).toEqual([[fakeTheme, "oc-2", "light", "light"]]) + }) +}) + +describe("createCommitPreview", () => { + test("commits a pending theme and scheme preview, then clears preview state", () => { + const [store, setStore] = makeStore({ previewThemeId: "dracula", previewScheme: "dark" }) + const setThemeCalls: string[] = [] + const setColorSchemeCalls: ColorScheme[] = [] + const commitPreview = createCommitPreview( + store, + setStore, + (id) => setThemeCalls.push(id), + (scheme) => setColorSchemeCalls.push(scheme), + ) + + commitPreview() + + expect(setThemeCalls).toEqual(["dracula"]) + expect(setColorSchemeCalls).toEqual(["dark"]) + expect(store.previewThemeId).toBeNull() + expect(store.previewScheme).toBeNull() + }) + + test("does nothing when there is no pending preview", () => { + const [store, setStore] = makeStore() + const setThemeCalls: string[] = [] + const setColorSchemeCalls: ColorScheme[] = [] + const commitPreview = createCommitPreview( + store, + setStore, + (id) => setThemeCalls.push(id), + (scheme) => setColorSchemeCalls.push(scheme), + ) + + commitPreview() + + expect(setThemeCalls).toEqual([]) + expect(setColorSchemeCalls).toEqual([]) + }) +}) + +describe("createCancelPreview", () => { + test("clears preview state and re-applies the committed theme", async () => { + const [store, setStore] = makeStore({ + themeId: "oc-2", + mode: "light", + colorScheme: "light", + previewThemeId: "dracula", + previewScheme: "dark", + }) + const load = (id: string) => Promise.resolve(id === "oc-2" ? fakeTheme : undefined) + const applied: unknown[] = [] + const cancelPreview = createCancelPreview(store, setStore, load, (...args) => applied.push(args)) + + cancelPreview() + + expect(store.previewThemeId).toBeNull() + expect(store.previewScheme).toBeNull() + + await Promise.resolve() + await Promise.resolve() + + expect(applied).toEqual([[fakeTheme, "oc-2", "light", "light"]]) + }) +}) diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index e1d6e2bf..81284ea3 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -176,7 +176,7 @@ type ThemeProps = { onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark", scheme: ColorScheme) => void } -type ThemeStore = { +export type ThemeStore = { themes: Record themeId: string colorScheme: ColorScheme @@ -379,6 +379,18 @@ function createCancelPreview(store: ThemeStore, setStore: SetStoreFunction {