diff --git a/bun.lock b/bun.lock index 619fe7d..185de8b 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "weather", "devDependencies": { "@playwright/test": "^1.60.0", - "@screenly/edge-apps": "^1.2.1", + "@screenly/edge-apps": "^1.5.0", "@types/bun": "^1.3.14", "@types/jsdom": "^28.0.3", "bun-types": "^1.3.14", @@ -44,7 +44,7 @@ "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], @@ -122,53 +122,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -416,7 +420,7 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@screenly/edge-apps": ["@screenly/edge-apps@1.2.1", "", { "dependencies": { "@eslint/js": "^10.0.1", "@photostructure/tz-lookup": "^11.5.0", "@sentry/browser": "^10.59.0", "@tailwindcss/vite": "^4.2.1", "country-locale-map": "^1.9.12", "dayjs": "^1.11.19", "eslint": "^10.0.3", "jiti": "^2.6.1", "jsdom": "^28.1.0", "offline-geocode-city": "^1.0.2", "panic-overlay": "^1.0.51", "sharp": "^0.34.5", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.1.2", "yaml": "^2.8.2" }, "peerDependencies": { "@playwright/test": "^1.58.0" }, "optionalPeers": ["@playwright/test"], "bin": { "edge-apps-scripts": "bin/edge-apps-scripts.js" } }, "sha512-9CTA8eaYtVlrACHWU9uhml3Yf7hIgc6mELmXhqDdHKDC2fmrgma3sAOpvr0sBjTJr37QRvpeqUjlZ9BJlwT0nQ=="], + "@screenly/edge-apps": ["@screenly/edge-apps@1.5.0", "", { "dependencies": { "@eslint/js": "^10.0.1", "@photostructure/tz-lookup": "^11.5.0", "@sentry/browser": "^10.59.0", "@tailwindcss/vite": "^4.2.1", "country-locale-map": "^1.9.12", "dayjs": "^1.11.19", "eslint": "^10.0.3", "jiti": "^2.6.1", "jsdom": "^28.1.0", "offline-geocode-city": "^1.0.2", "panic-overlay": "^1.0.51", "sharp": "^0.35.3", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.1.2", "yaml": "^2.8.2" }, "peerDependencies": { "@playwright/test": "^1.58.0" }, "optionalPeers": ["@playwright/test"], "bin": { "edge-apps-scripts": "bin/edge-apps-scripts.js" } }, "sha512-yEO2KA61p6FHTYQUuw1ffyAcDDoxhiCrj8iQ2evAw9OCid2yYQ5Z2zJbAWebGfhxSo2NzfUEcC9O7rZd+VS7OA=="], "@sentry/browser": ["@sentry/browser@10.60.0", "", { "dependencies": { "@sentry/browser-utils": "10.60.0", "@sentry/core": "10.60.0", "@sentry/feedback": "10.60.0", "@sentry/replay": "10.60.0", "@sentry/replay-canvas": "10.60.0" } }, "sha512-20vzPKGrmupJPCaWd+soCOLkZRwKZxt0AVF4XGPNoGp7D7wVPRlHf+FWwVMx+rRRkahKupFefM2D9YoiUiBeag=="], @@ -878,7 +882,7 @@ "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" } }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -978,6 +982,8 @@ "@parcel/source-map/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@screenly/edge-apps/jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="], "@screenly/edge-apps/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/index.html b/index.html index 0b706be..e72aedb 100644 --- a/index.html +++ b/index.html @@ -11,64 +11,68 @@ reference-width="1920" reference-height="1080" orientation="auto" + padding="0" > -
- +
+
+

+
+ --:-- + +
+
-
-
-
--
-
--°
-
- -
- --° - --° -
-
+
+
+

--

+

--°

+

+ + + --° + --° + +

-
-
- - Forecast by Hour -
-
-
-
-
- - + +
+

Next hours

+
    +
    + +
    +

    Weather unavailable

    +

    +
    +
    diff --git a/package.json b/package.json index c48ad6d..5006581 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": {}, "prettier": "./.prettierrc.json", "devDependencies": { - "@screenly/edge-apps": "^1.2.1", + "@screenly/edge-apps": "^1.5.0", "@playwright/test": "^1.60.0", "@types/bun": "^1.3.14", "@types/jsdom": "^28.0.3", diff --git a/screenshots/1080x1920.webp b/screenshots/1080x1920.webp new file mode 100644 index 0000000..c4ee36c Binary files /dev/null and b/screenshots/1080x1920.webp differ diff --git a/screenshots/1280x720.webp b/screenshots/1280x720.webp new file mode 100644 index 0000000..f7e2bbd Binary files /dev/null and b/screenshots/1280x720.webp differ diff --git a/screenshots/1920x1080.webp b/screenshots/1920x1080.webp new file mode 100644 index 0000000..f6b154b Binary files /dev/null and b/screenshots/1920x1080.webp differ diff --git a/screenshots/2160x3840.webp b/screenshots/2160x3840.webp new file mode 100644 index 0000000..9aaebf7 Binary files /dev/null and b/screenshots/2160x3840.webp differ diff --git a/screenshots/2160x4096.webp b/screenshots/2160x4096.webp new file mode 100644 index 0000000..55c6aed Binary files /dev/null and b/screenshots/2160x4096.webp differ diff --git a/screenshots/3840x2160.webp b/screenshots/3840x2160.webp new file mode 100644 index 0000000..64117c5 Binary files /dev/null and b/screenshots/3840x2160.webp differ diff --git a/screenshots/4096x2160.webp b/screenshots/4096x2160.webp new file mode 100644 index 0000000..09031bf Binary files /dev/null and b/screenshots/4096x2160.webp differ diff --git a/screenshots/480x800.webp b/screenshots/480x800.webp new file mode 100644 index 0000000..755715f Binary files /dev/null and b/screenshots/480x800.webp differ diff --git a/screenshots/720x1280.webp b/screenshots/720x1280.webp new file mode 100644 index 0000000..08cc80e Binary files /dev/null and b/screenshots/720x1280.webp differ diff --git a/screenshots/800x480.webp b/screenshots/800x480.webp new file mode 100644 index 0000000..a519915 Binary files /dev/null and b/screenshots/800x480.webp differ diff --git a/screenshots/no-api-key-2160x3840.webp b/screenshots/no-api-key-2160x3840.webp new file mode 100644 index 0000000..d50ad37 Binary files /dev/null and b/screenshots/no-api-key-2160x3840.webp differ diff --git a/screenshots/no-api-key-3840x2160.webp b/screenshots/no-api-key-3840x2160.webp new file mode 100644 index 0000000..b201947 Binary files /dev/null and b/screenshots/no-api-key-3840x2160.webp differ diff --git a/src/background.test.ts b/src/background.test.ts new file mode 100644 index 0000000..7516dd0 --- /dev/null +++ b/src/background.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { getBackgroundForWeatherId } from './background' + +describe('getBackgroundForWeatherId', () => { + test('treats thunderstorm, drizzle and rain as rainy', () => { + expect(getBackgroundForWeatherId(200)).toBe('rainy') + expect(getBackgroundForWeatherId(300)).toBe('rainy') + expect(getBackgroundForWeatherId(500)).toBe('rainy') + expect(getBackgroundForWeatherId(599)).toBe('rainy') + }) + + test('treats the 6xx range as snow', () => { + expect(getBackgroundForWeatherId(600)).toBe('snow') + expect(getBackgroundForWeatherId(699)).toBe('snow') + }) + + test('treats a clear sky as sunny', () => { + expect(getBackgroundForWeatherId(800)).toBe('sunny') + }) + + test('falls back to cloudy for atmosphere, clouds and anything unknown', () => { + expect(getBackgroundForWeatherId(701)).toBe('cloudy') + expect(getBackgroundForWeatherId(804)).toBe('cloudy') + expect(getBackgroundForWeatherId(0)).toBe('cloudy') + }) +}) diff --git a/src/background.ts b/src/background.ts index ae9aa8c..dd247fc 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,40 +1,24 @@ -import bgSunny from '../static/images/bg-sunny.webp' -import bgCloudy from '../static/images/bg-cloudy.webp' -import bgRainy from '../static/images/bg-rainy.webp' -import bgSnow from '../static/images/bg-snow.webp' - // OpenWeatherMap weather condition codes: // https://openweathermap.org/weather-conditions // -// 2xx: Thunderstorm -// 3xx: Drizzle -// 5xx: Rain -// 6xx: Snow -// 7xx: Atmosphere (mist, fog, etc.) -// 800: Clear -// 80x: Clouds -function getBackgroundForWeatherId(weatherId: number): string { - if (weatherId >= 200 && weatherId < 600) { - return bgRainy - } - - if (weatherId >= 600 && weatherId < 700) { - return bgSnow - } +// 2xx: Thunderstorm, 3xx: Drizzle, 5xx: Rain, 6xx: Snow, +// 7xx: Atmosphere (mist, fog), 800: Clear, 80x: Clouds +export const BACKGROUNDS = ['sunny', 'cloudy', 'rainy', 'snow'] as const - if (weatherId >= 700 && weatherId < 800) { - return bgCloudy - } +export type Background = (typeof BACKGROUNDS)[number] - if (weatherId === 800) { - return bgSunny - } +export function getBackgroundForWeatherId(weatherId: number): Background { + if (weatherId >= 200 && weatherId < 600) return 'rainy' + if (weatherId >= 600 && weatherId < 700) return 'snow' + if (weatherId === 800) return 'sunny' - // 80x: Clouds - return bgCloudy + return 'cloudy' } export function updateBackground(weatherId: number): void { - const bg = getBackgroundForWeatherId(weatherId) - document.body.style.backgroundImage = `url('${bg}')` + const background = getBackgroundForWeatherId(weatherId) + + BACKGROUNDS.forEach((candidate) => + document.body.classList.toggle(`bg-${candidate}`, candidate === background), + ) } diff --git a/src/css/style.css b/src/css/style.css index c069184..a6c8cfa 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1,341 +1,428 @@ -/* Import Screenly Design System */ @import '@screenly/edge-apps/styles'; -/* App-specific styles */ +body { + --app-accent: var(--theme-color-primary, #972eff); + --app-ink: #fff; + --app-ink-dim: rgb(255 255 255 / 72%); + --app-ink-faint: rgb(255 255 255 / 55%); + --app-rule: rgb(255 255 255 / 22%); + --app-shadow: 0 0.125rem 1rem rgb(0 0 0 / 45%); + + color: var(--app-ink); + background-color: #08080d; + background-repeat: no-repeat; + background-position: center center; + background-size: cover; +} -* { - box-sizing: border-box; +body.bg-sunny { + background-image: url('/static/images/bg-sunny.webp'); } -body { - margin: 0; - padding: 0; - overflow: hidden; - color: white; - font-family: 'Inter', system-ui, sans-serif; - background: url('/static/images/bg-sunny.webp') no-repeat center center; - background-size: cover; - text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); +body.bg-cloudy { + background-image: url('/static/images/bg-cloudy.webp'); } +body.bg-rainy { + background-image: url('/static/images/bg-rainy.webp'); +} + +body.bg-snow { + background-image: url('/static/images/bg-snow.webp'); +} + +/* Keeps the overlay readable over any of the condition photographs */ body::before { content: ''; position: fixed; inset: 0; z-index: var(--z-index-backdrop); - pointer-events: none; - background: rgba(0, 0, 0, 0.05); - backdrop-filter: blur(7px); + background: + linear-gradient( + to bottom, + rgb(4 4 8 / 62%) 0%, + rgb(4 4 8 / 34%) 42%, + rgb(4 4 8 / 82%) 100% + ), + linear-gradient(to right, rgb(4 4 8 / 55%) 0%, transparent 62%); } auto-scaler { - position: relative; z-index: var(--z-index-content); } -#app { +.weather { + display: flex; + flex-direction: column; width: 100%; height: 100%; + padding: 4rem 5rem 3.5rem; } -/* Current Weather Section */ -.current-weather { - position: absolute; - left: 320px; - top: 230px; +.weather-header { display: flex; - flex-direction: column; - gap: 0.5rem; + flex: none; + align-items: center; + justify-content: space-between; + gap: 2rem; } -.city { +.weather-date { + margin: 0; + color: var(--app-ink-dim); font-size: 2rem; - font-weight: 400; - letter-spacing: -0.04em; -} - -.temperature-display { - font-size: 7.5rem; - font-weight: 400; - line-height: 1; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + text-shadow: var(--app-shadow); } -/* Status Pill */ -.status-pill { - display: inline-flex; +.weather-brand { + display: flex; align-items: center; - justify-content: space-between; - padding: 0.25rem 1rem; - height: 2.4375rem; - border-radius: 1rem; - border: 1.68px solid rgba(255, 255, 255, 0.06); - background: linear-gradient( - 172deg, - rgba(255, 255, 255, 0.06) 0%, - rgba(255, 255, 255, 0.01) 100% - ); - box-shadow: - 0 13.438px 40.315px 0 rgba(0, 0, 0, 0.15), - 0 1.68px 0 0 rgba(255, 255, 255, 0.1) inset, - 0 -1.68px 0 0 rgba(255, 255, 255, 0.03) inset; - width: fit-content; - gap: 2rem; + gap: 2.5rem; } -.weather-description { - font-size: 1rem; +.weather-clock { + color: var(--app-ink); + font-size: 2.25rem; font-weight: 500; - letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-shadow: var(--app-shadow); } -.hi-lo { - display: flex; - align-items: center; - gap: 0.625rem; +.weather-logo { + width: auto; + max-height: 3.5rem; +} + +.weather-logo.is-hidden { + display: none; } -.hi, -.lo { +.middle { display: flex; + flex: 1; align-items: center; - gap: 0.25rem; - font-size: 1rem; - font-weight: 500; - letter-spacing: -0.04em; -} - -/* Forecast Card */ -.forecast-card { - position: absolute; - left: 50%; - transform: translateX(-50%); - top: 598px; - width: 1280px; - height: 269px; - border-radius: 1rem; - border: 1.054px solid rgba(255, 255, 255, 0.1); - background: linear-gradient( - 106deg, - rgba(255, 255, 255, 0.09) 3%, - rgba(255, 255, 255, 0.01) 100% - ); - backdrop-filter: blur(31.63px); - box-shadow: - 0 1px 0 0 rgba(255, 255, 255, 0.15) inset, - 0 -1px 0 0 rgba(255, 255, 255, 0.05) inset; -} - -.forecast-header { + gap: 5rem; + min-height: 0; +} + +.now { display: flex; - align-items: center; + flex: 1 1 52%; + flex-direction: column; + justify-content: center; gap: 0.5rem; - padding: 1.3rem 1.5rem 0; + min-height: 0; } -.forecast-header-icon { - width: 1.05rem; - height: 1.05rem; +.trend { + display: flex; + flex: 0 1 38%; + flex-direction: column; + gap: 1rem; + align-self: center; } -.forecast-header span { - font-size: 1.05rem; - font-weight: 400; - text-transform: uppercase; - letter-spacing: 0.037rem; - color: rgba(255, 255, 255, 0.7); +.trend.is-hidden { + display: none; } -.forecast-divider { - margin: 0.75rem 1.5rem 0; - height: 1px; - background: #d9d9d9; - opacity: 0.2; +.trend-label { + margin: 0; + color: var(--app-ink-faint); + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + text-shadow: var(--app-shadow); } -.forecast-items { +.trend-plot { display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.5rem 0; + align-items: stretch; + gap: 1.75rem; } -.forecast-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 1.5rem; - flex: 0 0 auto; +.trend-chart { + flex: 1; + width: 100%; + aspect-ratio: 100 / 40; + color: var(--app-ink); + filter: drop-shadow(0 0.125rem 0.75rem rgb(0 0 0 / 45%)); } -.forecast-item:not(:first-child) { - opacity: 0.8; +.trend-now { + stroke: var(--app-ink-faint); + stroke-width: 0.4; + stroke-dasharray: 1.2 1.4; + stroke-linecap: round; } -.forecast-item-temp { - font-size: 1.625rem; +.trend-tick { + fill: var(--app-ink-faint); + font-size: 3.2px; font-weight: 500; + letter-spacing: 0.04em; } -.forecast-item-icon { - width: 4.5rem; - height: 4.5rem; - object-fit: contain; +.trend-scale { + display: flex; + flex: none; + flex-direction: column; + justify-content: space-between; + margin: 0; + padding: 1.5rem 0 3rem; + font-size: 2rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-shadow: var(--app-shadow); } -.forecast-item-time { - font-size: 1.3rem; - font-weight: 500; - text-align: center; +.trend-scale-low { + color: var(--app-ink-faint); +} + +.trend-dot { + fill: var(--app-ink); + stroke: rgb(4 4 8 / 55%); + stroke-width: 0.6; +} + +.now-place { + margin: 0; + overflow: hidden; white-space: nowrap; + text-overflow: ellipsis; + color: var(--app-ink-dim); + font-size: 3rem; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + text-shadow: var(--app-shadow); } -.forecast-item:not(:first-child) .forecast-item-time { - color: #e9e9e9; +.now-place.is-long { + font-size: 2.25rem; } -/* Error screen */ +.now-place.is-very-long { + font-size: 1.75rem; +} -#error-screen { - position: absolute; - inset: 0; +.now-temp { + margin: 0; + font-size: 18rem; + font-weight: 600; + line-height: 0.9; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; + text-shadow: var(--app-shadow); +} + +.now-condition { display: flex; - align-items: center; - justify-content: center; - padding: 3rem; - z-index: 10; -} - -.error-card { - background: linear-gradient( - 106deg, - rgba(255, 255, 255, 0.09) 3%, - rgba(255, 255, 255, 0.01) 100% - ); - border: 1.054px solid rgba(255, 255, 255, 0.1); - border-radius: 1rem; + align-items: baseline; + gap: 2.5rem; + margin: 0; + font-size: 3rem; + font-weight: 500; + text-shadow: var(--app-shadow); +} + +.now-description::first-letter { + text-transform: uppercase; +} + +.now-range { display: flex; - flex-direction: row; - overflow: hidden; - max-width: 60rem; - width: 100%; - backdrop-filter: blur(31.63px); - box-shadow: - 0 1px 0 0 rgba(255, 255, 255, 0.15) inset, - 0 -1px 0 0 rgba(255, 255, 255, 0.05) inset; + gap: 1.5rem; + color: var(--app-ink-faint); + font-variant-numeric: tabular-nums; +} + +.now-high::before { + content: '↑ '; +} + +.now-low::before { + content: '↓ '; } -.error-card-left { - background: rgba(255, 255, 255, 0.08); - border-right: 0.0625rem solid rgba(255, 255, 255, 0.1); - padding: 2.5rem 2rem; +.forecast { display: flex; + flex: none; flex-direction: column; - justify-content: flex-start; - gap: 1rem; - flex: 0 0 20rem; + gap: 1.75rem; + padding-top: 2rem; + border-top: 0.0625rem solid var(--app-rule); } -.error-card-left h2 { - font-size: 2rem; - font-weight: 700; - line-height: 1.2; - letter-spacing: -0.03em; - color: rgba(255, 255, 255, 0.95); +.forecast.is-hidden { + display: none; +} + +.forecast-label { margin: 0; - text-shadow: none; + color: var(--app-ink-faint); + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + text-shadow: var(--app-shadow); } -.error-card-left p { - font-size: 1.25rem; - color: rgba(255, 255, 255, 0.85); - line-height: 1.6; +.forecast-items { + display: flex; + justify-content: space-between; + gap: 1.5rem; margin: 0; - text-shadow: none; + padding: 0; + list-style: none; } -.error-card-right { - flex: 1; - padding: 2.5rem 2rem; +.forecast-item { display: flex; + flex: 1; flex-direction: column; - justify-content: flex-start; + align-items: center; gap: 0.75rem; + text-shadow: var(--app-shadow); } -.error-detail-label { - font-size: 0.75rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.5); +.forecast-item-time { + color: var(--app-ink-faint); + font-size: 1.75rem; + font-weight: 500; + letter-spacing: 0.06em; text-transform: uppercase; - letter-spacing: 0.08em; - text-shadow: none; + white-space: nowrap; +} + +.forecast-item-icon { + width: 4.5rem; + height: 4.5rem; +} + +.forecast-item-temp { + font-size: 2.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.weather-error { + display: none; + flex: 1; + flex-direction: column; + justify-content: center; + gap: 1.5rem; + text-shadow: var(--app-shadow); +} + +.weather-error h2 { + margin: 0; + font-size: 5rem; + font-weight: 600; + letter-spacing: -0.02em; } -.error-detail-message { - font-size: 1.25rem; - color: rgba(255, 255, 255, 0.85); - line-height: 1.6; - word-break: break-word; +.weather-error p { + max-width: 46ch; margin: 0; - text-shadow: none; + color: var(--app-ink-dim); + font-size: 2.25rem; + line-height: 1.35; +} + +.weather.has-error .now, +.weather.has-error .forecast { + display: none; +} + +.weather.has-error .weather-error { + display: flex; } -/* Portrait mode */ @media (orientation: portrait) { - .current-weather { - left: 60px; - top: 200px; + .weather { + padding: 5rem 4rem 4.5rem; } - .city { + .weather-date { + font-size: 2.25rem; + } + + .weather-clock { font-size: 2.5rem; } - .temperature-display { - font-size: 9rem; + .middle { + flex-direction: column; + align-items: stretch; + gap: 3rem; } - .status-pill { - font-size: 1.5rem; + .now { + flex: none; + gap: 1rem; } - .forecast-card { - top: auto; - bottom: 60px; - width: calc(100% - 120px); - height: auto; - padding-bottom: 1.5rem; + .trend { + flex: none; + width: 100%; } - .forecast-item-temp { - font-size: 2rem; + .trend-chart { + aspect-ratio: 100 / 34; } - .forecast-item-icon { - width: 5rem; - height: 5rem; + .trend-now { + stroke: var(--app-ink-faint); + stroke-width: 0.4; + stroke-dasharray: 1.2 1.4; + stroke-linecap: round; } - .forecast-item-time { - font-size: 1.5rem; + .trend-tick { + font-size: 2.9px; + } + + .now-place { + font-size: 3.25rem; + } + + .now-place.is-long { + font-size: 2.4rem; } - .forecast-item-time-period { - display: none; + .now-place.is-very-long { + font-size: 1.85rem; } - #error-screen { - padding: 1.5rem 2rem; + .now-temp { + font-size: 14rem; } - .error-card { + .now-condition { flex-direction: column; - max-width: 100%; + align-items: flex-start; + gap: 1rem; + font-size: 3.25rem; } - .error-card-left { - flex: none; - border-right: none; - border-bottom: 0.0625rem solid rgba(255, 255, 255, 0.1); + .forecast-items { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2.5rem 1rem; + } + + .forecast-item-time { + font-size: 1.9rem; + } + + .forecast-item-temp { + font-size: 3rem; } } diff --git a/src/graph.test.ts b/src/graph.test.ts new file mode 100644 index 0000000..e8f50d0 --- /dev/null +++ b/src/graph.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test' +import { buildGraphShape } from './graph' + +const HOUR = 60 * 60 * 1000 +const START = Date.UTC(2026, 0, 1, 9) +const readings = [ + { temperature: 10, timestamp: START }, + { temperature: 16, timestamp: START + 3 * HOUR }, + { temperature: 21, timestamp: START + 6 * HOUR }, + { temperature: 14, timestamp: START + 9 * HOUR }, +] + +describe('buildGraphShape', () => { + test('needs at least two readings to draw a line', () => { + expect(buildGraphShape([])).toBeNull() + expect(buildGraphShape([{ temperature: 10, timestamp: START }])).toBeNull() + }) + + test('spreads points evenly across the full width', () => { + const shape = buildGraphShape(readings)! + const xs = shape.points.map((p) => p.x) + expect(xs[0]).toBeGreaterThan(0) + expect(xs.at(-1)).toBeLessThan(100) + const gaps = xs.slice(1).map((x, index) => x - xs[index]) + gaps.forEach((gap) => expect(gap).toBeCloseTo(gaps[0], 6)) + }) + + test('puts the warmest reading above the coldest', () => { + const shape = buildGraphShape(readings)! + expect(shape.high.temperature).toBe(21) + expect(shape.low.temperature).toBe(10) + expect(shape.high.y).toBeLessThan(shape.low.y) + }) + + test('closes the area path back along the baseline', () => { + const shape = buildGraphShape(readings)! + expect(shape.area.startsWith(shape.line)).toBe(true) + expect(shape.line.startsWith('M')).toBe(true) + expect(shape.area.endsWith('40 Z')).toBe(true) + }) + + test('draws a flat run down the middle rather than dividing by zero', () => { + const flat = buildGraphShape([ + { temperature: 12, timestamp: START }, + { temperature: 12, timestamp: START + 3 * HOUR }, + ])! + expect(flat.points.every((p) => Number.isFinite(p.y))).toBe(true) + expect(flat.points[0].y).toBe(flat.points[1].y) + }) +}) + +describe('smoothing', () => { + test('draws curves rather than straight segments', () => { + const shape = buildGraphShape(readings)! + expect(shape.line).toContain('C') + expect(shape.line).not.toContain('L') + }) + + test('still passes through every reading', () => { + const shape = buildGraphShape(readings)! + shape.points.forEach((point) => { + expect(shape.line).toContain(`${Math.round(point.x * 100) / 100} `) + }) + }) +}) + +describe('ticks', () => { + test('marks every second reading, so six-hourly on a three-hour grid', () => { + const shape = buildGraphShape(readings)! + expect(shape.ticks).toHaveLength(2) + expect(shape.ticks.map((t) => t.timestamp)).toEqual([ + START, + START + 6 * HOUR, + ]) + }) + + test('never repeats a reading in the tick list', () => { + const shape = buildGraphShape(readings)! + const stamps = shape.ticks.map((t) => t.timestamp) + expect(new Set(stamps).size).toBe(stamps.length) + }) +}) diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..8641612 --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,118 @@ +/** + * Geometry for the temperature curve. Kept free of the DOM so the shape of the + * line can be tested without a browser. + */ +export interface GraphPoint { + x: number + y: number + temperature: number + timestamp: number +} + +export interface GraphShape { + points: GraphPoint[] + line: string + area: string + high: GraphPoint + low: GraphPoint + ticks: GraphPoint[] +} + +export interface GraphInput { + temperature: number + timestamp: number +} + +const VIEWBOX_WIDTH = 100 +const VIEWBOX_HEIGHT = 40 +// Keeps the curve clear of the labels sitting above and below it +const TOP_PADDING = 9 +const BOTTOM_PADDING = 7 +// Keeps the end caps and the outermost dots clear of the viewBox edge +const SIDE_PADDING = 4 + +/** + * Maps readings onto a 100x40 viewBox. A flat run would divide by zero, so it + * is drawn along the middle instead. + */ +export function buildGraphShape(readings: GraphInput[]): GraphShape | null { + if (readings.length < 2) return null + + const temperatures = readings.map((reading) => reading.temperature) + const min = Math.min(...temperatures) + const max = Math.max(...temperatures) + const span = max - min + const usableHeight = VIEWBOX_HEIGHT - TOP_PADDING - BOTTOM_PADDING + + const points: GraphPoint[] = readings.map((reading, index) => ({ + x: + SIDE_PADDING + + (index / (readings.length - 1)) * (VIEWBOX_WIDTH - SIDE_PADDING * 2), + y: + span === 0 + ? TOP_PADDING + usableHeight / 2 + : TOP_PADDING + + usableHeight - + ((reading.temperature - min) / span) * usableHeight, + temperature: reading.temperature, + timestamp: reading.timestamp, + })) + + const line = smoothPath(points) + + const lastX = points[points.length - 1].x + const area = `${line} L${round(lastX)} ${VIEWBOX_HEIGHT} L${SIDE_PADDING} ${VIEWBOX_HEIGHT} Z` + + return { + points, + line, + area, + high: points.reduce((a, b) => (b.temperature > a.temperature ? b : a)), + low: points.reduce((a, b) => (b.temperature < a.temperature ? b : a)), + ticks: pickTicks(points), + } +} + +/** + * Readings arrive on a fixed three-hour grid, so taking every other one gives + * six-hourly marks on whole clock hours, the way a weather app labels a day. + */ +const TICK_STRIDE = 2 + +function pickTicks(points: GraphPoint[]): GraphPoint[] { + return points.filter((_, index) => index % TICK_STRIDE === 0) +} + +/** + * Catmull-Rom through every reading, converted to cubic beziers. Weather moves + * gradually, so a curve reads truer than straight segments between samples. + */ +function smoothPath(points: GraphPoint[]): string { + const segments = [`M${round(points[0].x)} ${round(points[0].y)}`] + + for (let i = 0; i < points.length - 1; i++) { + const previous = points[i - 1] ?? points[i] + const current = points[i] + const next = points[i + 1] + const after = points[i + 2] ?? next + + const cp1x = current.x + (next.x - previous.x) / 6 + const cp1y = current.y + (next.y - previous.y) / 6 + const cp2x = next.x - (after.x - current.x) / 6 + const cp2y = next.y - (after.y - current.y) / 6 + + segments.push( + `C${round(cp1x)} ${round(cp1y)}, ${round(cp2x)} ${round(cp2y)}, ${round(next.x)} ${round(next.y)}`, + ) + } + + return segments.join(' ') +} + +function round(value: number): number { + return Math.round(value * 100) / 100 +} + +export const GRAPH_HEIGHT = VIEWBOX_HEIGHT + +export const GRAPH_VIEWBOX = `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}` diff --git a/src/main.ts b/src/main.ts index e31513f..7ab7b11 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,8 @@ import './css/style.css' import { + formatLocalizedDate, + formatTime, getMetadata, getTimeZone, getLocale, @@ -8,9 +10,12 @@ import { getSetting, getCityInfo, resolveMeasurementUnit, + setupBrandingLogo, setupErrorHandling, + setupTheme, type MeasurementUnit, } from '@screenly/edge-apps' +// Side-effect import: registers as a custom element import '@screenly/edge-apps/components' import { getCurrentWeather, @@ -19,19 +24,22 @@ import { } from './weather' import type { ForecastItem } from './weather' import { updateBackground } from './background' -import sunIcon from '../static/images/sun.svg' +import { getPlaceSizeClass } from './place' +import { buildGraphShape } from './graph' +import { drawTrend } from './trend' + +const TICK_MS = 30 * 1000 type ErrorReporter = (error: unknown) => void function showError(error: unknown): void { const message = error instanceof Error ? error.message : String(error) - const contentEl = document.querySelector('main.content') - const errorScreen = document.getElementById('error-screen') - const errorMessage = document.getElementById('error-message') + const errorMessage = document.querySelector('[data-error-message]') - if (contentEl) contentEl.style.display = 'none' - if (errorScreen) errorScreen.style.display = 'flex' - if (errorMessage) errorMessage.textContent = message + document.getElementById('weather')?.classList.add('has-error') + if (errorMessage) { + errorMessage.textContent = message + } } function createErrorReporter(displayErrors: boolean): ErrorReporter { @@ -51,7 +59,14 @@ let tempHighEl: Element | null let tempLowEl: Element | null let forecastItemsEl: Element | null let forecastCardEl: Element | null -let forecastHeaderIconEl: HTMLImageElement | null +let trendEl: Element | null +let trendChartEl: SVGSVGElement | null +let trendHighEl: Element | null +let trendLowEl: Element | null + +let dateEl: Element | null +let clockEl: Element | null +let logoEl: HTMLImageElement | null // State let timezone: string = 'UTC' @@ -73,16 +88,8 @@ function getCoordinates(): [number, number] { return getMetadata().coordinates } -function showForecastCard() { - if (forecastCardEl) { - ;(forecastCardEl as HTMLElement).style.display = '' - } -} - -function hideForecastCard() { - if (forecastCardEl) { - ;(forecastCardEl as HTMLElement).style.display = 'none' - } +function setForecastVisible(visible: boolean) { + forecastCardEl?.classList.toggle('is-hidden', !visible) } function renderForecastItems(items: ForecastItem[]) { @@ -93,8 +100,7 @@ function renderForecastItems(items: ForecastItem[]) { ) as HTMLTemplateElement if (!template) return - // Clear existing items - forecastItemsEl.innerHTML = '' + forecastItemsEl.replaceChildren() for (const item of items) { const clone = template.content.cloneNode(true) as DocumentFragment @@ -124,6 +130,59 @@ function renderForecastItems(items: ForecastItem[]) { } } +function renderHeader(): void { + const now = new Date() + + if (dateEl) { + dateEl.textContent = formatLocalizedDate(now, locale, { + timeZone: timezone, + weekday: 'long', + }) + } + + if (!clockEl) return + + const time = formatTime(now, locale, timezone) + const period = time.dayPeriod ? ` ${time.dayPeriod}` : '' + clockEl.textContent = `${time.hour}:${time.minute}${period}` +} + +async function setupLogo(): Promise { + if (!logoEl) return + + logoEl.onerror = () => { + console.error('Failed to load branding logo') + logoEl?.classList.add('is-hidden') + } + logoEl.src = await setupBrandingLogo() +} + +function renderTrend(items: ForecastItem[]): void { + const shape = buildGraphShape( + items.map((item) => ({ + temperature: item.temperature, + timestamp: item.timestamp, + })), + ) + + trendEl?.classList.toggle('is-hidden', !shape) + if (!shape || !trendChartEl) return + + drawTrend(trendChartEl, shape, formatHour) + + if (trendHighEl) { + trendHighEl.textContent = `${Math.round(shape.high.temperature)}°` + } + if (trendLowEl) { + trendLowEl.textContent = `${Math.round(shape.low.temperature)}°` + } +} + +function formatHour(timestamp: number): string { + const time = formatTime(new Date(timestamp), locale, timezone) + return time.dayPeriod ? `${time.hour} ${time.dayPeriod}` : `${time.hour}:00` +} + async function updateWeatherDisplay( latitude: number, longitude: number, @@ -133,7 +192,7 @@ async function updateWeatherDisplay( const weather = await getCurrentWeather(latitude, longitude, tz, unit) if (!weather) { - hideForecastCard() + setForecastVisible(false) return } @@ -164,17 +223,20 @@ async function updateWeatherDisplay( weather, ) + setForecastVisible(forecast.length > 0) + renderTrend(forecast) if (forecast.length > 0) { - showForecastCard() renderForecastItems(forecast) - } else { - hideForecastCard() } } document.addEventListener('DOMContentLoaded', async () => { setupErrorHandling() + setupTheme() + dateEl = document.querySelector('[data-date]') + clockEl = document.querySelector('[data-clock]') + logoEl = document.querySelector('[data-logo]') locationEl = document.querySelector('[data-location]') temperatureEl = document.querySelector('[data-temperature]') weatherDescriptionEl = document.querySelector('[data-weather-description]') @@ -182,12 +244,10 @@ document.addEventListener('DOMContentLoaded', async () => { tempLowEl = document.querySelector('[data-temp-low]') forecastItemsEl = document.querySelector('[data-forecast-items]') forecastCardEl = document.querySelector('[data-forecast-card]') - forecastHeaderIconEl = document.querySelector('[data-forecast-header-icon]') - - if (forecastHeaderIconEl) { - forecastHeaderIconEl.src = sunIcon - } - + trendEl = document.querySelector('[data-trend]') + trendChartEl = document.querySelector('[data-trend-chart]') + trendHighEl = document.querySelector('[data-trend-high]') + trendLowEl = document.querySelector('[data-trend-low]') const displayErrors = getSetting('display_errors') === 'true' const reportError = createErrorReporter(displayErrors) @@ -202,9 +262,13 @@ document.addEventListener('DOMContentLoaded', async () => { timezone = await getTimeZone() locale = await getLocale() + renderHeader() + await setupLogo() + const { cityName, countryCode } = await getCityInfo(latitude, longitude) if (locationEl) { locationEl.textContent = cityName + locationEl.className = `now-place ${getPlaceSizeClass(cityName)}`.trim() } // Get measurement unit from settings, or auto-detect based on location @@ -212,6 +276,8 @@ document.addEventListener('DOMContentLoaded', async () => { await updateWeatherDisplay(latitude, longitude, timezone, measurementUnit) + setInterval(renderHeader, TICK_MS) + // Refresh weather every 15 minutes setInterval( () => { diff --git a/src/place.test.ts b/src/place.test.ts new file mode 100644 index 0000000..e4fbb74 --- /dev/null +++ b/src/place.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { getPlaceSizeClass } from './place' + +describe('getPlaceSizeClass', () => { + test('leaves an ordinary city at the full size', () => { + expect(getPlaceSizeClass('Berlin, DE')).toBe('') + expect(getPlaceSizeClass('a'.repeat(18))).toBe('') + }) + + test('steps down for a long name', () => { + expect(getPlaceSizeClass('Mountain View, US')).toBe('') + expect(getPlaceSizeClass('a'.repeat(19))).toBe('is-long') + expect(getPlaceSizeClass('a'.repeat(28))).toBe('is-long') + }) + + test('steps down again for a very long name', () => { + expect(getPlaceSizeClass('a'.repeat(29))).toBe('is-very-long') + expect(getPlaceSizeClass('Llanfairpwllgwyngyllgogerychwyrndrobwll')).toBe( + 'is-very-long', + ) + }) + + test('ignores surrounding whitespace', () => { + expect(getPlaceSizeClass(' Berlin, DE ')).toBe('') + }) +}) diff --git a/src/place.ts b/src/place.ts new file mode 100644 index 0000000..d22b59a --- /dev/null +++ b/src/place.ts @@ -0,0 +1,15 @@ +/** + * City names vary wildly in length, so the place line steps down a size rather + * than wrapping or truncating. Thresholds are character counts, which track + * rendered width closely enough on the fixed reference canvas. + */ +const LONG_PLACE_CHARS = 18 +const VERY_LONG_PLACE_CHARS = 28 + +export function getPlaceSizeClass(place: string): string { + const length = place.trim().length + if (length > VERY_LONG_PLACE_CHARS) return 'is-very-long' + if (length > LONG_PLACE_CHARS) return 'is-long' + + return '' +} diff --git a/src/trend.ts b/src/trend.ts new file mode 100644 index 0000000..8e320b3 --- /dev/null +++ b/src/trend.ts @@ -0,0 +1,114 @@ +import { GRAPH_HEIGHT, type GraphShape } from './graph' + +const SVG_NS = 'http://www.w3.org/2000/svg' + +function svgEl( + name: K, + attributes: Record, +): SVGElementTagNameMap[K] { + const element = document.createElementNS(SVG_NS, name) + Object.entries(attributes).forEach(([key, value]) => + element.setAttribute(key, value), + ) + return element +} + +function buildFill(): SVGDefsElement { + const gradient = svgEl('linearGradient', { + id: 'trend-fill', + x1: '0', + y1: '0', + x2: '0', + y2: '1', + }) + gradient.append( + svgEl('stop', { + offset: '0', + 'stop-color': 'currentColor', + 'stop-opacity': '0.2', + }), + svgEl('stop', { + offset: '1', + 'stop-color': 'currentColor', + 'stop-opacity': '0', + }), + ) + + const defs = svgEl('defs', {}) + defs.append(gradient) + return defs +} + +function buildTicks( + shape: GraphShape, + formatHour: (timestamp: number) => string, +): SVGTextElement[] { + return shape.ticks.map((tick, index) => { + const anchor = + index === 0 + ? 'start' + : index === shape.ticks.length - 1 + ? 'end' + : 'middle' + const label = svgEl('text', { + x: String(tick.x), + y: String(GRAPH_HEIGHT - 0.5), + class: 'trend-tick', + 'text-anchor': anchor, + }) + label.textContent = formatHour(tick.timestamp) + return label + }) +} + +/** + * Paints the temperature curve: the filled area, the line itself, a dotted rule + * at the present moment, dots on the day's extremes and the clock marks. + */ +export function drawTrend( + chart: SVGSVGElement, + shape: GraphShape, + formatHour: (timestamp: number) => string, +): void { + const area = svgEl('path', { d: shape.area, fill: 'url(#trend-fill)' }) + const line = svgEl('path', { + d: shape.line, + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '0.9', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }) + + const now = shape.points[0] + const nowRule = svgEl('line', { + x1: String(now.x), + y1: '2', + x2: String(now.x), + y2: String(GRAPH_HEIGHT - 5), + class: 'trend-now', + }) + + const dots = ( + [ + [shape.high, 'high'], + [shape.low, 'low'], + ] as const + ).map(([point, name]) => + svgEl('circle', { + cx: String(point.x), + cy: String(point.y), + r: '1.2', + class: `trend-dot trend-dot-${name}`, + }), + ) + + chart.replaceChildren( + buildFill(), + area, + line, + nowRule, + ...dots, + ...buildTicks(shape, formatHour), + ) +} diff --git a/src/weather.ts b/src/weather.ts index 85ba2b1..7149272 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -26,6 +26,7 @@ export interface ForecastItem { iconAlt: string timeLabel: string timePeriod?: string + timestamp: number displayTemp: string } @@ -108,6 +109,7 @@ export async function getHourlyForecast( iconAlt: description, timeLabel, timePeriod, + timestamp: item.dt * 1000, displayTemp: `${temperature}°`, } }, @@ -121,6 +123,7 @@ export async function getHourlyForecast( iconAlt: currentWeather.iconAlt, timeLabel: 'NOW', timePeriod: undefined, + timestamp: Date.now(), displayTemp: `${currentWeather.temperature}°`, } return [nowItem, ...forecastItems]