From 4031fd3ea0b42e406584f71261071c4918e7f864 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:14:07 +0300 Subject: [PATCH 01/14] fix web runtime behavior and responsive transitions --- package-lock.json | 316 +++++++++++++++++++--------------- package.json | 1 + src/App.css | 53 +++++- src/App.vue | 119 +++++++------ src/store.ts | 49 +++++- tests/web-regressions.test.ts | 119 +++++++++++++ web/index.html | 150 +++++++++++++++- 7 files changed, 600 insertions(+), 207 deletions(-) create mode 100644 tests/web-regressions.test.ts diff --git a/package-lock.json b/package-lock.json index 85766b9..3867239 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.0.0", "license": "ISC", "dependencies": { + "@lynx-js/web-core": "https://pkg.pr.new/@lynx-js/web-core@2971", "vue": "^3.5.0", "vue-lynx": "0.4.0" }, @@ -371,14 +372,7 @@ "node": "^18.14 || >=19.4" } }, - "node_modules/@lynx-js/template-webpack-plugin/node_modules/@rspack/lite-tapable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", - "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", - "license": "MIT", - "peer": true - }, - "node_modules/@lynx-js/web-core": { + "node_modules/@lynx-js/template-webpack-plugin/node_modules/@lynx-js/web-core": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@lynx-js/web-core/-/web-core-0.21.0.tgz", "integrity": "sha512-GrmABxobiXmFQvjTe9fiSNgZKnG+pswJjSBuD/XOlit+u7vpC1xR1yxJBocalLlSwGJVzpdFE1AbG7y14nQdBw==", @@ -406,7 +400,7 @@ } } }, - "node_modules/@lynx-js/web-elements": { + "node_modules/@lynx-js/template-webpack-plugin/node_modules/@lynx-js/web-elements": { "version": "0.12.3", "resolved": "https://registry.npmjs.org/@lynx-js/web-elements/-/web-elements-0.12.3.tgz", "integrity": "sha512-2hww4llLjwl/Q6y53V4roTNnqc/KYtIq2g8E2UD3Lnh35IJKdFTOQGDfyi82vbfkLD+XAvSIHDvP+fQufV6kqg==", @@ -420,6 +414,60 @@ "tslib": "^2.5.0" } }, + "node_modules/@lynx-js/template-webpack-plugin/node_modules/@lynx-js/web-worker-rpc": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lynx-js/web-worker-rpc/-/web-worker-rpc-0.21.0.tgz", + "integrity": "sha512-lRVzwz7+vMzSkcHEfDNquNH40f3oki9T3PVDMN4nsLqxu5OesFhRQhLN8HMPaNlx0OL24ZrFdGuRtu0vbxhOOA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@lynx-js/template-webpack-plugin/node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT", + "peer": true + }, + "node_modules/@lynx-js/web-core": { + "version": "0.22.1", + "resolved": "https://pkg.pr.new/@lynx-js/web-core@2971", + "integrity": "sha512-xqkFVXp+6IToy2s31qDDwSWG0NyXT7/8g+0wJF8sMvr+d+C4hTWN3P+h88FSpStZP33yaQDrl0ArJKw1goH4+g==", + "license": "Apache-2.0", + "dependencies": { + "@lynx-js/web-elements": "0.12.5", + "@lynx-js/web-worker-rpc": "https://pkg.pr.new/lynx-family/lynx-stack/@lynx-js/web-worker-rpc@76a4d76", + "wasm-feature-detect": "^1.8.0" + }, + "peerDependencies": { + "@lynx-js/css-serializer": "0.1.6", + "@lynx-js/lynx-core": "0.1.4", + "tslib": "^2.5.0" + }, + "peerDependenciesMeta": { + "@lynx-js/css-serializer": { + "optional": true + }, + "@lynx-js/lynx-core": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@lynx-js/web-elements": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@lynx-js/web-elements/-/web-elements-0.12.5.tgz", + "integrity": "sha512-plNOkiI/AvdSqTmsN8jDBtll3SOqfrnLUuqicn3DrMUT7tizUhX6569dLNae8dk9KQqnS1pukUK9d/P/Nw9edg==", + "license": "Apache-2.0", + "dependencies": { + "dompurify": "^3.3.1", + "markdown-it": "^14.1.0" + }, + "peerDependencies": { + "tslib": "^2.5.0" + } + }, "node_modules/@lynx-js/web-rsbuild-server-middleware": { "version": "0.19.9", "resolved": "https://registry.npmjs.org/@lynx-js/web-rsbuild-server-middleware/-/web-rsbuild-server-middleware-0.19.9.tgz", @@ -428,11 +476,10 @@ "license": "Apache-2.0" }, "node_modules/@lynx-js/web-worker-rpc": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@lynx-js/web-worker-rpc/-/web-worker-rpc-0.21.0.tgz", - "integrity": "sha512-lRVzwz7+vMzSkcHEfDNquNH40f3oki9T3PVDMN4nsLqxu5OesFhRQhLN8HMPaNlx0OL24ZrFdGuRtu0vbxhOOA==", - "license": "Apache-2.0", - "peer": true + "version": "0.22.1", + "resolved": "https://pkg.pr.new/lynx-family/lynx-stack/@lynx-js/web-worker-rpc@76a4d76", + "integrity": "sha512-ixu83cgVbDNL6srOp1S3k5Ww+LkGqO3dMvluAv8VYYRyvoU6i7shp1x14S0TKYktO/VHNQRbojXn6WXIlbtivQ==", + "license": "Apache-2.0" }, "node_modules/@lynx-js/webpack-dev-transport": { "version": "0.2.0", @@ -538,9 +585,9 @@ "license": "MIT" }, "node_modules/@rsbuild/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-1.7.5.tgz", - "integrity": "sha512-i37urpoV4y9NSsGiUOuLdoI42KJ5h4gAZ8EG8Ilmsond3bxoAoOCu7YvC+1pJ7p+r16suVPW8cki891ZKHOoXQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-1.7.6.tgz", + "integrity": "sha512-T1mLz745L59SM4F5HQFkeGAAsDBeHh2e5sT0cSC5c7xkBpstVWXJrQe5HALS+GvIxiV8cUOy2yD8QBT/Ah11lg==", "license": "MIT", "peer": true, "dependencies": { @@ -1094,8 +1141,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -1585,8 +1631,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0", - "peer": true + "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", @@ -2478,11 +2523,10 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -2741,9 +2785,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "license": "MIT", "peer": true }, @@ -3128,13 +3172,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -3858,9 +3895,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -3872,7 +3909,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -3929,9 +3965,9 @@ } }, "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "funding": [ { "type": "github", @@ -3943,11 +3979,10 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -3961,7 +3996,6 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -3988,8 +4022,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/media-typer": { "version": "0.3.0", @@ -4050,6 +4083,82 @@ "webpack": "^5.0.0" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -4797,7 +4906,6 @@ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5525,9 +5633,9 @@ } }, "node_modules/terser": { - "version": "5.48.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", - "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -5543,82 +5651,6 @@ "node": ">=10" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", - "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@minify-html/node": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "@swc/html": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "cssnano": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "html-minifier-terser": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "postcss": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -5778,8 +5810,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unbox-primitive": { "version": "1.1.0", @@ -6033,9 +6064,9 @@ } }, "node_modules/webpack": { - "version": "5.107.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", - "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "version": "5.108.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.4.tgz", + "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", "license": "MIT", "peer": true, "dependencies": { @@ -6048,19 +6079,18 @@ "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.0", + "enhanced-resolve": "^5.22.2", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "loader-runner": "^4.3.2", "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.5.0", - "watchpack": "^2.5.1", + "watchpack": "^2.5.2", "webpack-sources": "^3.5.0" }, "bin": { @@ -6080,9 +6110,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", - "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "license": "MIT", "peer": true, "engines": { @@ -6097,9 +6127,9 @@ "peer": true }, "node_modules/webpack/node_modules/enhanced-resolve": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", - "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", "license": "MIT", "peer": true, "dependencies": { diff --git a/package.json b/package.json index 7211f61..4106a34 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "rspeedy preview" }, "dependencies": { + "@lynx-js/web-core": "https://pkg.pr.new/@lynx-js/web-core@2971", "vue": "^3.5.0", "vue-lynx": "0.4.0" }, diff --git a/src/App.css b/src/App.css index d3c490b..1c6690e 100644 --- a/src/App.css +++ b/src/App.css @@ -88,6 +88,12 @@ width: 100%; padding-top: 10px; } +.day-list, +.day-todos { + display: flex; + flex-direction: column; + justify-content: flex-start; +} .empty { display: flex; @@ -117,6 +123,9 @@ .empty-hint { color: #9aa1a9; font-size: 13px; margin-top: 6px; } .day-group { + display: flex; + flex-direction: column; + justify-content: flex-start; width: 94%; margin-left: 3%; margin-top: 10px; @@ -124,11 +133,6 @@ border-radius: 14px; overflow: hidden; box-shadow: 0px 3px 12px rgba(45, 60, 80, 0.08); - animation: group-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) both; -} -@keyframes group-in { - from { opacity: 0; transform: translateY(14px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } } .day-header { @@ -173,13 +177,44 @@ padding-right: 10px; border-bottom: 1px solid #f4f6f8; transition: background-color 0.2s ease; - animation: todo-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both; } .todo:active { background-color: #f7fafc; } .todo--editing { background-color: #f2faff; } -@keyframes todo-in { - from { opacity: 0; transform: translateY(-8px); } - to { opacity: 1; transform: translateY(0); } + +/* Vue transition classes: rows give direct add/remove feedback, while the + day transition handles the first or last todo for a date. Only transform + and opacity animate, keeping the native and web paths compositor-friendly. */ +.todo-enter-active { + transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 220ms cubic-bezier(0.25, 1, 0.5, 1); +} +.todo-leave-active { + transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 160ms cubic-bezier(0.25, 1, 0.5, 1); +} +.todo-enter-from { + opacity: 0; + transform: translateY(-10px) scale(0.985); +} +.todo-leave-to { + opacity: 0; + transform: translateX(-24px) scale(0.985); +} +.day-enter-active { + transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 260ms cubic-bezier(0.25, 1, 0.5, 1); +} +.day-leave-active { + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 180ms cubic-bezier(0.25, 1, 0.5, 1); +} +.day-enter-from { + opacity: 0; + transform: translateY(14px) scale(0.985); +} +.day-leave-to { + opacity: 0; + transform: translateX(-28px) scale(0.985); } .checkbox { diff --git a/src/App.vue b/src/App.vue index c999e79..111d0fa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -327,62 +327,79 @@ function removeTodo(dayKey: string, id: string) { 点右下角 + 添加事项吧 - - - - - {{ getDayType(day.key) }} + + + + + + + {{ getDayType(day.key) }} + + {{ day.key }} · {{ getDay(day.key) }} - {{ day.key }} · {{ getDay(day.key) }} - - - - - - - - {{ todo.text }} - - - - - + + + + + + {{ todo.text }} + + + + + + + - + diff --git a/src/store.ts b/src/store.ts index 44f123b..221ffea 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,15 +16,26 @@ import type { Timeline } from './types.js' const STORAGE_KEY = 'busyWeek' +// Lynx evaluates application code with NativeModules as a function argument. +// It is therefore available as a lexical global, but is not necessarily copied +// onto globalThis (notably in @lynx-js/web-core's worker runtime). +declare const NativeModules: Record | undefined + interface NativeLocalStorageModule { setStorageItem(key: string, value: string): void getStorageItem(key: string, callback: (value: string | null) => void): void clearStorage(): void } +interface BrowserStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + function getNativeStorage(): NativeLocalStorageModule | undefined { - const modules = (globalThis as { NativeModules?: Record }) - .NativeModules + const modules = typeof NativeModules !== 'undefined' + ? NativeModules + : (globalThis as { NativeModules?: Record }).NativeModules const storage = modules?.NativeLocalStorageModule as | NativeLocalStorageModule | undefined @@ -34,6 +45,22 @@ function getNativeStorage(): NativeLocalStorageModule | undefined { return undefined } +function getBrowserStorage(): BrowserStorage | undefined { + try { + const storage = (globalThis as { localStorage?: BrowserStorage }).localStorage + if ( + storage + && typeof storage.getItem === 'function' + && typeof storage.setItem === 'function' + ) { + return storage + } + } catch { + /* localStorage can throw when browser storage is disabled */ + } + return undefined +} + // In-memory fallback so the app works even without the native module. const memory = new Map() @@ -56,6 +83,15 @@ export function loadTimeline(): Promise { native.getStorageItem(STORAGE_KEY, (value) => resolve(parse(value))) return } + const browser = getBrowserStorage() + if (browser) { + try { + resolve(parse(browser.getItem(STORAGE_KEY))) + return + } catch { + /* fall through to memory when browser storage is unavailable */ + } + } resolve(parse(memory.get(STORAGE_KEY))) }) } @@ -68,5 +104,14 @@ export function saveTimeline(timeline: Timeline): void { native.setStorageItem(STORAGE_KEY, raw) return } + const browser = getBrowserStorage() + if (browser) { + try { + browser.setItem(STORAGE_KEY, raw) + return + } catch { + /* fall through to memory when quota/privacy settings reject writes */ + } + } memory.set(STORAGE_KEY, raw) } diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts new file mode 100644 index 0000000..cd35474 --- /dev/null +++ b/tests/web-regressions.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { loadTimeline, saveTimeline } from '../src/store.ts' + +const appSource = readFileSync(new URL('../src/App.vue', import.meta.url), 'utf8') +const appCss = readFileSync(new URL('../src/App.css', import.meta.url), 'utf8') +const storeSource = readFileSync(new URL('../src/store.ts', import.meta.url), 'utf8') +const webHost = readFileSync(new URL('../web/index.html', import.meta.url), 'utf8') + +test('the assembled web runtime maps Lynx textarea to x-textarea', () => { + const client = readFileSync( + new URL( + '../node_modules/@lynx-js/web-core/dist/client_prod/static/js/client.js', + import.meta.url, + ), + 'utf8', + ) + + assert.match(client, /textarea\s*:\s*["']x-textarea["']/) +}) + +test('the web host enables the x-textarea lynxinput bridge before typing', () => { + assert.match(webHost, /querySelector\(['"]#addpage-ta['"]\)/) + assert.match(webHost, /addEventListener\(['"]lynxinput['"]/) +}) + +test('web persistence uses localStorage when the native module is unavailable', async () => { + const previousNativeModules = globalThis.NativeModules + const previousLocalStorage = globalThis.localStorage + const values = new Map() + + Object.defineProperty(globalThis, 'NativeModules', { + configurable: true, + value: undefined, + }) + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem(key: string) { + return values.get(key) ?? null + }, + setItem(key: string, value: string) { + values.set(key, value) + }, + }, + }) + + try { + const expected = { + '2026-07-14': { + date: '2026-07-14', + todos: [ + { + id: 'web-persistence', + date: '2026-07-14', + dayType: 0, + done: false, + text: 'survives reload', + }, + ], + }, + } + + saveTimeline(expected) + + assert.equal(values.get('busyWeek'), JSON.stringify(expected)) + assert.deepEqual(await loadTimeline(), expected) + } finally { + Object.defineProperty(globalThis, 'NativeModules', { + configurable: true, + value: previousNativeModules, + }) + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: previousLocalStorage, + }) + } +}) + +test('the web host exposes localStorage through NativeLocalStorageModule', () => { + assert.match(webHost, /nativeModulesMap/) + assert.match(webHost, /NativeLocalStorageModule/) + assert.match(webHost, /onNativeModulesCall/) + assert.match(webHost, /localStorage\.getItem/) + assert.match(webHost, /localStorage\.setItem/) +}) + +test('store reads Lynx lexical NativeModules before global fallbacks', () => { + assert.match(storeSource, /typeof NativeModules !== 'undefined'/) + assert.match(storeSource, /\? NativeModules/) +}) + +test('todo insertion and removal are owned by an explicit Vue transition group', () => { + assert.match(appSource, /]*name="todo"/s) + assert.match(appSource, /]*:duration="\{ enter: 280, leave: 200 \}"/s) + assert.match(appSource, /class="day-todos"/) + assert.match(appCss, /\.todo-enter-active/) + assert.match(appCss, /\.todo-leave-active/) + assert.match(appCss, /\.todo-enter-from/) + assert.match(appCss, /\.todo-leave-to/) +}) + +test('desktop CSS preserves the legacy centered timeline and floating composer', () => { + assert.match(webHost, /busyweek-web-responsive/) + assert.match(webHost, /@media[^\{]*\(min-width:\s*640px\)/) + assert.match(webHost, /\.timeline[^\{]*\{[^}]*width:\s*70%[^}]*margin-left:\s*15%/s) + assert.match(webHost, /\.addpage[^\{]*\{[^}]*width:\s*500px[^}]*height:\s*60%[^}]*right:\s*86px[^}]*bottom:\s*63px/s) + assert.match(webHost, /transform:\s*translateY\(100vh\)/) + assert.match(webHost, /@media[^\{]*\(min-width:\s*1000px\)/) + assert.match(webHost, /\.timeline[^\{]*\{[^}]*width:\s*57%[^}]*margin-left:\s*21\.5%/s) +}) + +test('motion has a reduced-motion fallback', () => { + assert.match(webHost, /@media\s*\(prefers-reduced-motion:\s*reduce\)/) + assert.match(webHost, /\.todo-enter-active[\s\S]*transition-duration:\s*0\.01ms/) + assert.match(webHost, /\.addpage[^}]*transition-duration:\s*0\.01ms/s) +}) diff --git a/web/index.html b/web/index.html index b016659..899a268 100644 --- a/web/index.html +++ b/web/index.html @@ -137,8 +137,58 @@ - - + + + + + +
From 8bc6934542898941dc6587238375a44a6507cf5f Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:41:59 +0300 Subject: [PATCH 02/14] docs: design completed toggle and native scrolling --- ...ggle-starter-todos-native-scroll-design.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-completed-toggle-starter-todos-native-scroll-design.md diff --git a/docs/superpowers/specs/2026-07-14-completed-toggle-starter-todos-native-scroll-design.md b/docs/superpowers/specs/2026-07-14-completed-toggle-starter-todos-native-scroll-design.md new file mode 100644 index 0000000..9042041 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-completed-toggle-starter-todos-native-scroll-design.md @@ -0,0 +1,54 @@ +# Completed Toggle, Starter Todos, and Native Scrolling Design + +## Goals + +- Replace the three-way filter bar with one app-bar control that shows or hides completed todos. +- Seed a fresh installation with three instructional todos. +- Preserve an explicitly stored empty timeline instead of reseeding it. +- Make the main timeline and day selector scroll reliably on native Lynx when their content overflows. + +## App-bar control + +The secondary filter bar and its `all / active / done` state are removed. The app bar keeps the BusyWeek logo on the left and adds a compact `显示已完成` switch on the right. + +The switch is off at launch and is session-only: + +- Off: show pending todos only. +- On: show both pending and completed todos. + +Completed todos keep their current checked and struck-through presentation. When completed todos are hidden and every stored todo is complete, the empty state explains that all pending work is complete and points to the switch. Otherwise the existing empty state remains. + +## Fresh-start data + +Persistence loading must distinguish two states that are currently both represented by `{}`: + +- No stored value: return `null` and create starter data. +- A stored value, including `{}`: return that value unchanged. + +The starter timeline contains three pending todos for the current date: + +1. `打开右上角查看已完成` +2. `点击文字编辑事项` +3. `点击圆圈完成事项` + +The generated ids are deterministic starter-only ids, the date is supplied by the caller, and `dayType` is `0`. On Web, the first load seeds and persists these todos; later loads use localStorage. A stored `{}` remains empty. On a native host without persistent storage, a new process is a fresh start and receives the starter data again, while deletes remain respected for the lifetime of the in-memory store. + +Malformed stored JSON is treated as missing data so the application can recover by replacing it with valid starter data. + +## Native scroll structure + +Lynx documents that direct children of `` support only linear/sticky layout and recommends a single child wrapper for complex content. Both broken surfaces currently violate that constraint. + +- Main timeline: add one `.timeline-content` direct child using linear column layout; keep all empty state, transition groups, and spacer inside it. Give `.timeline` a bounded flex box with `height: 0; flex: 1` so content can overflow the viewport. +- Day selector: add one `.dp-content` direct child using linear column layout inside the already fixed-height `.dp-list`. + +This keeps the existing vertical `scroll-orientation`, row sizes, transitions, and Web layout unchanged. + +## Testing + +- Unit-test starter todo content, date, pending state, and fresh object creation. +- Unit-test persistence distinction between a missing key and a stored `{}`. +- Unit-test pending-only versus show-completed timeline projection. +- Add structural regression tests for the app-bar toggle and single-child linear scroll wrappers. +- Run TypeScript checks and both Lynx/Web production builds. +- Browser-test the switch, fresh-start persistence, stored-empty behavior, long timeline scrolling, and day selector scrolling. Native-specific structure is verified against Lynx's documented scroll-view constraints; final physical-device confirmation remains the decisive native check. From cfa70fdae2c389caa3416e6e69912a52809599ec Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:43:19 +0300 Subject: [PATCH 03/14] docs: plan completed toggle and scroll fixes --- ...eted-toggle-starter-todos-native-scroll.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-completed-toggle-starter-todos-native-scroll.md diff --git a/docs/superpowers/plans/2026-07-14-completed-toggle-starter-todos-native-scroll.md b/docs/superpowers/plans/2026-07-14-completed-toggle-starter-todos-native-scroll.md new file mode 100644 index 0000000..b20d8eb --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-completed-toggle-starter-todos-native-scroll.md @@ -0,0 +1,218 @@ +# Completed Toggle, Starter Todos, and Native Scrolling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace list filters with a show-completed switch, seed only genuinely fresh starts, and make both native scroll views use Lynx-compatible bounded single-child layouts. + +**Architecture:** Put deterministic starter-data creation and timeline projection in small pure TypeScript modules so behavior is unit-testable outside the renderer. Change persistence loading to return `null` only when the storage key is absent or invalid. Restructure each `` around one linear direct child and explicitly bound the main flex scroller. + +**Tech Stack:** TypeScript 5.9, Vue Lynx 0.4, Lynx ``, Node test runner, Rspeedy. + +--- + +## File map + +- Create `src/starterTimeline.ts`: construct the three instructional todos for a supplied date. +- Create `src/timelineView.ts`: project stored timeline data according to `showCompleted`. +- Modify `src/store.ts`: preserve the difference between no value and an explicitly stored empty timeline. +- Modify `src/App.vue`: load starter data, replace filters with a toggle, and wrap timeline scroll content. +- Modify `src/App.css`: style the app-bar switch and bound the main scroller/linear wrapper. +- Modify `src/components/DayPickerSheet.vue`: add the single direct child required by Lynx scroll-view. +- Modify `src/components/daypicker.css`: give the day-picker wrapper linear column layout. +- Create `tests/starter-timeline.test.ts`: starter data behavior. +- Create `tests/timeline-view.test.ts`: completed visibility behavior. +- Modify `tests/web-regressions.test.ts`: persistence and structural regressions. + +### Task 1: Fresh-start persistence and starter data + +**Files:** +- Create: `src/starterTimeline.ts` +- Create: `tests/starter-timeline.test.ts` +- Modify: `src/store.ts` +- Modify: `tests/web-regressions.test.ts` + +- [ ] **Step 1: Write failing starter and persistence tests** + +```ts +test('creates three pending instructional todos for the supplied date', () => { + const timeline = createStarterTimeline('2026-07-14') + const todos = timeline['2026-07-14'].todos + assert.deepEqual(todos.map((todo) => todo.text), [ + '打开右上角查看已完成', + '点击文字编辑事项', + '点击圆圈完成事项', + ]) + assert.ok(todos.every((todo) => !todo.done && todo.dayType === 0)) +}) + +test('missing storage is distinct from an explicitly stored empty timeline', async () => { + localValues.delete('busyWeek') + assert.equal(await loadTimeline(), null) + localValues.set('busyWeek', '{}') + assert.deepEqual(await loadTimeline(), {}) +}) +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: `node --test tests/starter-timeline.test.ts tests/web-regressions.test.ts` + +Expected: FAIL because `starterTimeline.ts` does not exist and missing storage still resolves to `{}`. + +- [ ] **Step 3: Implement deterministic starter construction and nullable loading** + +```ts +export function createStarterTimeline(date: string): Timeline { + const texts = [ + '打开右上角查看已完成', + '点击文字编辑事项', + '点击圆圈完成事项', + ] + return { + [date]: { + date, + todos: texts.map((text, index) => ({ + id: `starter-${index + 1}`, + date, + dayType: 0, + done: false, + text, + })), + }, + } +} +``` + +Change `parse` and `loadTimeline` to return `Timeline | null`, returning `null` for absent/malformed raw data and preserving parsed `{}`. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `node --test tests/starter-timeline.test.ts tests/web-regressions.test.ts` + +Expected: all focused tests pass. + +### Task 2: Show-completed projection and app-bar toggle + +**Files:** +- Create: `src/timelineView.ts` +- Create: `tests/timeline-view.test.ts` +- Modify: `src/App.vue` +- Modify: `src/App.css` +- Modify: `tests/web-regressions.test.ts` + +- [ ] **Step 1: Write failing projection and structure tests** + +```ts +test('hides completed todos until showCompleted is enabled', () => { + assert.deepEqual(getVisibleDays(timeline, false)[0].todos.map((todo) => todo.id), ['active']) + assert.deepEqual(getVisibleDays(timeline, true)[0].todos.map((todo) => todo.id), ['active', 'done']) +}) + +test('uses one app-bar show-completed control instead of filter tabs', () => { + assert.doesNotMatch(appSource, /class="filters"/) + assert.match(appSource, /class="completed-toggle"/) + assert.match(appSource, /showCompleted\.value|showCompleted = !showCompleted/) +}) +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: `node --test tests/timeline-view.test.ts tests/web-regressions.test.ts` + +Expected: FAIL because the projection module and app-bar toggle do not exist. + +- [ ] **Step 3: Implement projection and toggle** + +```ts +export function getVisibleDays(timeline: Timeline, showCompleted: boolean): VisibleDay[] { + return Object.keys(timeline) + .sort() + .map((key) => ({ + key, + todos: timeline[key].todos.filter((todo) => showCompleted || !todo.done), + })) + .filter((day) => day.todos.length > 0) +} +``` + +In `App.vue`, replace `activeFilter` and `filters` with `const showCompleted = ref(false)`, load `stored ?? createStarterTimeline(getTodayDate())`, use `getVisibleDays`, and render a tappable `.completed-toggle` in `.app-bar`. Add a track and thumb whose active classes reflect `showCompleted`. Add a computed empty-state message for the all-completed-hidden case. + +In `App.css`, set `.app-bar { justify-content: space-between; }`, group the logo in `.brand`, and add the switch label/track/thumb styles with a 44px minimum touch target and short transform/color transitions. Delete the filter bar styles. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `node --test tests/timeline-view.test.ts tests/web-regressions.test.ts` + +Expected: all focused tests pass. + +### Task 3: Native-compatible scroll-view children + +**Files:** +- Modify: `src/App.vue` +- Modify: `src/App.css` +- Modify: `src/components/DayPickerSheet.vue` +- Modify: `src/components/daypicker.css` +- Modify: `tests/web-regressions.test.ts` + +- [ ] **Step 1: Write failing structural regression tests** + +```ts +test('native scroll views use bounded single linear content children', () => { + assert.match(appSource, /]*>\s*/s) + assert.match(appCss, /\.timeline\s*\{[^}]*height:\s*0/s) + assert.match(appCss, /\.timeline-content\s*\{[^}]*display:\s*linear[^}]*linear-direction:\s*column/s) + assert.match(dayPickerSource, /]*>\s*/s) + assert.match(dayPickerCss, /\.dp-content\s*\{[^}]*display:\s*linear[^}]*linear-direction:\s*column/s) +}) +``` + +- [ ] **Step 2: Run the structural test and verify RED** + +Run: `node --test tests/web-regressions.test.ts` + +Expected: FAIL because neither single-child wrapper exists and timeline height is unbounded. + +- [ ] **Step 3: Implement the wrapper structure and bounded sizes** + +Wrap all direct timeline children in ``. Wrap all day-picker rows in ``. Add: + +```css +.timeline { flex: 1; height: 0; width: 100%; padding-top: 10px; } +.timeline-content { display: linear; linear-direction: column; width: 100%; } +.dp-content { display: linear; linear-direction: column; width: 100%; } +``` + +Keep `.dp-list { height: 320px; }` and both vertical `scroll-orientation` attributes. + +- [ ] **Step 4: Run structural tests and verify GREEN** + +Run: `node --test tests/web-regressions.test.ts` + +Expected: all structural regressions pass. + +### Task 4: Full verification and publication + +**Files:** +- Verify all modified files. + +- [ ] **Step 1: Run full automated verification** + +Run: `git diff --check && node --test tests/*.test.ts && npx tsc --noEmit && npm run build:web` + +Expected: zero test failures, TypeScript exit 0, and successful Lynx/Web production bundles plus static assembly. + +- [ ] **Step 2: Run browser behavior checks** + +Serve `dist/`, then verify at mobile and desktop sizes: + +- fresh localStorage shows exactly three starter todos and persists them; +- stored `{}` remains empty after reload; +- completing a todo hides it while the switch is off; +- enabling the switch shows pending and completed todos together; +- a long generated timeline scrolls vertically; +- the day selector scroll offset changes after a vertical wheel/drag; +- no page errors occur. + +- [ ] **Step 3: Commit and push** + +Stage only the planned source, test, and plan files. Preserve unrelated `artifacts/` and the pre-existing untracked plan. Commit with `feat: add completed toggle and starter todos` and push `HEAD` to `origin/claude/vue-lynx-port-mxmd1y` so the branch preview rebuilds. From a8f08d850e191d686db6e44ebc48d72d75a4d00e Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:45:36 +0300 Subject: [PATCH 04/14] feat: seed fresh timelines --- src/starterTimeline.ts | 23 +++++++++++++++++++ src/store.ts | 17 +++++++------- tests/starter-timeline.test.ts | 28 +++++++++++++++++++++++ tests/web-regressions.test.ts | 41 ++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/starterTimeline.ts create mode 100644 tests/starter-timeline.test.ts diff --git a/src/starterTimeline.ts b/src/starterTimeline.ts new file mode 100644 index 0000000..dfc24f8 --- /dev/null +++ b/src/starterTimeline.ts @@ -0,0 +1,23 @@ +import type { Timeline } from './types.js' + +const STARTER_TODO_TEXTS = [ + '打开右上角查看已完成', + '点击文字编辑事项', + '点击圆圈完成事项', +] as const + +/** Create the instructional timeline shown only when no stored value exists. */ +export function createStarterTimeline(date: string): Timeline { + return { + [date]: { + date, + todos: STARTER_TODO_TEXTS.map((text, index) => ({ + id: `starter-${index + 1}`, + date, + dayType: 0, + done: false, + text, + })), + }, + } +} diff --git a/src/store.ts b/src/store.ts index 221ffea..56d0f98 100644 --- a/src/store.ts +++ b/src/store.ts @@ -64,19 +64,20 @@ function getBrowserStorage(): BrowserStorage | undefined { // In-memory fallback so the app works even without the native module. const memory = new Map() -function parse(raw: string | null | undefined): Timeline { - if (!raw) { - return {} - } +function parse(raw: string | null | undefined): Timeline | null { + if (raw == null) return null + try { - return JSON.parse(raw) as Timeline + const value = JSON.parse(raw) as unknown + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Timeline } catch { - return {} + return null } } -/** Load the persisted timeline. Resolves to `{}` when nothing is stored. */ -export function loadTimeline(): Promise { +/** Load persisted data, or `null` when no valid stored value exists. */ +export function loadTimeline(): Promise { return new Promise((resolve) => { const native = getNativeStorage() if (native) { diff --git a/tests/starter-timeline.test.ts b/tests/starter-timeline.test.ts new file mode 100644 index 0000000..b97ab7b --- /dev/null +++ b/tests/starter-timeline.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createStarterTimeline } from '../src/starterTimeline.ts' + +test('creates three pending instructional todos for the supplied date', () => { + const timeline = createStarterTimeline('2026-07-14') + const todos = timeline['2026-07-14'].todos + + assert.deepEqual(todos.map((todo) => todo.text), [ + '打开右上角查看已完成', + '点击文字编辑事项', + '点击圆圈完成事项', + ]) + assert.ok(todos.every((todo) => todo.date === '2026-07-14')) + assert.ok(todos.every((todo) => todo.dayType === 0)) + assert.ok(todos.every((todo) => todo.done === false)) +}) + +test('returns fresh starter objects so callers cannot share mutations', () => { + const first = createStarterTimeline('2026-07-14') + const second = createStarterTimeline('2026-07-14') + + first['2026-07-14'].todos[0].done = true + + assert.notStrictEqual(first, second) + assert.equal(second['2026-07-14'].todos[0].done, false) +}) diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index cd35474..c45d11f 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -79,6 +79,47 @@ test('web persistence uses localStorage when the native module is unavailable', } }) +test('missing storage is distinct from an explicitly stored empty timeline', async () => { + const previousNativeModules = globalThis.NativeModules + const previousLocalStorage = globalThis.localStorage + const values = new Map() + + Object.defineProperty(globalThis, 'NativeModules', { + configurable: true, + value: undefined, + }) + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem(key: string) { + return values.get(key) ?? null + }, + setItem(key: string, value: string) { + values.set(key, value) + }, + }, + }) + + try { + assert.equal(await loadTimeline(), null) + + values.set('busyWeek', '{}') + assert.deepEqual(await loadTimeline(), {}) + + values.set('busyWeek', '{not valid json') + assert.equal(await loadTimeline(), null) + } finally { + Object.defineProperty(globalThis, 'NativeModules', { + configurable: true, + value: previousNativeModules, + }) + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: previousLocalStorage, + }) + } +}) + test('the web host exposes localStorage through NativeLocalStorageModule', () => { assert.match(webHost, /nativeModulesMap/) assert.match(webHost, /NativeLocalStorageModule/) From abc9e52901534ea0e958ddf1bb2eaf11f099a6f4 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:47:46 +0300 Subject: [PATCH 05/14] feat: add show completed toggle --- src/App.css | 60 ++++++++++++++---------- src/App.vue | 86 +++++++++++++++++------------------ src/timelineView.ts | 22 +++++++++ tests/timeline-view.test.ts | 63 +++++++++++++++++++++++++ tests/web-regressions.test.ts | 8 ++++ 5 files changed, 172 insertions(+), 67 deletions(-) create mode 100644 src/timelineView.ts create mode 100644 tests/timeline-view.test.ts diff --git a/src/App.css b/src/App.css index 1c6690e..722bd97 100644 --- a/src/App.css +++ b/src/App.css @@ -14,7 +14,7 @@ to { opacity: 1; } } -/* ===== Pinned header (app bar + filters never scroll) ===== */ +/* ===== Pinned app bar ===== */ .header { position: relative; z-index: 10; @@ -32,10 +32,17 @@ display: flex; flex-direction: row; align-items: center; + justify-content: space-between; height: 56px; padding-left: 18px; padding-right: 18px; } +.brand { + display: flex; + flex-direction: row; + align-items: center; + flex: 1; +} .logo { color: #ffffff; font-size: 20px; @@ -49,38 +56,43 @@ margin-top: 4px; } -.filters { - position: relative; +.completed-toggle { display: flex; flex-direction: row; -} -.filter { - flex: 1; - display: flex; align-items: center; justify-content: center; height: 44px; + padding-left: 10px; } -.filter:active { background-color: rgba(255, 255, 255, 0.08); } -.filter-text { - color: rgba(255, 255, 255, 0.72); - font-size: 15px; - transition: color 0.25s ease, font-weight 0.25s ease; -} -.filter-text--active { - color: #ffffff; - font-weight: 700; +.completed-toggle-label { + color: rgba(255, 255, 255, 0.74); + font-size: 13px; + margin-right: 8px; + transition: color 0.2s ease; +} +.completed-toggle-label--on { color: #ffffff; } +.completed-toggle-track { + width: 38px; + height: 22px; + border-radius: 11px; + background-color: rgba(255, 255, 255, 0.28); + transition: background-color 0.22s ease; + display: flex; + flex-direction: row; + align-items: center; } -.filter-indicator { - position: absolute; - bottom: 0; - left: 0; - width: 33.333%; - height: 3px; - border-radius: 3px; +.completed-toggle-track--on { background-color: rgba(255, 255, 255, 0.55); } +.completed-toggle-thumb { + width: 18px; + height: 18px; + margin-left: 2px; + border-radius: 9px; background-color: #ffffff; - transition: transform 0.32s cubic-bezier(0.34, 1.4, 0.5, 1); + box-shadow: 0px 1px 4px rgba(0, 60, 100, 0.28); + transform: translateX(0); + transition: transform 0.24s cubic-bezier(0.34, 1.4, 0.5, 1); } +.completed-toggle-thumb--on { transform: translateX(16px); } /* ===== Timeline — the only scroller ===== */ .timeline { diff --git a/src/App.vue b/src/App.vue index 111d0fa..d514425 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,8 +10,10 @@ import { import './App.css' import { syncNativeInputOnMount } from './nativeInput.js' -import type { Filter, Timeline, Todo } from './types.js' +import { createStarterTimeline } from './starterTimeline.js' import { loadTimeline, saveTimeline } from './store.js' +import { getVisibleDays } from './timelineView.js' +import type { Timeline, Todo } from './types.js' import { getDateDiff, getDay, getDayType, getTodayDate, parseDate } from './util.js' import DatePickerSheet from './components/DatePickerSheet.vue' import DayPickerSheet from './components/DayPickerSheet.vue' @@ -21,7 +23,7 @@ type AppState = 'LIST' | 'INPUT' // --- reactive state (ported from the original `data` object) --------------- const state = ref('LIST') const timeline = ref({}) -const activeFilter = ref('all') +const showCompleted = ref(false) const editingId = ref(null) // the "new todo" being composed on the add page @@ -47,15 +49,6 @@ const prettyDate = computed(() => { return `${month0 + 1}月${day}日` }) -const filters: { value: Filter; label: string }[] = [ - { value: 'all', label: '全部' }, - { value: 'active', label: '在忙' }, - { value: 'done', label: '完成' }, -] -const filterIndex = computed(() => - Math.max(0, filters.findIndex((f) => f.value === activeFilter.value)), -) - // --- keyboard avoidance (native) ------------------------------------------- // Lynx has no keyboard-height CSS/viewport primitive, so the documented // approach is to listen for the `keyboardstatuschanged` global event and @@ -84,7 +77,8 @@ function bindKeyboard() { // --- load & persist -------------------------------------------------------- onMounted(async () => { bindKeyboard() - timeline.value = await loadTimeline() + const stored = await loadTimeline() + timeline.value = stored ?? createStarterTimeline(getTodayDate()) }) onUnmounted(() => removeKbListener?.()) @@ -92,22 +86,25 @@ onUnmounted(() => removeKbListener?.()) watch(timeline, (tl) => saveTimeline(tl), { deep: true }) // --- derived view ---------------------------------------------------------- -const visibleDays = computed(() => { - const tl = timeline.value - return Object.keys(tl) - .sort() - .map((key) => { - const todos = tl[key].todos.filter((todo) => { - if (activeFilter.value === 'active') return !todo.done - if (activeFilter.value === 'done') return todo.done - return true - }) - return { key, todos } - }) - .filter((day) => day.todos.length > 0) -}) +const visibleDays = computed(() => + getVisibleDays(timeline.value, showCompleted.value), +) const isEmpty = computed(() => visibleDays.value.length === 0) +const hasStoredTodos = computed(() => + Object.values(timeline.value).some((day) => day.todos.length > 0), +) +const hasOnlyHiddenCompleted = computed( + () => !showCompleted.value && hasStoredTodos.value && isEmpty.value, +) +const emptyText = computed(() => + hasOnlyHiddenCompleted.value ? '待忙事项都完成了' : '这周还不忙', +) +const emptyHint = computed(() => + hasOnlyHiddenCompleted.value + ? '打开右上角查看已完成' + : '点右下角 + 添加事项吧', +) // --- helpers exposed to the template --------------------------------------- function isToday(dateStr: string): boolean { @@ -296,26 +293,29 @@ function removeTodo(dayKey: string, id: string) { - - 好忙啊 - - + + + 好忙啊 + {{ f.label }}显示已完成 + + + - @@ -323,8 +323,8 @@ function removeTodo(dayKey: string, id: string) { - 这周还不忙 - 点右下角 + 添加事项吧 + {{ emptyText }} + {{ emptyHint }} + {{ emptyText }} @@ -402,6 +403,7 @@ function removeTodo(dayKey: string, id: string) { + diff --git a/src/components/DayPickerSheet.vue b/src/components/DayPickerSheet.vue index 58d4d19..bd4cdc7 100644 --- a/src/components/DayPickerSheet.vue +++ b/src/components/DayPickerSheet.vue @@ -32,19 +32,21 @@ function pick(offset: number) { - - {{ getPickerLabel(o) }} + - + {{ getPickerLabel(o) }} + + diff --git a/src/components/daypicker.css b/src/components/daypicker.css index 4729c76..73dc43e 100644 --- a/src/components/daypicker.css +++ b/src/components/daypicker.css @@ -6,6 +6,11 @@ height: 320px; padding-top: 4px; } +.dp-content { + display: linear; + linear-direction: column; + width: 100%; +} .dp-item { display: flex; flex-direction: row; diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index 7df3938..a602631 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -6,6 +6,14 @@ import { loadTimeline, saveTimeline } from '../src/store.ts' const appSource = readFileSync(new URL('../src/App.vue', import.meta.url), 'utf8') const appCss = readFileSync(new URL('../src/App.css', import.meta.url), 'utf8') +const dayPickerSource = readFileSync( + new URL('../src/components/DayPickerSheet.vue', import.meta.url), + 'utf8', +) +const dayPickerCss = readFileSync( + new URL('../src/components/daypicker.css', import.meta.url), + 'utf8', +) const storeSource = readFileSync(new URL('../src/store.ts', import.meta.url), 'utf8') const webHost = readFileSync(new URL('../web/index.html', import.meta.url), 'utf8') @@ -141,6 +149,26 @@ test('uses one app-bar show-completed control instead of filter tabs', () => { assert.match(appSource, /stored \?\? createStarterTimeline\(getTodayDate\(\)\)/) }) +test('native scroll views use bounded single linear content children', () => { + assert.match( + appSource, + /]*>\s*/s, + ) + assert.match(appCss, /\.timeline\s*\{[^}]*height:\s*0/s) + assert.match( + appCss, + /\.timeline-content\s*\{[^}]*display:\s*linear[^}]*linear-direction:\s*column/s, + ) + assert.match( + dayPickerSource, + /]*>\s*/s, + ) + assert.match( + dayPickerCss, + /\.dp-content\s*\{[^}]*display:\s*linear[^}]*linear-direction:\s*column/s, + ) +}) + test('todo insertion and removal are owned by an explicit Vue transition group', () => { assert.match(appSource, /]*name="todo"/s) assert.match(appSource, /]*:duration="\{ enter: 280, leave: 200 \}"/s) From 1308c23357bb76907642928b1f3a0557ebb1d563 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:56:53 +0300 Subject: [PATCH 07/14] fix web toggle reduced motion --- tests/web-regressions.test.ts | 4 ++++ web/index.html | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index a602631..ff39f5b 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -193,4 +193,8 @@ test('motion has a reduced-motion fallback', () => { assert.match(webHost, /@media\s*\(prefers-reduced-motion:\s*reduce\)/) assert.match(webHost, /\.todo-enter-active[\s\S]*transition-duration:\s*0\.01ms/) assert.match(webHost, /\.addpage[^}]*transition-duration:\s*0\.01ms/s) + assert.match(webHost, /\.completed-toggle-label[^}]*transition-duration:\s*0\.01ms/s) + assert.match(webHost, /\.completed-toggle-track[^}]*transition-duration:\s*0\.01ms/s) + assert.match(webHost, /\.completed-toggle-thumb[^}]*transition-duration:\s*0\.01ms/s) + assert.doesNotMatch(webHost, /\.filter-indicator/) }) diff --git a/web/index.html b/web/index.html index 899a268..af0e843 100644 --- a/web/index.html +++ b/web/index.html @@ -282,7 +282,9 @@ transition-duration: 0.01ms; } .addpage:not([l-e-name]), - .filter-indicator:not([l-e-name]), + .completed-toggle-label:not([l-e-name]), + .completed-toggle-track:not([l-e-name]), + .completed-toggle-thumb:not([l-e-name]), .sheet-backdrop:not([l-e-name]), .sheet-panel:not([l-e-name]), .checkbox:not([l-e-name]), From 1427c5b13f2dc59514ebee81ba2dec552c94f004 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:13:05 +0300 Subject: [PATCH 08/14] docs: plan header menu and timeline motion --- ...2026-07-15-header-menu-and-clear-motion.md | 150 ++++++++++++++++++ ...-15-header-menu-and-clear-motion-design.md | 61 +++++++ 2 files changed, 211 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-header-menu-and-clear-motion.md create mode 100644 docs/superpowers/specs/2026-07-15-header-menu-and-clear-motion-design.md diff --git a/docs/superpowers/plans/2026-07-15-header-menu-and-clear-motion.md b/docs/superpowers/plans/2026-07-15-header-menu-and-clear-motion.md new file mode 100644 index 0000000..f6674ac --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-header-menu-and-clear-motion.md @@ -0,0 +1,150 @@ +# Header Menu and Clear-Style Timeline Motion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show completed todos by default, move the Web overflow menu trigger into the app header, and animate retained todos/day cards into their new positions when content disappears. + +**Architecture:** A pure timeline geometry module maps visible data to fixed-height day and todo slots. Vue renders those slots with explicit Y transforms so CSS can interpolate order changes even though Vue Lynx `TransitionGroup` lacks FLIP move support; nested card/row elements keep independent enter/leave motion. The Web host retains the overflow menu content but binds it to the Lynx shadow-root brand instead of a floating button. + +**Tech Stack:** Vue Lynx 0.4, TypeScript, Lynx CSS transitions, Node test runner, Rspeedy, Lynx Web Platform. + +--- + +### Task 1: Default completed visibility and hidden Web menu trigger + +**Files:** +- Modify: `src/App.vue` +- Modify: `web/index.html` +- Modify: `tests/web-regressions.test.ts` + +- [ ] **Step 1: Write failing source-regression assertions** + +Assert that `showCompleted` initializes with `true`, `web/index.html` contains no `ovf-btn`, and the menu script queries `.brand` and handles keyboard activation. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `node --test --test-name-pattern='completed visibility|header brand' tests/web-regressions.test.ts` + +Expected: FAIL because the state is `false`, the button is present, and no brand binding exists. + +- [ ] **Step 3: Implement the minimal state and host changes** + +Change the state to: + +```ts +const showCompleted = ref(true) +``` + +Remove the `.ovf-btn` markup and CSS. Keep `.ovf-menu`, bind `.brand` after the shadow root mounts, and toggle on click, Enter, or Space. Add `role="button"`, `tabindex="0"`, and `aria-label="更多"` to the Web shadow element. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: `node --test --test-name-pattern='completed visibility|header brand' tests/web-regressions.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/App.vue web/index.html tests/web-regressions.test.ts +git commit -m "feat: move web menu into header" +``` + +### Task 2: Deterministic Clear-style timeline geometry + +**Files:** +- Create: `src/timelineMotion.ts` +- Create: `tests/timeline-motion.test.ts` + +- [ ] **Step 1: Write failing geometry tests** + +Use two days with fixed ids. Assert that todo offsets advance by `52`, day offsets include `10 + 42 + rows * 52`, and removing an earlier row changes both the next retained todo and the next day by exactly `52`. + +- [ ] **Step 2: Run the new test and verify RED** + +Run: `node --test tests/timeline-motion.test.ts` + +Expected: FAIL with module-not-found for `src/timelineMotion.ts`. + +- [ ] **Step 3: Implement the pure layout function** + +Export `TODO_ROW_HEIGHT`, `DAY_HEADER_HEIGHT`, `DAY_GAP`, and `createTimelineMotionLayout(days)`. Return `{ height, days }`, where each day record contains `offset`, `todosHeight`, and a todo-id-to-offset map. + +- [ ] **Step 4: Run the new test and verify GREEN** + +Run: `node --test tests/timeline-motion.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/timelineMotion.ts tests/timeline-motion.test.ts +git commit -m "feat: calculate timeline motion slots" +``` + +### Task 3: Render and animate retained positions + +**Files:** +- Modify: `src/App.vue` +- Modify: `src/App.css` +- Modify: `web/index.html` +- Modify: `tests/web-regressions.test.ts` + +- [ ] **Step 1: Write failing structure and motion assertions** + +Assert that the template renders `.day-slot` and `.todo-slot` with layout-derived transforms and container heights. Assert that CSS absolutely positions both slots, transitions their transforms, applies enter/leave transforms to nested `.day-group`/`.todo`, and includes all new selectors in reduced-motion CSS. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `node --test --test-name-pattern='Clear-style motion|reduced-motion' tests/web-regressions.test.ts` + +Expected: FAIL because the slot structure and transitions do not exist. + +- [ ] **Step 3: Integrate the layout into the Vue template** + +Compute `motionLayout` from `visibleDays`. Give `.day-list` its calculated height, wrap every card in a keyed `.day-slot` with its calculated Y transform, give `.day-todos` its calculated height, and wrap every row in a keyed `.todo-slot` with its calculated Y transform. + +- [ ] **Step 4: Add coordinated CSS motion** + +Use `position: relative` on `.day-list`/`.day-todos`, absolute slots at `top: 0; left: 0`, and transform transitions around `260–300ms` with `cubic-bezier(0.22, 1, 0.36, 1)`. Move presence styles onto nested elements so slot transforms and exit transforms never overwrite each other. Add reduced-motion selectors for slot transforms and container heights. + +- [ ] **Step 5: Run the focused and complete test suites** + +Run: `node --test --test-name-pattern='Clear-style motion|reduced-motion' tests/web-regressions.test.ts && node --test tests/*.test.ts` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/App.vue src/App.css web/index.html tests/web-regressions.test.ts +git commit -m "feat: animate timeline reflow" +``` + +### Task 4: Build and behavior verification + +**Files:** +- Verify only; no planned production edits. + +- [ ] **Step 1: Run static and production checks** + +Run: `git diff --check && node --test tests/*.test.ts && npx tsc --noEmit && npm run build:web` + +Expected: zero failures; both `main.lynx.bundle` and `main.web.bundle` are emitted. + +- [ ] **Step 2: Run the assembled Web app** + +Serve `dist/`, clear storage, and verify completed todos are visible by default. Verify clicking the brand opens the menu, clicking the switch does not, and no floating overflow button exists. + +- [ ] **Step 3: Verify timeline motion** + +Load multiple completed and pending todos across two dates. Toggle completed visibility and delete a pending todo. Capture positions across animation frames and confirm retained todo and day-card Y positions interpolate rather than jump. + +- [ ] **Step 4: Verify reduced motion** + +Emulate `prefers-reduced-motion: reduce` and confirm slot/container transition durations resolve to approximately zero while all state changes still complete. + +- [ ] **Step 5: Push the established remote branch and verify deployment** + +Push `HEAD` to `origin/claude/vue-lynx-port-mxmd1y`, wait for the Vercel commit status, and confirm `/`, `/main.web.bundle`, and `/main.lynx.bundle` return HTTP 200. diff --git a/docs/superpowers/specs/2026-07-15-header-menu-and-clear-motion-design.md b/docs/superpowers/specs/2026-07-15-header-menu-and-clear-motion-design.md new file mode 100644 index 0000000..0c6e87b --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-header-menu-and-clear-motion-design.md @@ -0,0 +1,61 @@ +# Header Menu and Clear-Style Timeline Motion Design + +## Goals + +- Show completed todos by default while keeping the app-bar switch as the explicit visibility control. +- Remove the Web-only floating overflow button and expose its existing menu by tapping the BusyWeek header brand area. +- Replace the current enter/leave-only list behavior with continuous displacement motion for retained todos and day cards. + +## Constraints and root cause + +`vue-lynx@0.4.0` implements `TransitionGroup` enter and leave classes, but its runtime does not implement Vue's FLIP move phase. The `moveClass` prop exists in the public component shape but is never applied. Consequently, a leaving todo can animate horizontally while retained siblings receive their new layout positions in one frame and appear to flash into place. + +The solution must run through the same Vue/Lynx template on Native and Web, retain the main timeline's single linear scroll child, and avoid browser-only layout measurement APIs. + +## Considered approaches + +1. Add `.todo-move` and `.day-move` CSS. This is the normal Vue solution but cannot work until Vue Lynx implements the missing FLIP phase. +2. Delay the data mutation until the exit animation ends. This preserves the exit but only postpones the sibling jump and makes persistence/state timing more complex. +3. Use data-driven position slots, following Evan You's HTML5 Clear v2. Every retained item transitions between explicit order-derived `translateY` positions while the departing item transitions independently. This is deterministic without DOM measurements and is the selected approach. + +## Timeline layout model + +Create a small pure `timelineMotion` module with the shared geometry: + +- todo row height: `52px` +- day header height: `42px` +- gap before each day card: `10px` + +For each visible day, the module produces: + +- the day slot's cumulative Y offset; +- the day todo container height; +- every todo slot's `index * 52` Y offset; +- the total day-list height. + +The template uses two levels of absolute slots inside bounded relative containers: + +- `.day-slot` transitions to its new cumulative Y position. Its inner `.day-group` owns the existing card visuals and enter/leave animation. +- `.todo-slot` transitions to its new row Y position. Its inner `.todo` owns the existing row visuals and enter/leave animation. + +The `.day-list` and each `.day-todos` container transition their calculated height. Therefore a deletion or completed-filter change produces three coordinated signals: the removed content exits left, retained rows move into their exact new slots, and later day cards move with the collapsing card height. Reduced-motion mode reduces all new transform and height transitions to `0.01ms`. + +## Header and Web menu + +`showCompleted` starts as `true`, so both pending and completed todos are visible on launch. The switch remains session-only and can hide completed todos. + +The Web host removes `.ovf-btn` entirely. The existing copy-bundle and Legacy menu remains in light DOM but has no persistent visual trigger. Its script waits for the Lynx shadow root, binds the `.brand` area, and toggles the menu on click or Enter/Space. The brand receives Web-only button semantics and a focus target. Clicking the completed toggle does not open the menu because it is outside the brand target. + +Clicking elsewhere closes the menu. Copy behavior and the full-screen Lynx bundle URL remain unchanged. Native has no Web menu binding, so tapping the brand remains inert there. + +## Verification + +- Unit-test layout offsets before and after removing rows and entire days. +- Add structural regression tests for default-visible completed state, removal of the floating Web button, header-brand menu binding, nested motion slots, and reduced-motion coverage. +- Run all Node tests, TypeScript, and production Lynx/Web builds. +- In the Web runtime, verify the menu, completed switch, deletion, filtering, retained-row displacement, retained-day displacement, and reduced-motion behavior. +- Treat physical Lynx Explorer verification as the final authority for Native motion timing. + +## Reference + +The motion model follows the ordering strategy in [yyx990803/HTML5-Clear-v2](https://github.com/yyx990803/HTML5-Clear-v2): items are absolutely positioned and their Y transforms are updated when their order changes, so surrounding content visibly moves instead of being reflowed instantaneously. From c3892801fd3c5304b4988a2d4fed1b3c5d26d2b1 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:29:35 +0300 Subject: [PATCH 09/14] feat: move web menu into header --- src/App.vue | 2 +- tests/web-regressions.test.ts | 12 ++++++ web/index.html | 73 +++++++++++++++-------------------- 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/App.vue b/src/App.vue index 9d11f63..f24e123 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,7 +23,7 @@ type AppState = 'LIST' | 'INPUT' // --- reactive state (ported from the original `data` object) --------------- const state = ref('LIST') const timeline = ref({}) -const showCompleted = ref(false) +const showCompleted = ref(true) const editingId = ref(null) // the "new todo" being composed on the add page diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index ff39f5b..c45e80a 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -149,6 +149,18 @@ test('uses one app-bar show-completed control instead of filter tabs', () => { assert.match(appSource, /stored \?\? createStarterTimeline\(getTodayDate\(\)\)/) }) +test('completed visibility starts enabled', () => { + assert.match(appSource, /const showCompleted = ref\(true\)/) +}) + +test('the hidden Web menu is triggered by the header brand', () => { + assert.doesNotMatch(webHost, /class="ovf-btn"/) + assert.doesNotMatch(webHost, /id="ovf-btn"/) + assert.match(webHost, /querySelector\(['"]\.brand['"]\)/) + assert.match(webHost, /brand\.addEventListener\(['"]click['"]/) + assert.match(webHost, /brand\.addEventListener\(['"]keydown['"]/) +}) + test('native scroll views use bounded single linear content children', () => { assert.match( appSource, diff --git a/web/index.html b/web/index.html index af0e843..7a43596 100644 --- a/web/index.html +++ b/web/index.html @@ -64,39 +64,20 @@ border: none; } - /* Top-right overflow menu (⋯): Lynx bundle URL + legacy version. */ + /* Header-triggered overflow menu: Lynx bundle URL + legacy version. */ .ovf { position: fixed; right: 12px; - top: calc(10px + env(safe-area-inset-top)); + top: calc(62px + env(safe-area-inset-top)); z-index: 2147483647; font: 500 14px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + pointer-events: none; } - .ovf-btn { - width: 36px; - height: 36px; - padding: 0; - border: none; - border-radius: 18px; - background: rgba(255, 255, 255, 0.2); - color: #ffffff; - font-size: 22px; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - -webkit-backdrop-filter: blur(6px); - backdrop-filter: blur(6px); - transition: background 0.2s ease, transform 0.15s ease; - } - .ovf-btn:hover { background: rgba(255, 255, 255, 0.32); } - .ovf-btn:active { transform: scale(0.94); } .ovf-menu { - position: absolute; + position: relative; right: 0; - top: 44px; + top: 0; min-width: 218px; padding: 6px; border-radius: 14px; @@ -307,20 +288,10 @@ })() - +
- -