your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #273 from flo-bit/fix/layout-stuff

fix layout stuff

authored by

Florian and committed by
GitHub
213036b7 54253064

+937 -25
+3 -1
.gitignore
··· 22 22 vite.config.js.timestamp-* 23 23 vite.config.ts.timestamp-* 24 24 25 - references 25 + references 26 + 27 + sveltekit-cloudflare-workers
+3 -1
package.json
··· 12 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 13 "lint": "prettier --check . && eslint .", 14 14 "format": "eslint --fix . && prettier --write .", 15 + "test": "vitest run", 15 16 "deploy": "pnpm run build && wrangler deploy", 16 17 "cf-typegen": "wrangler types ./src/worker-configuration.d.ts" 17 18 }, ··· 38 39 "typescript": "^5.9.3", 39 40 "typescript-eslint": "^8.57.0", 40 41 "valibot": "^1.3.1", 41 - "vite": "^8.0.0" 42 + "vite": "^8.0.0", 43 + "vitest": "^4.1.4" 42 44 }, 43 45 "dependencies": { 44 46 "@atcute/atproto": "^3.1.10",
+231
pnpm-lock.yaml
··· 270 270 vite: 271 271 specifier: ^8.0.0 272 272 version: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 273 + vitest: 274 + specifier: ^4.1.4 275 + version: 4.1.4(@types/node@25.0.10)(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1)) 273 276 274 277 packages: 275 278 ··· 1417 1420 '@tybys/wasm-util@0.10.1': 1418 1421 resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} 1419 1422 1423 + '@types/chai@5.2.3': 1424 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 1425 + 1420 1426 '@types/cookie@0.6.0': 1421 1427 resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 1428 + 1429 + '@types/deep-eql@4.0.2': 1430 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} 1422 1431 1423 1432 '@types/esrecurse@4.3.1': 1424 1433 resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} ··· 1533 1542 '@use-gesture/vanilla@10.3.1': 1534 1543 resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==} 1535 1544 1545 + '@vitest/expect@4.1.4': 1546 + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} 1547 + 1548 + '@vitest/mocker@4.1.4': 1549 + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} 1550 + peerDependencies: 1551 + msw: ^2.4.9 1552 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 1553 + peerDependenciesMeta: 1554 + msw: 1555 + optional: true 1556 + vite: 1557 + optional: true 1558 + 1559 + '@vitest/pretty-format@4.1.4': 1560 + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} 1561 + 1562 + '@vitest/runner@4.1.4': 1563 + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} 1564 + 1565 + '@vitest/snapshot@4.1.4': 1566 + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} 1567 + 1568 + '@vitest/spy@4.1.4': 1569 + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} 1570 + 1571 + '@vitest/utils@4.1.4': 1572 + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} 1573 + 1536 1574 '@webgpu/types@0.1.69': 1537 1575 resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} 1538 1576 ··· 1555 1593 aria-query@5.3.1: 1556 1594 resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} 1557 1595 engines: {node: '>= 0.4'} 1596 + 1597 + assertion-error@2.0.1: 1598 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 1599 + engines: {node: '>=12'} 1558 1600 1559 1601 axobject-query@4.1.0: 1560 1602 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} ··· 1600 1642 canvas-confetti@1.9.4: 1601 1643 resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} 1602 1644 1645 + chai@6.2.2: 1646 + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} 1647 + engines: {node: '>=18'} 1648 + 1603 1649 cheerio-select@2.1.0: 1604 1650 resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} 1605 1651 ··· 1627 1673 1628 1674 confbox@0.2.4: 1629 1675 resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} 1676 + 1677 + convert-source-map@2.0.0: 1678 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 1630 1679 1631 1680 cookie@0.6.0: 1632 1681 resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} ··· 1757 1806 error-stack-parser-es@1.0.5: 1758 1807 resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} 1759 1808 1809 + es-module-lexer@2.0.0: 1810 + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} 1811 + 1760 1812 esbuild@0.27.3: 1761 1813 resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} 1762 1814 engines: {node: '>=18'} ··· 1841 1893 resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} 1842 1894 engines: {node: '>=4.0'} 1843 1895 1896 + estree-walker@3.0.3: 1897 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 1898 + 1844 1899 esutils@2.0.3: 1845 1900 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} 1846 1901 engines: {node: '>=0.10.0'} 1847 1902 1903 + expect-type@1.3.0: 1904 + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} 1905 + engines: {node: '>=12.0.0'} 1906 + 1848 1907 exsolve@1.0.8: 1849 1908 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} 1850 1909 ··· 2668 2727 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 2669 2728 engines: {node: '>=8'} 2670 2729 2730 + siginfo@2.0.0: 2731 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 2732 + 2671 2733 simple-icons@16.11.0: 2672 2734 resolution: {integrity: sha512-6vqbcdaT6PsgUXud9rrP9w+nrmRzzStMEvyDavMeGwDgZSYM4uJ3tH7zurgTLHJO0RnMqU3Q09Vgo7WdTXV1eA==} 2673 2735 engines: {node: '>=0.12.18'} ··· 2680 2742 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 2681 2743 engines: {node: '>=0.10.0'} 2682 2744 2745 + stackback@0.0.2: 2746 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} 2747 + 2683 2748 std-env@3.10.0: 2684 2749 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 2750 + 2751 + std-env@4.0.0: 2752 + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} 2685 2753 2686 2754 string.prototype.codepointat@0.2.1: 2687 2755 resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} ··· 2807 2875 tiny-inflate@1.0.3: 2808 2876 resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} 2809 2877 2878 + tinybench@2.9.0: 2879 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 2880 + 2881 + tinyexec@1.1.1: 2882 + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} 2883 + engines: {node: '>=18'} 2884 + 2810 2885 tinyglobby@0.2.15: 2811 2886 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 2812 2887 engines: {node: '>=12.0.0'} 2813 2888 2814 2889 tinyqueue@3.0.0: 2815 2890 resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} 2891 + 2892 + tinyrainbow@3.1.0: 2893 + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} 2894 + engines: {node: '>=14.0.0'} 2816 2895 2817 2896 totalist@3.0.1: 2818 2897 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} ··· 2962 3041 vite: 2963 3042 optional: true 2964 3043 3044 + vitest@4.1.4: 3045 + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} 3046 + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} 3047 + hasBin: true 3048 + peerDependencies: 3049 + '@edge-runtime/vm': '*' 3050 + '@opentelemetry/api': ^1.9.0 3051 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 3052 + '@vitest/browser-playwright': 4.1.4 3053 + '@vitest/browser-preview': 4.1.4 3054 + '@vitest/browser-webdriverio': 4.1.4 3055 + '@vitest/coverage-istanbul': 4.1.4 3056 + '@vitest/coverage-v8': 4.1.4 3057 + '@vitest/ui': 4.1.4 3058 + happy-dom: '*' 3059 + jsdom: '*' 3060 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 3061 + peerDependenciesMeta: 3062 + '@edge-runtime/vm': 3063 + optional: true 3064 + '@opentelemetry/api': 3065 + optional: true 3066 + '@types/node': 3067 + optional: true 3068 + '@vitest/browser-playwright': 3069 + optional: true 3070 + '@vitest/browser-preview': 3071 + optional: true 3072 + '@vitest/browser-webdriverio': 3073 + optional: true 3074 + '@vitest/coverage-istanbul': 3075 + optional: true 3076 + '@vitest/coverage-v8': 3077 + optional: true 3078 + '@vitest/ui': 3079 + optional: true 3080 + happy-dom: 3081 + optional: true 3082 + jsdom: 3083 + optional: true 3084 + 2965 3085 w3c-keyname@2.2.8: 2966 3086 resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} 2967 3087 ··· 2997 3117 which@2.0.2: 2998 3118 resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 2999 3119 engines: {node: '>= 8'} 3120 + hasBin: true 3121 + 3122 + why-is-node-running@2.3.0: 3123 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} 3124 + engines: {node: '>=8'} 3000 3125 hasBin: true 3001 3126 3002 3127 word-wrap@1.2.5: ··· 4161 4286 tslib: 2.8.1 4162 4287 optional: true 4163 4288 4289 + '@types/chai@5.2.3': 4290 + dependencies: 4291 + '@types/deep-eql': 4.0.2 4292 + assertion-error: 2.0.1 4293 + 4164 4294 '@types/cookie@0.6.0': {} 4295 + 4296 + '@types/deep-eql@4.0.2': {} 4165 4297 4166 4298 '@types/esrecurse@4.3.1': {} 4167 4299 ··· 4310 4442 dependencies: 4311 4443 '@use-gesture/core': 10.3.1 4312 4444 4445 + '@vitest/expect@4.1.4': 4446 + dependencies: 4447 + '@standard-schema/spec': 1.1.0 4448 + '@types/chai': 5.2.3 4449 + '@vitest/spy': 4.1.4 4450 + '@vitest/utils': 4.1.4 4451 + chai: 6.2.2 4452 + tinyrainbow: 3.1.0 4453 + 4454 + '@vitest/mocker@4.1.4(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1))': 4455 + dependencies: 4456 + '@vitest/spy': 4.1.4 4457 + estree-walker: 3.0.3 4458 + magic-string: 0.30.21 4459 + optionalDependencies: 4460 + vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 4461 + 4462 + '@vitest/pretty-format@4.1.4': 4463 + dependencies: 4464 + tinyrainbow: 3.1.0 4465 + 4466 + '@vitest/runner@4.1.4': 4467 + dependencies: 4468 + '@vitest/utils': 4.1.4 4469 + pathe: 2.0.3 4470 + 4471 + '@vitest/snapshot@4.1.4': 4472 + dependencies: 4473 + '@vitest/pretty-format': 4.1.4 4474 + '@vitest/utils': 4.1.4 4475 + magic-string: 0.30.21 4476 + pathe: 2.0.3 4477 + 4478 + '@vitest/spy@4.1.4': {} 4479 + 4480 + '@vitest/utils@4.1.4': 4481 + dependencies: 4482 + '@vitest/pretty-format': 4.1.4 4483 + convert-source-map: 2.0.0 4484 + tinyrainbow: 3.1.0 4485 + 4313 4486 '@webgpu/types@0.1.69': {} 4314 4487 4315 4488 acorn-jsx@5.3.2(acorn@8.16.0): ··· 4329 4502 4330 4503 aria-query@5.3.1: {} 4331 4504 4505 + assertion-error@2.0.1: {} 4506 + 4332 4507 axobject-query@4.1.0: {} 4333 4508 4334 4509 balanced-match@4.0.4: {} ··· 4367 4542 three: 0.183.2 4368 4543 4369 4544 canvas-confetti@1.9.4: {} 4545 + 4546 + chai@6.2.2: {} 4370 4547 4371 4548 cheerio-select@2.1.0: 4372 4549 dependencies: ··· 4413 4590 confbox@0.1.8: {} 4414 4591 4415 4592 confbox@0.2.4: {} 4593 + 4594 + convert-source-map@2.0.0: {} 4416 4595 4417 4596 cookie@0.6.0: {} 4418 4597 ··· 4521 4700 entities@7.0.1: {} 4522 4701 4523 4702 error-stack-parser-es@1.0.5: {} 4703 + 4704 + es-module-lexer@2.0.0: {} 4524 4705 4525 4706 esbuild@0.27.3: 4526 4707 optionalDependencies: ··· 4661 4842 4662 4843 estraverse@5.3.0: {} 4663 4844 4845 + estree-walker@3.0.3: 4846 + dependencies: 4847 + '@types/estree': 1.0.8 4848 + 4664 4849 esutils@2.0.3: {} 4850 + 4851 + expect-type@1.3.0: {} 4665 4852 4666 4853 exsolve@1.0.8: {} 4667 4854 ··· 5446 5633 5447 5634 shebang-regex@3.0.0: {} 5448 5635 5636 + siginfo@2.0.0: {} 5637 + 5449 5638 simple-icons@16.11.0: {} 5450 5639 5451 5640 sirv@3.0.2: ··· 5456 5645 5457 5646 source-map-js@1.2.1: {} 5458 5647 5648 + stackback@0.0.2: {} 5649 + 5459 5650 std-env@3.10.0: {} 5651 + 5652 + std-env@4.0.0: {} 5460 5653 5461 5654 string.prototype.codepointat@0.2.1: {} 5462 5655 ··· 5591 5784 5592 5785 tiny-inflate@1.0.3: {} 5593 5786 5787 + tinybench@2.9.0: {} 5788 + 5789 + tinyexec@1.1.1: {} 5790 + 5594 5791 tinyglobby@0.2.15: 5595 5792 dependencies: 5596 5793 fdir: 6.5.0(picomatch@4.0.3) ··· 5598 5795 5599 5796 tinyqueue@3.0.0: {} 5600 5797 5798 + tinyrainbow@3.1.0: {} 5799 + 5601 5800 totalist@3.0.1: {} 5602 5801 5603 5802 troika-three-text@0.52.4(three@0.183.2): ··· 5706 5905 optionalDependencies: 5707 5906 vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 5708 5907 5908 + vitest@4.1.4(@types/node@25.0.10)(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1)): 5909 + dependencies: 5910 + '@vitest/expect': 4.1.4 5911 + '@vitest/mocker': 4.1.4(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1)) 5912 + '@vitest/pretty-format': 4.1.4 5913 + '@vitest/runner': 4.1.4 5914 + '@vitest/snapshot': 4.1.4 5915 + '@vitest/spy': 4.1.4 5916 + '@vitest/utils': 4.1.4 5917 + es-module-lexer: 2.0.0 5918 + expect-type: 1.3.0 5919 + magic-string: 0.30.21 5920 + obug: 2.1.1 5921 + pathe: 2.0.3 5922 + picomatch: 4.0.3 5923 + std-env: 4.0.0 5924 + tinybench: 2.9.0 5925 + tinyexec: 1.1.1 5926 + tinyglobby: 0.2.15 5927 + tinyrainbow: 3.1.0 5928 + vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 5929 + why-is-node-running: 2.3.0 5930 + optionalDependencies: 5931 + '@types/node': 25.0.10 5932 + transitivePeerDependencies: 5933 + - msw 5934 + 5709 5935 w3c-keyname@2.2.8: {} 5710 5936 5711 5937 web-haptics@0.0.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.53.11): ··· 5725 5951 which@2.0.2: 5726 5952 dependencies: 5727 5953 isexe: 2.0.0 5954 + 5955 + why-is-node-running@2.3.0: 5956 + dependencies: 5957 + siginfo: 2.0.0 5958 + stackback: 0.0.2 5728 5959 5729 5960 word-wrap@1.2.5: {} 5730 5961
+133
scripts/atproto.ts
··· 1 + /** 2 + * ATProto helper script for debugging. 3 + * 4 + * Usage: 5 + * npx tsx scripts/atproto.ts listRecords <handle> <collection> 6 + * npx tsx scripts/atproto.ts getRecord <handle> <collection> <rkey> 7 + * 8 + * Examples: 9 + * npx tsx scripts/atproto.ts listRecords japan.selfhosted.social app.blento.card 10 + * npx tsx scripts/atproto.ts getRecord japan.selfhosted.social app.blento.card self 11 + */ 12 + 13 + async function resolveHandle(handle: string): Promise<string> { 14 + // Try DNS-based resolution via DoH 15 + const dnsRes = await fetch( 16 + `https://mozilla.cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, 17 + { headers: { Accept: 'application/dns-json' } } 18 + ); 19 + const dns = await dnsRes.json(); 20 + for (const answer of dns.Answer ?? []) { 21 + const match = answer.data?.replace(/"/g, '').match(/^did=(.+)$/); 22 + if (match) return match[1]; 23 + } 24 + 25 + // Fallback: HTTP well-known 26 + const httpRes = await fetch(`https://${handle}/.well-known/atproto-did`); 27 + if (httpRes.ok) { 28 + const did = (await httpRes.text()).trim(); 29 + if (did.startsWith('did:')) return did; 30 + } 31 + 32 + throw new Error(`Could not resolve handle: ${handle}`); 33 + } 34 + 35 + async function resolvePDS(did: string): Promise<string> { 36 + let docUrl: string; 37 + if (did.startsWith('did:plc:')) { 38 + docUrl = `https://plc.directory/${did}`; 39 + } else if (did.startsWith('did:web:')) { 40 + const host = did.replace('did:web:', ''); 41 + docUrl = `https://${host}/.well-known/did.json`; 42 + } else { 43 + throw new Error(`Unsupported DID method: ${did}`); 44 + } 45 + 46 + const res = await fetch(docUrl); 47 + if (!res.ok) throw new Error(`Failed to fetch DID document: ${res.status}`); 48 + const doc = await res.json(); 49 + 50 + for (const service of doc.service ?? []) { 51 + if (service.id === '#atproto_pds') { 52 + return service.serviceEndpoint; 53 + } 54 + } 55 + throw new Error('No #atproto_pds service found in DID document'); 56 + } 57 + 58 + async function listRecords(handle: string, collection: string) { 59 + const did = await resolveHandle(handle); 60 + const pds = await resolvePDS(did); 61 + console.error(`Resolved: ${handle} → ${did} @ ${pds}`); 62 + 63 + const allRecords: any[] = []; 64 + let cursor: string | undefined; 65 + 66 + do { 67 + const params = new URLSearchParams({ 68 + repo: did, 69 + collection, 70 + limit: '100' 71 + }); 72 + if (cursor) params.set('cursor', cursor); 73 + 74 + const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`); 75 + if (!res.ok) { 76 + const body = await res.text(); 77 + throw new Error(`listRecords failed: ${res.status} ${body}`); 78 + } 79 + const data = await res.json(); 80 + allRecords.push(...data.records); 81 + cursor = data.cursor; 82 + } while (cursor); 83 + 84 + console.log(JSON.stringify(allRecords, null, 2)); 85 + } 86 + 87 + async function getRecord(handle: string, collection: string, rkey: string) { 88 + const did = await resolveHandle(handle); 89 + const pds = await resolvePDS(did); 90 + console.error(`Resolved: ${handle} → ${did} @ ${pds}`); 91 + 92 + const params = new URLSearchParams({ repo: did, collection, rkey }); 93 + const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`); 94 + if (!res.ok) { 95 + const body = await res.text(); 96 + throw new Error(`getRecord failed: ${res.status} ${body}`); 97 + } 98 + const data = await res.json(); 99 + console.log(JSON.stringify(data, null, 2)); 100 + } 101 + 102 + // CLI 103 + const [, , command, ...args] = process.argv; 104 + 105 + switch (command) { 106 + case 'listRecords': 107 + if (args.length < 2) { 108 + console.error('Usage: listRecords <handle> <collection>'); 109 + process.exit(1); 110 + } 111 + listRecords(args[0], args[1]).catch((e) => { 112 + console.error(e.message); 113 + process.exit(1); 114 + }); 115 + break; 116 + 117 + case 'getRecord': 118 + if (args.length < 3) { 119 + console.error('Usage: getRecord <handle> <collection> <rkey>'); 120 + process.exit(1); 121 + } 122 + getRecord(args[0], args[1], args[2]).catch((e) => { 123 + console.error(e.message); 124 + process.exit(1); 125 + }); 126 + break; 127 + 128 + default: 129 + console.error('Commands: listRecords, getRecord'); 130 + console.error(' listRecords <handle> <collection>'); 131 + console.error(' getRecord <handle> <collection> <rkey>'); 132 + process.exit(1); 133 + }
+123
scripts/simulate-load.ts
··· 1 + /** 2 + * Simulate what checkData() does to a user's layout on load. 3 + * Uses react-grid-layout directly to avoid Svelte import chain. 4 + * 5 + * Usage: npx tsx scripts/atproto.ts listRecords <handle> app.blento.card 2>/dev/null | npx tsx scripts/simulate-load.ts 6 + */ 7 + import { 8 + correctBounds, 9 + verticalCompactor, 10 + type LayoutItem 11 + } from 'react-grid-layout/core'; 12 + import * as fs from 'fs'; 13 + 14 + const COLUMNS = 8; 15 + 16 + type Item = { 17 + id: string; 18 + x: number; y: number; w: number; h: number; 19 + mobileX: number; mobileY: number; mobileW: number; mobileH: number; 20 + cardType: string; 21 + }; 22 + 23 + function toLayout(items: Item[], mobile: boolean): LayoutItem[] { 24 + return items.map((item) => 25 + mobile 26 + ? { x: item.mobileX, y: item.mobileY, w: item.mobileW, h: item.mobileH, i: item.id } 27 + : { x: item.x, y: item.y, w: item.w, h: item.h, i: item.id } 28 + ); 29 + } 30 + 31 + function applyLayout(items: Item[], layout: LayoutItem[], mobile: boolean) { 32 + const map = new Map(items.map((i) => [i.id, i])); 33 + for (const l of layout) { 34 + const item = map.get(l.i); 35 + if (!item) continue; 36 + if (mobile) { item.mobileX = l.x; item.mobileY = l.y; } 37 + else { item.x = l.x; item.y = l.y; } 38 + } 39 + } 40 + 41 + function fixAllCollisions(items: Item[], mobile: boolean) { 42 + let layout = toLayout(items, mobile); 43 + correctBounds(layout as any, { cols: COLUMNS }); 44 + layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 45 + applyLayout(items, layout, mobile); 46 + } 47 + 48 + function compactItems(items: Item[], mobile: boolean) { 49 + const layout = toLayout(items, mobile); 50 + const compacted = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 51 + applyLayout(items, compacted, mobile); 52 + } 53 + 54 + // --- 55 + 56 + const input = fs.readFileSync('/dev/stdin', 'utf8'); 57 + const records = JSON.parse(input); 58 + 59 + const cards: Item[] = records 60 + .filter((r: any) => r.value.cardType && (!r.value.page || r.value.page === 'blento.self')) 61 + .map((r: any) => ({ 62 + id: r.value.id, 63 + x: r.value.x, y: r.value.y, w: r.value.w, h: r.value.h, 64 + mobileX: r.value.mobileX, mobileY: r.value.mobileY, 65 + mobileW: r.value.mobileW, mobileH: r.value.mobileH, 66 + cardType: r.value.cardType 67 + })); 68 + 69 + // Save original positions 70 + const originals = cards.map((c) => ({ ...c })); 71 + 72 + // Simulate checkData — fixAllCollisions only (no separate compactItems) 73 + fixAllCollisions(cards, false); 74 + fixAllCollisions(cards, true); 75 + 76 + // Compare 77 + let desktopChanges = 0; 78 + let mobileChanges = 0; 79 + 80 + for (const card of cards) { 81 + const orig = originals.find((o) => o.id === card.id)!; 82 + const dChanged = card.x !== orig.x || card.y !== orig.y; 83 + const mChanged = card.mobileX !== orig.mobileX || card.mobileY !== orig.mobileY; 84 + 85 + if (dChanged || mChanged) { 86 + console.log( 87 + `${orig.cardType.padEnd(20)} id=${orig.id}` + 88 + (dChanged ? ` DESKTOP: (${orig.x},${orig.y}) → (${card.x},${card.y})` : '') + 89 + (mChanged ? ` MOBILE: (${orig.mobileX},${orig.mobileY}) → (${card.mobileX},${card.mobileY})` : '') 90 + ); 91 + if (dChanged) desktopChanges++; 92 + if (mChanged) mobileChanges++; 93 + } 94 + } 95 + 96 + if (desktopChanges === 0 && mobileChanges === 0) { 97 + console.log('No layout changes on load — checkData is not the culprit.'); 98 + } else { 99 + console.log(`\n${desktopChanges} desktop changes, ${mobileChanges} mobile changes on load.`); 100 + } 101 + 102 + // Check for ORDER changes in mobile layout 103 + console.log('\n=== Mobile reading order (y, then x) ==='); 104 + const sortByMobile = (items: { id: string; mobileX: number; mobileY: number; cardType: string }[]) => 105 + [...items].sort((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 106 + 107 + const origOrder = sortByMobile(originals); 108 + const newOrder = sortByMobile(cards); 109 + 110 + let orderChanges = 0; 111 + for (let i = 0; i < origOrder.length; i++) { 112 + const same = origOrder[i].id === newOrder[i].id; 113 + if (!same) orderChanges++; 114 + const orig = originals.find(o => o.id === newOrder[i].id)!; 115 + const card = cards.find(c => c.id === newOrder[i].id)!; 116 + console.log( 117 + `${i.toString().padStart(2)}: ${same ? ' ' : '!!'} ` + 118 + `${card.cardType.padEnd(20)} ` + 119 + `was (${orig.mobileX},${orig.mobileY}) → now (${card.mobileX},${card.mobileY})` + 120 + (!same ? ` [was #${origOrder.findIndex(o => o.id === newOrder[i].id)}]` : '') 121 + ); 122 + } 123 + console.log(`\n${orderChanges} order changes in mobile layout.`);
+20
src/lib/layout/algorithms.ts
··· 62 62 return collides(toLayoutItem(a, mobile), toLayoutItem(b, mobile)); 63 63 } 64 64 65 + /** Returns true if any two items overlap in the given layout. */ 66 + export function hasOverlaps(items: Item[], mobile: boolean): boolean { 67 + for (let i = 0; i < items.length; i++) { 68 + for (let j = i + 1; j < items.length; j++) { 69 + if (overlaps(items[i], items[j], mobile)) return true; 70 + } 71 + } 72 + return false; 73 + } 74 + 65 75 export function fixCollisions( 66 76 items: Item[], 67 77 item: Item, ··· 102 112 let layout = toLayout(items, mobile); 103 113 correctBounds(layout as any, { cols: COLUMNS }); 104 114 layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 115 + applyLayout(items, layout, mobile); 116 + } 117 + 118 + /** 119 + * Only fix items that are out of grid bounds, without compacting or resolving overlaps. 120 + * This is safe to call on load — it won't shift already-valid layouts. 121 + */ 122 + export function sanitizeBounds(items: Item[], mobile: boolean) { 123 + const layout = toLayout(items, mobile); 124 + correctBounds(layout as any, { cols: COLUMNS }); 105 125 applyLayout(items, layout, mobile); 106 126 } 107 127
+4 -1
src/lib/layout/index.ts
··· 4 4 fixAllCollisions, 5 5 compactItems, 6 6 setPositionOfNewItem, 7 - findValidPosition 7 + findValidPosition, 8 + sanitizeBounds, 9 + hasOverlaps 8 10 } from './algorithms'; 9 11 10 12 export { shouldMirror, mirrorItemSize, mirrorLayout } from './mirror'; 13 + export type { LayoutMode } from './mirror'; 11 14 12 15 export { getGridPosition, getViewportCenterGridY, pixelToGrid } from './grid'; 13 16 export type { GridPosition, DragState } from './grid';
+184
src/lib/layout/mirror.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import type { Item } from '$lib/types'; 3 + 4 + // Mock CardDefinitionsByType — tests don't need real card definitions 5 + vi.mock('$lib/cards', () => ({ 6 + CardDefinitionsByType: {} 7 + })); 8 + 9 + import { mirrorItemSize, mirrorLayout, shouldMirror } from './mirror'; 10 + 11 + function makeItem(overrides: Partial<Item> & { id: string }): Item { 12 + return { 13 + w: 2, 14 + h: 2, 15 + x: 0, 16 + y: 0, 17 + mobileW: 4, 18 + mobileH: 4, 19 + mobileX: 0, 20 + mobileY: 0, 21 + cardType: 'text', 22 + cardData: {}, 23 + ...overrides 24 + }; 25 + } 26 + 27 + describe('shouldMirror', () => { 28 + it('mirrors when editedOn is 0 (never edited) and no layoutMode', () => { 29 + expect(shouldMirror(0, undefined, false)).toBe(true); 30 + expect(shouldMirror(0, undefined, true)).toBe(true); 31 + }); 32 + 33 + it('mirrors when only one layout edited and no layoutMode', () => { 34 + expect(shouldMirror(1, undefined, false)).toBe(true); 35 + expect(shouldMirror(2, undefined, true)).toBe(true); 36 + }); 37 + 38 + it('does not mirror when both layouts edited and no layoutMode', () => { 39 + expect(shouldMirror(3, undefined, false)).toBe(false); 40 + expect(shouldMirror(3, undefined, true)).toBe(false); 41 + }); 42 + 43 + it('desktop-leads: mirrors only when editing desktop', () => { 44 + expect(shouldMirror(3, 'desktop-leads', false)).toBe(true); 45 + expect(shouldMirror(3, 'desktop-leads', true)).toBe(false); 46 + }); 47 + 48 + it('mobile-leads: mirrors only when editing mobile', () => { 49 + expect(shouldMirror(3, 'mobile-leads', true)).toBe(true); 50 + expect(shouldMirror(3, 'mobile-leads', false)).toBe(false); 51 + }); 52 + 53 + it('independent: never mirrors', () => { 54 + expect(shouldMirror(0, 'independent', false)).toBe(false); 55 + expect(shouldMirror(0, 'independent', true)).toBe(false); 56 + }); 57 + }); 58 + 59 + describe('mirrorItemSize', () => { 60 + it('desktop → mobile: doubles dimensions', () => { 61 + const item = makeItem({ id: 'a', w: 3, h: 2 }); 62 + mirrorItemSize(item, false); 63 + expect(item.mobileW).toBe(6); 64 + expect(item.mobileH).toBe(4); 65 + }); 66 + 67 + it('desktop → mobile: caps width at COLUMNS (8)', () => { 68 + const item = makeItem({ id: 'a', w: 6, h: 2 }); 69 + mirrorItemSize(item, false); 70 + expect(item.mobileW).toBe(8); 71 + expect(item.mobileH).toBe(4); 72 + }); 73 + 74 + it('mobile → desktop: halves dimensions with snap-even', () => { 75 + const item = makeItem({ id: 'a', mobileW: 6, mobileH: 4 }); 76 + mirrorItemSize(item, true); 77 + // snapEven(6/2) = snapEven(3) = max(2, round(1.5)*2) = max(2, 4) = 4 78 + expect(item.w).toBe(4); 79 + expect(item.h).toBe(2); 80 + }); 81 + 82 + it('mobile → desktop: exact halves', () => { 83 + const item = makeItem({ id: 'a', mobileW: 4, mobileH: 4 }); 84 + mirrorItemSize(item, true); 85 + // snapEven(4/2) = snapEven(2) = max(2, round(1)*2) = 2 86 + expect(item.w).toBe(2); 87 + expect(item.h).toBe(2); 88 + }); 89 + 90 + it('mobile → desktop: minimum width is 2', () => { 91 + const item = makeItem({ id: 'a', mobileW: 2, mobileH: 2 }); 92 + mirrorItemSize(item, true); 93 + expect(item.w).toBe(2); // snapEven(1) = max(2, round(0.5)*2) = max(2, 1*2) = 2 94 + expect(item.h).toBe(1); // round(2/2) = 1 95 + }); 96 + }); 97 + 98 + describe('mirrorLayout desktop → mobile', () => { 99 + it('two items that fit side-by-side on mobile stay side-by-side', () => { 100 + // Two 2-wide items next to each other on desktop → 4-wide on mobile, should fit in 8 cols 101 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 2, h: 2 }); 102 + const b = makeItem({ id: 'b', x: 2, y: 0, w: 2, h: 2 }); 103 + const items = [a, b]; 104 + 105 + mirrorLayout(items, false); 106 + 107 + // Both should be on the same row 108 + expect(a.mobileY).toBe(b.mobileY); 109 + // They should not overlap 110 + expect(a.mobileX + a.mobileW).toBeLessThanOrEqual(b.mobileX); 111 + }); 112 + 113 + it('preserves reading order', () => { 114 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 4, h: 2 }); 115 + const b = makeItem({ id: 'b', x: 4, y: 0, w: 4, h: 2 }); 116 + const c = makeItem({ id: 'c', x: 0, y: 2, w: 4, h: 2 }); 117 + const items = [a, b, c]; 118 + 119 + mirrorLayout(items, false); 120 + 121 + // a and b are on the same desktop row but become full-width on mobile (8 cols each) 122 + // So they must stack. a should come before b, and b before c. 123 + expect(a.mobileY).toBeLessThan(b.mobileY); 124 + expect(b.mobileY).toBeLessThan(c.mobileY); 125 + }); 126 + 127 + it('does not leave gaps when items fit next to each other', () => { 128 + // Three 2-wide items on one desktop row → 4-wide on mobile, two fit per row 129 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 2, h: 2 }); 130 + const b = makeItem({ id: 'b', x: 2, y: 0, w: 2, h: 2 }); 131 + const c = makeItem({ id: 'c', x: 4, y: 0, w: 2, h: 2 }); 132 + const items = [a, b, c]; 133 + 134 + mirrorLayout(items, false); 135 + 136 + // a and b should be on the same row 137 + expect(a.mobileY).toBe(b.mobileY); 138 + // c should be on the next row (only 4 cols left isn't enough... wait, 8 - 4 - 4 = 0) 139 + // Actually a(4) + b(4) = 8, c must go to next row 140 + expect(c.mobileY).toBe(a.mobileY + a.mobileH); 141 + // c should start at x=0 142 + expect(c.mobileX).toBe(0); 143 + }); 144 + 145 + it('full-width items stack vertically', () => { 146 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 8, h: 2 }); 147 + const b = makeItem({ id: 'b', x: 0, y: 2, w: 8, h: 2 }); 148 + const items = [a, b]; 149 + 150 + mirrorLayout(items, false); 151 + 152 + expect(a.mobileW).toBe(8); 153 + expect(b.mobileW).toBe(8); 154 + expect(a.mobileY).toBe(0); 155 + expect(b.mobileY).toBe(a.mobileH); 156 + }); 157 + }); 158 + 159 + describe('mirrorLayout mobile → desktop', () => { 160 + it('two mobile items that fit side-by-side on desktop stay side-by-side', () => { 161 + const a = makeItem({ id: 'a', mobileX: 0, mobileY: 0, mobileW: 4, mobileH: 4 }); 162 + const b = makeItem({ id: 'b', mobileX: 4, mobileY: 0, mobileW: 4, mobileH: 4 }); 163 + const items = [a, b]; 164 + 165 + mirrorLayout(items, true); 166 + 167 + // Both should be on the same desktop row 168 + expect(a.y).toBe(b.y); 169 + expect(a.x + a.w).toBeLessThanOrEqual(b.x); 170 + }); 171 + 172 + it('preserves reading order from mobile layout', () => { 173 + const a = makeItem({ id: 'a', mobileX: 0, mobileY: 0, mobileW: 8, mobileH: 4 }); 174 + const b = makeItem({ id: 'b', mobileX: 0, mobileY: 4, mobileW: 8, mobileH: 4 }); 175 + const c = makeItem({ id: 'c', mobileX: 0, mobileY: 8, mobileW: 8, mobileH: 4 }); 176 + const items = [a, b, c]; 177 + 178 + mirrorLayout(items, true); 179 + 180 + // a before b before c in desktop Y 181 + expect(a.y).toBeLessThanOrEqual(b.y); 182 + expect(b.y).toBeLessThanOrEqual(c.y); 183 + }); 184 + });
+31 -14
src/lib/layout/mirror.ts
··· 1 1 import { COLUMNS } from '$lib'; 2 2 import { CardDefinitionsByType } from '$lib/cards'; 3 3 import { clamp } from '$lib/helper'; 4 - import { fixAllCollisions, findValidPosition } from './algorithms'; 4 + import { findValidPosition } from './algorithms'; 5 5 import type { Item } from '$lib/types'; 6 6 7 + export type LayoutMode = 'desktop-leads' | 'mobile-leads' | 'independent'; 8 + 7 9 /** 8 - * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). 9 - * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both 10 + * Determine whether mirroring should happen and in which direction. 11 + * Returns 'desktop' or 'mobile' for the source layout, or false if no mirroring. 12 + * 13 + * @param editedOn - bitflag: 0=never, 1=desktop, 2=mobile, 3=both 14 + * @param layoutMode - explicit override (takes precedence when set) 15 + * @param editingMobile - true if the current edit is on mobile 10 16 */ 11 - export function shouldMirror(editedOn: number | undefined): boolean { 17 + export function shouldMirror( 18 + editedOn: number | undefined, 19 + layoutMode: LayoutMode | undefined, 20 + editingMobile: boolean 21 + ): boolean { 22 + if (layoutMode) { 23 + if (layoutMode === 'independent') return false; 24 + if (layoutMode === 'desktop-leads') return !editingMobile; 25 + if (layoutMode === 'mobile-leads') return editingMobile; 26 + return false; 27 + } 28 + // Legacy behavior: mirror as long as both layouts haven't been edited 12 29 return (editedOn ?? 0) !== 3; 13 30 } 14 31 ··· 40 57 41 58 /** 42 59 * Mirror the full layout from one view to the other. 43 - * Copies sizes proportionally and maps positions, then resolves collisions. 60 + * Copies sizes proportionally and reflows items in reading order, then resolves collisions. 44 61 * Mutates items in-place. 45 62 */ 46 63 export function mirrorLayout(items: Item[], fromMobile: boolean): void { ··· 50 67 } 51 68 52 69 if (fromMobile) { 53 - // Mobile → Desktop: reflow items to use the full grid width. 54 - // Sort by mobile position so items are placed in reading order. 70 + // Mobile → Desktop: reflow items in mobile reading order 55 71 const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 56 - 57 - // Place each item into the first available spot on the desktop grid 58 72 const placed: Item[] = []; 59 73 for (const item of sorted) { 60 74 item.x = 0; ··· 63 77 placed.push(item); 64 78 } 65 79 } else { 66 - // Desktop → Mobile: proportional positions 67 - for (const item of items) { 68 - item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 69 - item.mobileY = Math.max(0, Math.round(item.y * 2)); 80 + // Desktop → Mobile: reflow items in desktop reading order 81 + const sorted = items.toSorted((a, b) => a.y - b.y || a.x - b.x); 82 + const placed: Item[] = []; 83 + for (const item of sorted) { 84 + item.mobileX = 0; 85 + item.mobileY = 0; 86 + findValidPosition(item, placed, true); 87 + placed.push(item); 70 88 } 71 - fixAllCollisions(items, true); 72 89 } 73 90 }
+6
src/lib/types.ts
··· 71 71 72 72 // layout mirroring: 0/undefined=never edited, 1=desktop only, 2=mobile only, 3=both 73 73 editedOn?: number; 74 + 75 + // explicit layout sync mode (overrides editedOn when set) 76 + layoutMode?: 'desktop-leads' | 'mobile-leads' | 'independent'; 74 77 }; 75 78 }; 76 79 profile: AppBskyActorDefs.ProfileViewDetailed; ··· 80 83 additionalData: Record<string, unknown>; 81 84 updatedAt: number; 82 85 version?: number; 86 + 87 + /** Set by checkData when overlapping cards are detected before fixing. */ 88 + hasLayoutIssue?: boolean; 83 89 };
+11 -1
src/lib/website/Account.svelte
··· 5 5 import type { WebsiteData } from '$lib/types'; 6 6 import { Avatar, Button, Popover } from '@foxui/core'; 7 7 import CustomDomainModal, { customDomainModalState } from '$lib/website/CustomDomainModal.svelte'; 8 + import SettingsModal, { settingsModalState } from '$lib/website/SettingsModal.svelte'; 8 9 9 10 let { 10 - data 11 + data = $bindable() 11 12 }: { 12 13 data: WebsiteData; 13 14 } = $props(); ··· 38 39 variant="ghost" 39 40 onclick={() => { 40 41 settingsPopoverOpen = false; 42 + settingsModalState.show(); 43 + }}>Settings</Button 44 + > 45 + 46 + <Button 47 + variant="ghost" 48 + onclick={() => { 49 + settingsPopoverOpen = false; 41 50 customDomainModalState.show(); 42 51 }}>Custom Domain</Button 43 52 > ··· 48 57 </div> 49 58 50 59 <CustomDomainModal publicationUrl={data.publication?.url} /> 60 + <SettingsModal bind:data /> 51 61 {/if}
+39 -2
src/lib/website/EditableWebsite.svelte
··· 59 59 // svelte-ignore state_referenced_locally 60 60 let items: Item[] = $state(data.cards); 61 61 62 + // Flag set by checkData when overlapping cards were detected before fixing 63 + // Flag set by checkData when overlapping cards were auto-fixed on load 64 + let showLayoutFixModal = $state(data.hasLayoutIssue ?? false); 65 + 66 + function acknowledgeLayoutFix() { 67 + hasUnsavedChanges = true; 68 + showLayoutFixModal = false; 69 + } 70 + 62 71 // svelte-ignore state_referenced_locally 63 72 let publication = $state(JSON.stringify(data.publication)); 64 73 ··· 109 118 // svelte-ignore state_referenced_locally 110 119 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 111 120 121 + let layoutMode = $derived(data.publication.preferences?.layoutMode); 122 + 112 123 function onLayoutChanged() { 113 124 hasUnsavedChanges = true; 114 125 // Set the bit for the current layout: desktop=1, mobile=2 115 126 editedOn = editedOn | (isMobile ? 2 : 1); 116 - if (shouldMirror(editedOn)) { 127 + if (shouldMirror(editedOn, layoutMode, isMobile)) { 117 128 mirrorLayout(items, isMobile); 118 129 } 119 130 } ··· 499 510 baseColor={data.publication?.preferences?.baseColor} 500 511 /> 501 512 502 - <Account {data} /> 513 + <Account bind:data /> 503 514 504 515 <Context {data} isEditing={true}> 505 516 <ImageViewerProvider /> ··· 555 566 handle={data.handle} 556 567 page={data.page} 557 568 /> 569 + 570 + <Modal open={showLayoutFixModal} closeButton={false}> 571 + <div class="flex flex-col items-center gap-4 text-center"> 572 + <svg 573 + xmlns="http://www.w3.org/2000/svg" 574 + fill="none" 575 + viewBox="0 0 24 24" 576 + stroke-width="1.5" 577 + stroke="currentColor" 578 + class="size-10 text-amber-500" 579 + > 580 + <path 581 + stroke-linecap="round" 582 + stroke-linejoin="round" 583 + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" 584 + /> 585 + </svg> 586 + <p class="text-base-700 dark:text-base-300 text-xl font-bold">Layout Auto-Fixed</p> 587 + <p class="text-base-500 dark:text-base-400 text-sm"> 588 + Your card layout had overlapping cards from an older version. This has been automatically 589 + fixed, but some cards may have moved. Please check your layout and rearrange if needed, 590 + then save to keep the changes. 591 + </p> 592 + <Button class="w-full" onclick={acknowledgeLayoutFix}>Got it</Button> 593 + </div> 594 + </Modal> 558 595 559 596 <Modal open={showMobileWarning} closeButton={false}> 560 597 <div class="flex flex-col items-center gap-4 text-center">
+97
src/lib/website/SettingsModal.svelte
··· 1 + <script lang="ts" module> 2 + export const settingsModalState = $state({ 3 + visible: false, 4 + show: () => (settingsModalState.visible = true), 5 + hide: () => (settingsModalState.visible = false) 6 + }); 7 + </script> 8 + 9 + <script lang="ts"> 10 + import type { WebsiteData } from '$lib/types'; 11 + import Modal from '$lib/components/modal/Modal.svelte'; 12 + 13 + let { data = $bindable() }: { data: WebsiteData } = $props(); 14 + 15 + type LayoutMode = NonNullable<WebsiteData['publication']['preferences']>['layoutMode']; 16 + 17 + type LayoutOption = { 18 + key: string; 19 + value: LayoutMode; 20 + label: string; 21 + description: string; 22 + }; 23 + 24 + const options: LayoutOption[] = [ 25 + { 26 + key: 'automatic', 27 + value: undefined, 28 + label: 'Automatic', 29 + description: 'Automatically syncs layouts until both are edited independently' 30 + }, 31 + { 32 + key: 'desktop-leads', 33 + value: 'desktop-leads', 34 + label: 'Desktop drives mobile', 35 + description: 'Desktop edits update mobile, mobile edits are independent' 36 + }, 37 + { 38 + key: 'mobile-leads', 39 + value: 'mobile-leads', 40 + label: 'Mobile drives desktop', 41 + description: 'Mobile edits update desktop, desktop edits are independent' 42 + }, 43 + { 44 + key: 'independent', 45 + value: 'independent', 46 + label: 'Independent', 47 + description: 'Desktop and mobile layouts are fully independent' 48 + } 49 + ]; 50 + 51 + let selected = $derived(data.publication.preferences?.layoutMode ?? 'automatic'); 52 + 53 + function selectOption(option: LayoutOption) { 54 + data.publication.preferences ??= {}; 55 + data.publication.preferences.layoutMode = option.value; 56 + data = { ...data }; 57 + } 58 + </script> 59 + 60 + <Modal bind:open={settingsModalState.visible}> 61 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Settings</h3> 62 + 63 + <div class="mt-4"> 64 + <h4 class="text-base-800 dark:text-base-200 text-sm font-medium">Layout Sync</h4> 65 + <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 66 + Control how desktop and mobile layouts stay in sync. 67 + </p> 68 + 69 + <div class="mt-3 flex flex-col gap-2"> 70 + {#each options as option (option.key)} 71 + {@const isSelected = option.key === (selected === undefined ? 'automatic' : selected)} 72 + <button 73 + type="button" 74 + class="border-base-200 dark:border-base-700 hover:border-base-300 dark:hover:border-base-600 rounded-xl border-2 px-4 py-3 text-left transition-colors {isSelected 75 + ? 'border-accent-500 bg-accent-50 dark:bg-accent-950/20' 76 + : 'bg-base-50 dark:bg-base-800/50'}" 77 + onclick={() => selectOption(option)} 78 + > 79 + <span 80 + class="text-sm font-semibold {isSelected 81 + ? 'text-accent-700 dark:text-accent-300' 82 + : 'text-base-900 dark:text-base-100'}" 83 + > 84 + {option.label} 85 + </span> 86 + <p 87 + class="mt-0.5 text-xs {isSelected 88 + ? 'text-accent-600 dark:text-accent-400' 89 + : 'text-base-500 dark:text-base-400'}" 90 + > 91 + {option.description} 92 + </p> 93 + </button> 94 + {/each} 95 + </div> 96 + </div> 97 + </Modal>
+43 -5
src/lib/website/load.ts
··· 7 7 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 8 8 9 9 import { isDid, isHandle } from '@atcute/lexicons/syntax'; 10 - import { fixAllCollisions, compactItems } from '$lib/layout'; 10 + import { fixAllCollisions, compactItems, hasOverlaps } from '$lib/layout'; 11 11 12 12 const CURRENT_CACHE_VERSION = 1; 13 13 ··· 378 378 const cards = data.cards.filter((v) => v.page === data.page); 379 379 380 380 if (cards.length > 0) { 381 - fixAllCollisions(cards, false); 382 - fixAllCollisions(cards, true); 381 + // Detect overlaps before fixing — flag is used by the edit UI 382 + const desktopOverlaps = hasOverlaps(cards, false); 383 + const mobileOverlaps = hasOverlaps(cards, true); 384 + data.hasLayoutIssue = desktopOverlaps || mobileOverlaps; 385 + 386 + if (data.hasLayoutIssue) { 387 + console.log('[checkData] Layout issues detected:'); 388 + if (desktopOverlaps) console.log(' - Desktop has overlapping cards'); 389 + if (mobileOverlaps) console.log(' - Mobile has overlapping cards'); 390 + 391 + // Log before positions 392 + const before = cards.map((c) => ({ 393 + id: c.id, 394 + type: c.cardType, 395 + desktop: `(${c.x},${c.y},${c.w}x${c.h})`, 396 + mobile: `(${c.mobileX},${c.mobileY},${c.mobileW}x${c.mobileH})` 397 + })); 383 398 384 - compactItems(cards, false); 385 - compactItems(cards, true); 399 + fixAllCollisions(cards, false); 400 + fixAllCollisions(cards, true); 401 + compactItems(cards, false); 402 + compactItems(cards, true); 403 + 404 + // Log changes 405 + for (let i = 0; i < cards.length; i++) { 406 + const c = cards[i]; 407 + const b = before[i]; 408 + const newDesktop = `(${c.x},${c.y},${c.w}x${c.h})`; 409 + const newMobile = `(${c.mobileX},${c.mobileY},${c.mobileW}x${c.mobileH})`; 410 + if (newDesktop !== b.desktop || newMobile !== b.mobile) { 411 + console.log( 412 + ` ${b.type} ${b.id}: ` + 413 + (newDesktop !== b.desktop ? `desktop ${b.desktop} → ${newDesktop} ` : '') + 414 + (newMobile !== b.mobile ? `mobile ${b.mobile} → ${newMobile}` : '') 415 + ); 416 + } 417 + } 418 + } else { 419 + fixAllCollisions(cards, false); 420 + fixAllCollisions(cards, true); 421 + compactItems(cards, false); 422 + compactItems(cards, true); 423 + } 386 424 } 387 425 388 426 data.cards = cards;
+9
vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + import { sveltekit } from '@sveltejs/kit/vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()], 6 + test: { 7 + include: ['src/**/*.test.ts'] 8 + } 9 + });