mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-05 08:33:10 +00:00
Animation Smorgasbord (#15637)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
42
bun.lock
42
bun.lock
@@ -423,17 +423,18 @@
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "^10.2.10",
|
||||
"@storybook/addon-docs": "^10.2.10",
|
||||
"@storybook/addon-links": "^10.2.10",
|
||||
"@storybook/addon-onboarding": "^10.2.10",
|
||||
"@storybook/addon-vitest": "^10.2.10",
|
||||
"@storybook/addon-a11y": "^10.2.13",
|
||||
"@storybook/addon-docs": "^10.2.13",
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-onboarding": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "18.0.25",
|
||||
"react": "18.2.0",
|
||||
"solid-js": "catalog:",
|
||||
"storybook": "^10.2.10",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-solidjs-vite": "^10.0.9",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
@@ -461,6 +462,9 @@
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.3",
|
||||
"motion-dom": "12.34.3",
|
||||
"motion-utils": "12.29.2",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -1803,25 +1807,25 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q=="],
|
||||
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-zuR1n1xgWoieEnr6E5xdTR40BI61IBQahgmsRpTvqRffL3mxAs5aFoORDmA5pZWI2LE9URdMkY85h218ijuLiw=="],
|
||||
|
||||
"@storybook/addon-docs": ["@storybook/addon-docs@10.2.10", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.10", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.10", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ=="],
|
||||
"@storybook/addon-docs": ["@storybook/addon-docs@10.2.13", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.13", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.13", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-puMxpJbt/CuodLIbKDxWrW1ZgADYomfNHWEKp2d2l2eJjp17rADx0h3PABuNbX+YHbJwYcDdqluSnQwMysFEOA=="],
|
||||
|
||||
"@storybook/addon-links": ["@storybook/addon-links@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" }, "optionalPeers": ["react"] }, "sha512-oo9Xx4/2OVJtptXKpqH4ySri7ZuBdiSOXlZVGejEfLa0Jeajlh/KIlREpGvzPPOqUVT7dSddWzBjJmJUyQC3ew=="],
|
||||
"@storybook/addon-links": ["@storybook/addon-links@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" }, "optionalPeers": ["react"] }, "sha512-8wnAomGiHaUpNIc+lOzmazTrebxa64z9rihIbM/Q59vkOImHQNkGp7KP/qNgJA4GPTFtu8+fLjX2qCoAQPM0jQ=="],
|
||||
|
||||
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.10", "", { "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-DkzZQTXHp99SpHMIQ5plbbHcs4EWVzWhLXlW+icA8sBlKo5Bwj540YcOApKbqB0m/OzWprsznwN7Kv4vfvHu4w=="],
|
||||
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.13", "", { "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-kw2GgIY67UR8YXKfuVS0k+mfWL1joNQHeSe5DlDL4+7qbgp9zfV6cRJ199BMdfRAQNMzQoxHgRUcAMAqs3Rkpw=="],
|
||||
|
||||
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.10", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-U2oHw+Ar+Xd06wDTB74VlujhIIW89OHThpJjwgqgM6NWrOC/XLllJ53ILFDyREBkMwpBD7gJQIoQpLEcKBIEhw=="],
|
||||
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.13", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-qQD3xzxc31cQHS0loF9enGWi5sgA6zBTbaJ0HuSUNGO81iwfLSALh8L/1vrD5NfN2vlBeUMTsgv3EkCuLfe9EQ=="],
|
||||
|
||||
"@storybook/builder-vite": ["@storybook/builder-vite@10.2.10", "", { "dependencies": { "@storybook/csf-plugin": "10.2.10", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Wd6CYL7LvRRNiXMz977x9u/qMm7nmMw/7Dow2BybQo+Xbfy1KhVjIoZ/gOiG515zpojSozctNrJUbM0+jH1jwg=="],
|
||||
|
||||
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="],
|
||||
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.13", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.13", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-gUCR7PmyrWYj3dIJJgxOm25dcXFolPIUPmug3z90Aaon7YPXw3pUN+dNDx8KqDJqRK1WDIB4HaefgYZIm5V7iA=="],
|
||||
|
||||
"@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="],
|
||||
|
||||
"@storybook/icons": ["@storybook/icons@2.0.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg=="],
|
||||
|
||||
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.10", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" } }, "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w=="],
|
||||
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.13", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" } }, "sha512-ZSduoB10qTI0V9z22qeULmQLsvTs8d/rtJi03qbVxpPiMRor86AmyAaBrfhGGmWBxWQZpOGQQm6yIT2YLoPs7w=="],
|
||||
|
||||
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
|
||||
|
||||
@@ -3325,6 +3329,12 @@
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"motion": ["motion@12.34.3", "", { "dependencies": { "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -3897,7 +3907,7 @@
|
||||
|
||||
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
|
||||
|
||||
"storybook": ["storybook@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-N4U42qKgzMHS7DjqLz5bY4P7rnvJtYkWFCyKspZr3FhPUuy6CWOae3aYC2BjXkHrdug0Jyta6VxFTuB1tYUKhg=="],
|
||||
"storybook": ["storybook@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ=="],
|
||||
|
||||
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="],
|
||||
|
||||
@@ -4721,6 +4731,8 @@
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
@@ -4887,6 +4899,8 @@
|
||||
|
||||
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.34.3", "", { "dependencies": { "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q=="],
|
||||
|
||||
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
@@ -255,6 +256,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(
|
||||
() => (store.mode === "normal" ? 1 : 0),
|
||||
{ visualDuration: 0.2, bounce: 0 },
|
||||
)
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
@@ -1250,10 +1256,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
||||
class="flex items-center gap-1"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
@@ -1266,6 +1271,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1303,6 +1313,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1355,14 +1370,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<DockTray attach="top">
|
||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Show when={store.mode === "shell"}>
|
||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
@@ -1376,7 +1398,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1394,7 +1422,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{ height: "28px" }}
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
@@ -1423,7 +1457,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: { height: "28px" },
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
@@ -1455,11 +1495,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<RadioGroup
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, createEffect, createMemo } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
@@ -18,6 +19,23 @@ export function SessionComposerRegion(props: {
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
dockOpenVisualDuration?: number
|
||||
dockOpenBounce?: number
|
||||
dockCloseVisualDuration?: number
|
||||
dockCloseBounce?: number
|
||||
drawerExpandVisualDuration?: number
|
||||
drawerExpandBounce?: number
|
||||
drawerCollapseVisualDuration?: number
|
||||
drawerCollapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const params = useParams()
|
||||
const prompt = usePrompt()
|
||||
@@ -43,6 +61,40 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const open = createMemo(() => props.state.dock() && !props.state.closing())
|
||||
const config = createMemo(() =>
|
||||
open()
|
||||
? {
|
||||
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
config,
|
||||
)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const dock = createMemo(() => props.state.dock() || value() > 0.001)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef()
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
@@ -87,30 +139,46 @@ export function SessionComposerRegion(props: {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.dock()}>
|
||||
<Show when={dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
||||
"max-h-[320px]": !props.state.closing(),
|
||||
"max-h-0 pointer-events-none": props.state.closing(),
|
||||
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
|
||||
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * value()}px`,
|
||||
}}
|
||||
>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
/>
|
||||
<div ref={setContentRef}>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
visualDuration={props.visualDuration}
|
||||
bounce={props.bounce}
|
||||
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||
expandBounce={props.drawerExpandBounce}
|
||||
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||
collapseBounce={props.drawerCollapseBounce}
|
||||
subtitleDuration={props.subtitleDuration}
|
||||
subtitleTravel={props.subtitleTravel}
|
||||
subtitleEdge={props.subtitleEdge}
|
||||
countDuration={props.countDuration}
|
||||
countMask={props.countMask}
|
||||
countMaskHeight={props.countMaskHeight}
|
||||
countWidthDuration={props.countWidthDuration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"transition-[margin] duration-[400ms] ease-out": true,
|
||||
"-mt-9": props.state.dock() && !props.state.closing(),
|
||||
"mt-0": !props.state.dock() || props.state.closing(),
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
|
||||
@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionComposerState() {
|
||||
export function createSessionComposerState(
|
||||
options?: {
|
||||
closeMs?: number | (() => number)
|
||||
},
|
||||
) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@@ -96,12 +100,19 @@ export function createSessionComposerState() {
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const closeMs = () => {
|
||||
const value = options?.closeMs
|
||||
if (typeof value === "function") return Math.max(0, value())
|
||||
if (typeof value === "number") return Math.max(0, value)
|
||||
return 400
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore({ dock: false, closing: false })
|
||||
timer = undefined
|
||||
}, 400)
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
createEffect(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
@@ -30,19 +34,35 @@ function dot(status: Todo["status"]) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
|
||||
export function SessionTodoDock(props: {
|
||||
todos: Todo[]
|
||||
title: string
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
expandVisualDuration?: number
|
||||
expandBounce?: number
|
||||
collapseVisualDuration?: number
|
||||
collapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
const toggle = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const total = props.todos.length
|
||||
if (total === 0) return ""
|
||||
const completed = props.todos.filter((todo) => todo.status === "completed").length
|
||||
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
|
||||
})
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
|
||||
|
||||
const active = createMemo(
|
||||
() =>
|
||||
@@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const config = createMemo(() =>
|
||||
store.collapsed
|
||||
? {
|
||||
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.collapseBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.expandBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
data-component="session-todo-dock"
|
||||
classList={{
|
||||
"h-[78px]": store.collapsed,
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
|
||||
<Show when={store.collapsed}>
|
||||
<div class="ml-1 flex-1 min-w-0">
|
||||
<Show when={preview()}>
|
||||
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
|
||||
</Show>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
|
||||
"--tool-motion-mask": `${props.countMask ?? 18}%`,
|
||||
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
<span class="mx-1">of</span>
|
||||
<AnimatedNumber value={total()} />
|
||||
<span> {props.title.toLowerCase()} completed</span>
|
||||
</span>
|
||||
<div
|
||||
data-slot="session-todo-preview"
|
||||
class="ml-1 min-w-0 overflow-hidden"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
"max-width": "100%",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
duration={props.subtitleDuration ?? 600}
|
||||
travel={props.subtitleTravel ?? 25}
|
||||
edge={props.subtitleEdge ?? 17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
data-collapsed={store.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="session-todo-list" hidden={store.collapsed}>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={store.collapsed || off()}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
@@ -171,33 +269,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
}, 250)
|
||||
}}
|
||||
>
|
||||
<For each={props.todos}>
|
||||
<Index each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo.status === "completed"}
|
||||
indeterminate={todo.status === "in_progress"}
|
||||
data-in-progress={todo.status === "in_progress" ? "" : undefined}
|
||||
icon={dot(todo.status)}
|
||||
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
|
||||
checked={todo().status === "completed"}
|
||||
indeterminate={todo().status === "in_progress"}
|
||||
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||
data-state={todo().status}
|
||||
icon={dot(todo().status)}
|
||||
style={{
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition:
|
||||
"opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<TextStrikethrough
|
||||
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||
text={todo().content}
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
classList={{
|
||||
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
|
||||
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
|
||||
}}
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
"text-decoration":
|
||||
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
|
||||
}}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
/>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</Index>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||
|
||||
@@ -10,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
@@ -31,6 +32,9 @@ type MessageComment = {
|
||||
}
|
||||
}
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||
parts.flatMap((part) => {
|
||||
if (part.type !== "text" || !(part as TextPart).synthetic) return []
|
||||
@@ -213,8 +217,34 @@ export function MessageTimeline(props: {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return emptyMessages
|
||||
return sync.data.message[id] ?? emptyMessages
|
||||
})
|
||||
const pending = createMemo(() =>
|
||||
sessionMessages().findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (!parentID) return
|
||||
|
||||
const messages = sessionMessages()
|
||||
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||
if (!message || message.role !== "user") return
|
||||
return message.id
|
||||
})
|
||||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
@@ -651,17 +681,23 @@ export function MessageTimeline(props: {
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={staging.messages()}>
|
||||
{(message) => {
|
||||
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const queued = createMemo(() => {
|
||||
const item = pending()
|
||||
if (!item || active()) return false
|
||||
return messageID > item.id
|
||||
})
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []))
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
props.onRegisterMessage(el, messageID)
|
||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
@@ -701,7 +737,10 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={message.id}
|
||||
messageID={messageID}
|
||||
active={active()}
|
||||
queued={queued()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { defineMain } from "storybook-solidjs-vite"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ui = path.resolve(here, "../../ui")
|
||||
const app = path.resolve(here, "../../app/src")
|
||||
const mocks = path.resolve(here, "./mocks")
|
||||
|
||||
export default defineMain({
|
||||
framework: {
|
||||
@@ -21,15 +24,41 @@ export default defineMain({
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
|
||||
return mergeConfig(config, {
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
|
||||
alias: [
|
||||
{ find: "@solidjs/router", replacement: path.resolve(mocks, "solid-router.tsx") },
|
||||
{ find: /^@\/context\/local$/, replacement: path.resolve(mocks, "app/context/local.ts") },
|
||||
{ find: /^@\/context\/file$/, replacement: path.resolve(mocks, "app/context/file.ts") },
|
||||
{ find: /^@\/context\/prompt$/, replacement: path.resolve(mocks, "app/context/prompt.ts") },
|
||||
{ find: /^@\/context\/layout$/, replacement: path.resolve(mocks, "app/context/layout.ts") },
|
||||
{ find: /^@\/context\/sdk$/, replacement: path.resolve(mocks, "app/context/sdk.ts") },
|
||||
{ find: /^@\/context\/sync$/, replacement: path.resolve(mocks, "app/context/sync.ts") },
|
||||
{ find: /^@\/context\/comments$/, replacement: path.resolve(mocks, "app/context/comments.ts") },
|
||||
{ find: /^@\/context\/command$/, replacement: path.resolve(mocks, "app/context/command.ts") },
|
||||
{ find: /^@\/context\/permission$/, replacement: path.resolve(mocks, "app/context/permission.ts") },
|
||||
{ find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") },
|
||||
{ find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") },
|
||||
{ find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") },
|
||||
{ find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") },
|
||||
{
|
||||
find: /^@\/components\/dialog-select-model$/,
|
||||
replacement: path.resolve(mocks, "app/components/dialog-select-model.tsx"),
|
||||
},
|
||||
{
|
||||
find: /^@\/components\/dialog-select-model-unpaid$/,
|
||||
replacement: path.resolve(mocks, "app/components/dialog-select-model-unpaid.tsx"),
|
||||
},
|
||||
{ find: "@", replacement: app },
|
||||
],
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [searchForWorkspaceRoot(process.cwd()), ui],
|
||||
allow: [searchForWorkspaceRoot(process.cwd()), ui, app, mocks],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function DialogSelectModelUnpaid() {
|
||||
return <div data-component="dialog-select-model-unpaid">Select model</div>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record<string, unknown>; children: any }) {
|
||||
const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"])
|
||||
const Trigger = local.triggerAs
|
||||
return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
|
||||
}
|
||||
22
packages/storybook/.storybook/mocks/app/context/command.ts
Normal file
22
packages/storybook/.storybook/mocks/app/context/command.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const keybinds: Record<string, string> = {
|
||||
"file.attach": "mod+u",
|
||||
"prompt.mode.shell": "mod+shift+x",
|
||||
"prompt.mode.normal": "mod+shift+e",
|
||||
"permissions.autoaccept": "mod+shift+a",
|
||||
"agent.cycle": "mod+.",
|
||||
"model.choose": "mod+m",
|
||||
"model.variant.cycle": "mod+shift+m",
|
||||
}
|
||||
|
||||
export function useCommand() {
|
||||
return {
|
||||
options: [],
|
||||
register() {
|
||||
return () => undefined
|
||||
},
|
||||
trigger() {},
|
||||
keybind(id: string) {
|
||||
return keybinds[id]
|
||||
},
|
||||
}
|
||||
}
|
||||
34
packages/storybook/.storybook/mocks/app/context/comments.ts
Normal file
34
packages/storybook/.storybook/mocks/app/context/comments.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
type Comment = {
|
||||
id: string
|
||||
file: string
|
||||
selection: { start: number; end: number }
|
||||
comment: string
|
||||
time: number
|
||||
}
|
||||
|
||||
const [list, setList] = createSignal<Comment[]>([])
|
||||
const [focus, setFocus] = createSignal<{ file: string; id: string } | null>(null)
|
||||
const [active, setActive] = createSignal<{ file: string; id: string } | null>(null)
|
||||
|
||||
export function useComments() {
|
||||
return {
|
||||
all: list,
|
||||
replace(next: Comment[]) {
|
||||
setList(next)
|
||||
},
|
||||
remove(file: string, id: string) {
|
||||
setList((current) => current.filter((item) => !(item.file === file && item.id === id)))
|
||||
},
|
||||
clear() {
|
||||
setList([])
|
||||
setFocus(null)
|
||||
setActive(null)
|
||||
},
|
||||
focus,
|
||||
setFocus,
|
||||
active,
|
||||
setActive,
|
||||
}
|
||||
}
|
||||
47
packages/storybook/.storybook/mocks/app/context/file.ts
Normal file
47
packages/storybook/.storybook/mocks/app/context/file.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
startChar: number
|
||||
endLine: number
|
||||
endChar: number
|
||||
}
|
||||
|
||||
export type SelectedLineRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export function selectionFromLines(selection?: SelectedLineRange): FileSelection | undefined {
|
||||
if (!selection) return undefined
|
||||
return {
|
||||
startLine: selection.start,
|
||||
startChar: 0,
|
||||
endLine: selection.end,
|
||||
endChar: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const pool = [
|
||||
"src/session/timeline.tsx",
|
||||
"src/session/composer.tsx",
|
||||
"src/components/prompt-input.tsx",
|
||||
"src/components/session-todo-dock.tsx",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
export function useFile() {
|
||||
return {
|
||||
tab(path: string) {
|
||||
return `file:${path}`
|
||||
},
|
||||
pathFromTab(tab: string) {
|
||||
if (!tab.startsWith("file:")) return ""
|
||||
return tab.slice(5)
|
||||
},
|
||||
load: async () => undefined,
|
||||
async searchFilesAndDirectories(query: string) {
|
||||
const text = query.trim().toLowerCase()
|
||||
if (!text) return pool
|
||||
return pool.filter((path) => path.toLowerCase().includes(text))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const provider = {
|
||||
all: [
|
||||
{
|
||||
id: "anthropic",
|
||||
models: {
|
||||
"claude-3-7-sonnet": {
|
||||
id: "claude-3-7-sonnet",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
cost: { input: 1, output: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["anthropic"],
|
||||
default: { anthropic: "claude-3-7-sonnet" },
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
todo: {} as Record<string, any[]>,
|
||||
provider,
|
||||
session: [] as any[],
|
||||
config: { permission: {} },
|
||||
})
|
||||
|
||||
export function useGlobalSync() {
|
||||
return {
|
||||
data: {
|
||||
provider,
|
||||
session_todo: store.todo,
|
||||
},
|
||||
child() {
|
||||
return [store, setStore] as const
|
||||
},
|
||||
todo: {
|
||||
set(sessionID: string, todos: any[]) {
|
||||
setStore("todo", sessionID, todos)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
74
packages/storybook/.storybook/mocks/app/context/language.ts
Normal file
74
packages/storybook/.storybook/mocks/app/context/language.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
const dict: Record<string, string> = {
|
||||
"session.todo.title": "Todos",
|
||||
"session.todo.collapse": "Collapse todos",
|
||||
"session.todo.expand": "Expand todos",
|
||||
"prompt.loading": "Loading prompt...",
|
||||
"prompt.placeholder.normal": "Ask anything...",
|
||||
"prompt.placeholder.simple": "Ask anything...",
|
||||
"prompt.placeholder.shell": "Run a shell command...",
|
||||
"prompt.placeholder.summarizeComment": "Summarize this comment",
|
||||
"prompt.placeholder.summarizeComments": "Summarize these comments",
|
||||
"prompt.action.attachFile": "Attach file",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
"prompt.dropzone.label": "Drop image to attach",
|
||||
"prompt.dropzone.file.label": "Drop file to attach",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"dialog.model.select.title": "Select model",
|
||||
"common.default": "Default",
|
||||
"common.key.esc": "Esc",
|
||||
"command.category.file": "File",
|
||||
"command.category.session": "Session",
|
||||
"command.agent.cycle": "Cycle agent",
|
||||
"command.model.choose": "Choose model",
|
||||
"command.model.variant.cycle": "Cycle model variant",
|
||||
"command.prompt.mode.shell": "Switch to shell mode",
|
||||
"command.prompt.mode.normal": "Switch to prompt mode",
|
||||
"command.permissions.autoaccept.enable": "Enable auto-accept",
|
||||
"command.permissions.autoaccept.disable": "Disable auto-accept",
|
||||
"prompt.example.1": "Refactor this function and keep behavior the same",
|
||||
"prompt.example.2": "Find the root cause of this error",
|
||||
"prompt.example.3": "Write tests for this module",
|
||||
"prompt.example.4": "Explain this diff",
|
||||
"prompt.example.5": "Optimize this query",
|
||||
"prompt.example.6": "Clean up this component",
|
||||
"prompt.example.7": "Summarize the recent changes",
|
||||
"prompt.example.8": "Add accessibility checks",
|
||||
"prompt.example.9": "Review this API design",
|
||||
"prompt.example.10": "Generate migration notes",
|
||||
"prompt.example.11": "Patch this bug",
|
||||
"prompt.example.12": "Make this animation smoother",
|
||||
"prompt.example.13": "Improve error handling",
|
||||
"prompt.example.14": "Document this feature",
|
||||
"prompt.example.15": "Refine these styles",
|
||||
"prompt.example.16": "Check edge cases",
|
||||
"prompt.example.17": "Help me write a commit message",
|
||||
"prompt.example.18": "Reduce re-renders in this component",
|
||||
"prompt.example.19": "Verify keyboard navigation",
|
||||
"prompt.example.20": "Make this copy clearer",
|
||||
"prompt.example.21": "Add telemetry for this flow",
|
||||
"prompt.example.22": "Compare these two implementations",
|
||||
"prompt.example.23": "Create a minimal reproduction",
|
||||
"prompt.example.24": "Suggest naming improvements",
|
||||
"prompt.example.25": "What should we test next?",
|
||||
}
|
||||
|
||||
function render(template: string, params?: Record<string, unknown>) {
|
||||
if (!params) return template
|
||||
return template.replace(/\{\{([^}]+)\}\}/g, (_, key: string) => {
|
||||
const value = params[key.trim()]
|
||||
if (value === undefined || value === null) return ""
|
||||
return String(value)
|
||||
})
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return {
|
||||
locale: () => "en" as const,
|
||||
t(key: string, params?: Record<string, unknown>) {
|
||||
return render(dict[key] ?? key, params)
|
||||
},
|
||||
}
|
||||
}
|
||||
41
packages/storybook/.storybook/mocks/app/context/layout.ts
Normal file
41
packages/storybook/.storybook/mocks/app/context/layout.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [all, setAll] = createSignal<string[]>([])
|
||||
const [active, setActive] = createSignal<string | undefined>(undefined)
|
||||
const [reviewOpen, setReviewOpen] = createSignal(false)
|
||||
|
||||
const tabs = {
|
||||
all,
|
||||
active,
|
||||
open(tab: string) {
|
||||
setAll((current) => (current.includes(tab) ? current : [...current, tab]))
|
||||
},
|
||||
setActive(tab: string) {
|
||||
if (!all().includes(tab)) {
|
||||
tabs.open(tab)
|
||||
}
|
||||
setActive(tab)
|
||||
},
|
||||
}
|
||||
|
||||
const view = {
|
||||
reviewPanel: {
|
||||
opened: reviewOpen,
|
||||
open() {
|
||||
setReviewOpen(true)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function useLayout() {
|
||||
return {
|
||||
tabs: () => tabs,
|
||||
view: () => view,
|
||||
fileTree: {
|
||||
setTab() {},
|
||||
},
|
||||
handoff: {
|
||||
setTabs() {},
|
||||
},
|
||||
}
|
||||
}
|
||||
41
packages/storybook/.storybook/mocks/app/context/local.ts
Normal file
41
packages/storybook/.storybook/mocks/app/context/local.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const model = {
|
||||
id: "claude-3-7-sonnet",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
provider: { id: "anthropic" },
|
||||
variants: { fast: {}, thinking: {} },
|
||||
}
|
||||
|
||||
const agents = [{ name: "build" }, { name: "review" }, { name: "plan" }]
|
||||
|
||||
const [agent, setAgent] = createSignal(agents[0].name)
|
||||
const [variant, setVariant] = createSignal<string | undefined>(undefined)
|
||||
|
||||
export function useLocal() {
|
||||
return {
|
||||
slug: () => "c3Rvcnk=",
|
||||
agent: {
|
||||
list: () => agents,
|
||||
current: () => agents.find((item) => item.name === agent()) ?? agents[0],
|
||||
set(value?: string) {
|
||||
if (!value) {
|
||||
setAgent(agents[0].name)
|
||||
return
|
||||
}
|
||||
const hit = agents.find((item) => item.name === value)
|
||||
setAgent(hit?.name ?? agents[0].name)
|
||||
},
|
||||
},
|
||||
model: {
|
||||
current: () => model,
|
||||
variant: {
|
||||
list: () => Object.keys(model.variants),
|
||||
current: () => variant(),
|
||||
set(next?: string) {
|
||||
setVariant(next)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const accepted = new Set<string>()
|
||||
|
||||
function key(sessionID: string, directory?: string) {
|
||||
return `${directory ?? ""}:${sessionID}`
|
||||
}
|
||||
|
||||
export function usePermission() {
|
||||
return {
|
||||
autoResponds() {
|
||||
return false
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return accepted.has(key(sessionID, directory))
|
||||
},
|
||||
toggleAutoAccept(sessionID: string, directory?: string) {
|
||||
const next = key(sessionID, directory)
|
||||
if (accepted.has(next)) {
|
||||
accepted.delete(next)
|
||||
return
|
||||
}
|
||||
accepted.add(next)
|
||||
},
|
||||
}
|
||||
}
|
||||
16
packages/storybook/.storybook/mocks/app/context/platform.ts
Normal file
16
packages/storybook/.storybook/mocks/app/context/platform.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Platform } from "../../../../../app/src/context/platform"
|
||||
|
||||
const value: Platform = {
|
||||
platform: "web",
|
||||
openLink() {},
|
||||
restart: async () => {},
|
||||
back() {},
|
||||
forward() {},
|
||||
notify: async () => {},
|
||||
fetch: globalThis.fetch.bind(globalThis),
|
||||
parseMarkdown: async (markdown: string) => markdown,
|
||||
}
|
||||
|
||||
export function usePlatform() {
|
||||
return value
|
||||
}
|
||||
117
packages/storybook/.storybook/mocks/app/context/prompt.ts
Normal file
117
packages/storybook/.storybook/mocks/app/context/prompt.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
type ContextItem = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: { startLine: number; startChar: number; endLine: number; endChar: number }
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
if (part.type === "file") return { ...part }
|
||||
return { ...part }
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt) {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
export function isPromptEqual(a: Prompt, b: Prompt) {
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
|
||||
}
|
||||
|
||||
let index = 0
|
||||
const [prompt, setPrompt] = createSignal<Prompt>(clonePrompt(DEFAULT_PROMPT))
|
||||
const [cursor, setCursor] = createSignal<number>(0)
|
||||
const [items, setItems] = createSignal<ContextItem[]>([])
|
||||
|
||||
const withKey = (item: Omit<ContextItem, "key"> & { key?: string }): ContextItem => ({
|
||||
...item,
|
||||
key: item.key ?? `ctx:${++index}`,
|
||||
})
|
||||
|
||||
export function usePrompt() {
|
||||
return {
|
||||
ready: () => true,
|
||||
current: prompt,
|
||||
cursor,
|
||||
dirty: () => !isPromptEqual(prompt(), DEFAULT_PROMPT),
|
||||
set(next: Prompt, cursorPosition?: number) {
|
||||
setPrompt(clonePrompt(next))
|
||||
if (cursorPosition !== undefined) setCursor(cursorPosition)
|
||||
},
|
||||
reset() {
|
||||
setPrompt(clonePrompt(DEFAULT_PROMPT))
|
||||
setCursor(0)
|
||||
setItems((current) => current.filter((item) => !!item.comment?.trim()))
|
||||
},
|
||||
context: {
|
||||
items,
|
||||
add(item: Omit<ContextItem, "key"> & { key?: string }) {
|
||||
const next = withKey(item)
|
||||
if (items().some((current) => current.key === next.key)) return
|
||||
setItems((current) => [...current, next])
|
||||
},
|
||||
remove(key: string) {
|
||||
setItems((current) => current.filter((item) => item.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setItems((current) =>
|
||||
current.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<ContextItem>) {
|
||||
setItems((current) =>
|
||||
current.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
return withKey({ ...item, ...next })
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(next: Array<Omit<ContextItem, "key"> & { key?: string }>) {
|
||||
const nonComment = items().filter((item) => !item.comment?.trim())
|
||||
setItems([...nonComment, ...next.map(withKey)])
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
25
packages/storybook/.storybook/mocks/app/context/sdk.ts
Normal file
25
packages/storybook/.storybook/mocks/app/context/sdk.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const make = (directory: string) => ({
|
||||
session: {
|
||||
create: async () => ({ data: { id: "story-session" } }),
|
||||
prompt: async () => ({ data: undefined }),
|
||||
shell: async () => ({ data: undefined }),
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/worktree-1` } }),
|
||||
},
|
||||
})
|
||||
|
||||
const root = "/tmp/story"
|
||||
|
||||
export function useSDK() {
|
||||
return {
|
||||
directory: root,
|
||||
url: "http://localhost:4096",
|
||||
client: make(root),
|
||||
createClient(input: { directory: string }) {
|
||||
return make(input.directory)
|
||||
},
|
||||
}
|
||||
}
|
||||
32
packages/storybook/.storybook/mocks/app/context/sync.ts
Normal file
32
packages/storybook/.storybook/mocks/app/context/sync.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const [data, setData] = createStore({
|
||||
session: [] as Array<{ id: string; parentID?: string }>,
|
||||
permission: {} as Record<string, Array<{ id: string; sessionID: string; permission: string; patterns: string[] }>>,
|
||||
question: {} as Record<string, Array<{ id: string; questions: unknown[] }>>,
|
||||
session_diff: {} as Record<string, Array<{ file: string }>>,
|
||||
message: {
|
||||
"story-session": [] as Array<{ id: string; role: string }>,
|
||||
} as Record<string, Array<{ id: string; role: string }>>,
|
||||
session_status: {} as Record<string, { type: "idle" | "busy" }>,
|
||||
agent: [{ name: "build", mode: "task", hidden: false }],
|
||||
command: [{ name: "fix", description: "Run fix command", source: "project" }],
|
||||
})
|
||||
|
||||
export function useSync() {
|
||||
return {
|
||||
data,
|
||||
set(...input: unknown[]) {
|
||||
;(setData as (...args: unknown[]) => void)(...input)
|
||||
},
|
||||
session: {
|
||||
get(id: string) {
|
||||
return { id }
|
||||
},
|
||||
optimistic: {
|
||||
add() {},
|
||||
remove() {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const model_id = "claude-3-7-sonnet"
|
||||
|
||||
const provider = {
|
||||
id: "anthropic",
|
||||
models: {
|
||||
[model_id]: {
|
||||
id: model_id,
|
||||
name: "Claude 3.7 Sonnet",
|
||||
cost: { input: 1, output: 1 },
|
||||
variants: { fast: {}, thinking: {} },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function useProviders() {
|
||||
return {
|
||||
all: () => [provider],
|
||||
default: () => ({ anthropic: model_id }),
|
||||
connected: () => [provider],
|
||||
paid: () => [provider],
|
||||
popular: () => [provider],
|
||||
}
|
||||
}
|
||||
20
packages/storybook/.storybook/mocks/solid-router.tsx
Normal file
20
packages/storybook/.storybook/mocks/solid-router.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ParentProps } from "solid-js"
|
||||
|
||||
export function useParams() {
|
||||
return {
|
||||
dir: "c3Rvcnk=",
|
||||
id: "story-session",
|
||||
}
|
||||
}
|
||||
|
||||
export function useNavigate() {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
export function MemoryRouter(props: ParentProps) {
|
||||
return props.children
|
||||
}
|
||||
|
||||
export function Route(props: ParentProps) {
|
||||
return props.children
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import "@opencode-ai/ui/styles"
|
||||
import "@opencode-ai/ui/styles/tailwind"
|
||||
|
||||
import { createEffect, onCleanup, onMount } from "solid-js"
|
||||
import addonA11y from "@storybook/addon-a11y"
|
||||
@@ -7,12 +7,8 @@ import { MetaProvider } from "@solidjs/meta"
|
||||
import { addons } from "storybook/preview-api"
|
||||
import { GLOBALS_UPDATED } from "storybook/internal/core-events"
|
||||
import { createJSXDecorator, definePreview } from "storybook-solidjs-vite"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { Diff } from "@opencode-ai/ui/diff"
|
||||
import { ThemeProvider, useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
|
||||
@@ -58,20 +54,16 @@ const frame = createJSXDecorator((Story, context) => {
|
||||
<Scheme value={scheme} />
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
<div
|
||||
style={{
|
||||
"min-height": "100vh",
|
||||
padding: "24px",
|
||||
"background-color": "var(--background-base)",
|
||||
color: "var(--text-base)",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
<div
|
||||
style={{
|
||||
"min-height": "100vh",
|
||||
padding: "24px",
|
||||
"background-color": "var(--background-base)",
|
||||
color: "var(--text-base)",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -8,19 +8,20 @@
|
||||
"build": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "^10.2.10",
|
||||
"@storybook/addon-docs": "^10.2.10",
|
||||
"@storybook/addon-links": "^10.2.10",
|
||||
"@storybook/addon-onboarding": "^10.2.10",
|
||||
"@storybook/addon-vitest": "^10.2.10",
|
||||
"@storybook/addon-a11y": "^10.2.13",
|
||||
"@storybook/addon-docs": "^10.2.13",
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-onboarding": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "18.0.25",
|
||||
"react": "18.2.0",
|
||||
"solid-js": "catalog:",
|
||||
"storybook": "^10.2.10",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-solidjs-vite": "^10.0.9",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.3",
|
||||
"motion-dom": "12.34.3",
|
||||
"motion-utils": "12.29.2",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
75
packages/ui/src/components/animated-number.css
Normal file
75
packages/ui/src/components/animated-number.css
Normal file
@@ -0,0 +1,75 @@
|
||||
[data-component="animated-number"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
line-height: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
[data-slot="animated-number-value"] {
|
||||
display: inline-flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
line-height: inherit;
|
||||
width: var(--animated-number-width, 1ch);
|
||||
overflow: hidden;
|
||||
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="animated-number-digit"] {
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
overflow: hidden;
|
||||
vertical-align: baseline;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--tool-motion-mask, 18%),
|
||||
#000 calc(100% - var(--tool-motion-mask, 18%)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--tool-motion-mask, 18%),
|
||||
#000 calc(100% - var(--tool-motion-mask, 18%)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-strip"] {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
|
||||
transition-property: transform;
|
||||
transition-duration: var(--animated-number-duration, 560ms);
|
||||
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="animated-number-strip"][data-animating="false"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-cell"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1ch;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="animated-number"] [data-slot="animated-number-value"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-component="animated-number"] [data-slot="animated-number-strip"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
100
packages/ui/src/components/animated-number.tsx
Normal file
100
packages/ui/src/components/animated-number.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
|
||||
|
||||
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
|
||||
const DURATION = 600
|
||||
|
||||
function normalize(value: number) {
|
||||
return ((value % 10) + 10) % 10
|
||||
}
|
||||
|
||||
function spin(from: number, to: number, direction: 1 | -1) {
|
||||
if (from === to) return 0
|
||||
if (direction > 0) return (to - from + 10) % 10
|
||||
return -((from - to + 10) % 10)
|
||||
}
|
||||
|
||||
function Digit(props: { value: number; direction: 1 | -1 }) {
|
||||
const [step, setStep] = createSignal(props.value + 10)
|
||||
const [animating, setAnimating] = createSignal(false)
|
||||
let last = props.value
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
(next) => {
|
||||
const delta = spin(last, next, props.direction)
|
||||
last = next
|
||||
if (!delta) {
|
||||
setAnimating(false)
|
||||
setStep(next + 10)
|
||||
return
|
||||
}
|
||||
|
||||
setAnimating(true)
|
||||
setStep((value) => value + delta)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<span data-slot="animated-number-digit">
|
||||
<span
|
||||
data-slot="animated-number-strip"
|
||||
data-animating={animating() ? "true" : "false"}
|
||||
onTransitionEnd={() => {
|
||||
setAnimating(false)
|
||||
setStep((value) => normalize(value) + 10)
|
||||
}}
|
||||
style={{
|
||||
"--animated-number-offset": `${step()}`,
|
||||
"--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
|
||||
}}
|
||||
>
|
||||
<For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedNumber(props: { value: number; class?: string }) {
|
||||
const target = createMemo(() => {
|
||||
if (!Number.isFinite(props.value)) return 0
|
||||
return Math.max(0, Math.round(props.value))
|
||||
})
|
||||
|
||||
const [value, setValue] = createSignal(target())
|
||||
const [direction, setDirection] = createSignal<1 | -1>(1)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
target,
|
||||
(next) => {
|
||||
const current = value()
|
||||
if (next === current) return
|
||||
|
||||
setDirection(next > current ? 1 : -1)
|
||||
setValue(next)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const label = createMemo(() => value().toString())
|
||||
const digits = createMemo(() =>
|
||||
Array.from(label(), (char) => {
|
||||
const code = char.charCodeAt(0) - 48
|
||||
if (code < 0 || code > 9) return 0
|
||||
return code
|
||||
}).reverse(),
|
||||
)
|
||||
const width = createMemo(() => `${digits().length}ch`)
|
||||
|
||||
return (
|
||||
<span data-component="animated-number" class={props.class} aria-label={label()}>
|
||||
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
|
||||
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { animate, type AnimationPlaybackControls } from "motion"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import type { IconProps } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
@@ -29,9 +30,12 @@ export interface BasicToolProps {
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
locked?: boolean
|
||||
animated?: boolean
|
||||
onSubtitleClick?: () => void
|
||||
}
|
||||
|
||||
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
|
||||
const [ready, setReady] = createSignal(open())
|
||||
@@ -73,6 +77,38 @@ export function BasicTool(props: BasicToolProps) {
|
||||
),
|
||||
)
|
||||
|
||||
// Animated height for collapsible open/close
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let heightAnim: AnimationPlaybackControls | undefined
|
||||
const initialOpen = open()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!props.animated || !contentRef) return
|
||||
heightAnim?.stop()
|
||||
if (isOpen) {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
|
||||
heightAnim.finished.then(() => {
|
||||
if (!contentRef || !open()) return
|
||||
contentRef.style.overflow = "visible"
|
||||
contentRef.style.height = "auto"
|
||||
})
|
||||
} else {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
heightAnim?.stop()
|
||||
})
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (pending()) return
|
||||
if (props.locked && !value) return
|
||||
@@ -96,9 +132,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
<Show when={pending()} fallback={trigger().title}>
|
||||
<TextShimmer text={trigger().title} />
|
||||
</Show>
|
||||
<TextShimmer text={trigger().title} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<Show when={trigger().subtitle}>
|
||||
@@ -147,7 +181,20 @@ export function BasicTool(props: BasicToolProps) {
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={props.children && !props.hideDetails}>
|
||||
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-slot="collapsible-content"
|
||||
data-animated
|
||||
style={{
|
||||
height: initialOpen ? "auto" : "0px",
|
||||
overflow: initialOpen ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.animated && props.children && !props.hideDetails}>
|
||||
<Collapsible.Content>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</Collapsible.Content>
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
transition:
|
||||
border-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
background-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
box-shadow 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
/* background-color: var(--surface-weak); */
|
||||
}
|
||||
|
||||
@@ -39,6 +43,10 @@
|
||||
height: 100%;
|
||||
color: var(--icon-base);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transition:
|
||||
opacity 180ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
transform 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
/* [data-slot="checkbox-checkbox-content"] { */
|
||||
@@ -100,6 +108,7 @@
|
||||
&[data-checked] [data-slot="checkbox-checkbox-indicator"],
|
||||
&[data-indeterminate] [data-slot="checkbox-checkbox-indicator"] {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./code"
|
||||
import { create } from "../storybook/scaffold"
|
||||
import { code } from "../storybook/fixtures"
|
||||
|
||||
const docs = `### Overview
|
||||
Syntax-highlighted code viewer with selection support and large-file virtualization.
|
||||
|
||||
Use alongside \`LineComment\` and \`Diff\` in review workflows.
|
||||
|
||||
### API
|
||||
- Required: \`file\` with file name + contents.
|
||||
- Optional: \`language\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`.
|
||||
- Optional callbacks: \`onRendered\`, \`onLineSelectionEnd\`.
|
||||
|
||||
### Variants and states
|
||||
- Supports large-file virtualization automatically.
|
||||
|
||||
### Behavior
|
||||
- Re-renders when \`file\` or rendering options change.
|
||||
- Optional line selection integrates with selection callbacks.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm keyboard find and selection behavior.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="code"\` and Pierre CSS variables from \`styleVariables\`.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/Code",
|
||||
mod,
|
||||
args: {
|
||||
file: code,
|
||||
language: "ts",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/Code",
|
||||
id: "components-code",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const SelectedLines = {
|
||||
args: {
|
||||
enableLineSelection: true,
|
||||
selectedLines: { start: 2, end: 4 },
|
||||
},
|
||||
}
|
||||
|
||||
export const CommentedLines = {
|
||||
args: {
|
||||
commentedLines: [
|
||||
{ start: 1, end: 1 },
|
||||
{ start: 5, end: 6 },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { preloadMultiFileDiff } from "@pierre/diffs/ssr"
|
||||
import { createResource, Show } from "solid-js"
|
||||
import * as mod from "./diff-ssr"
|
||||
import { createDefaultOptions } from "../pierre"
|
||||
import { WorkerPoolProvider } from "../context/worker-pool"
|
||||
import { getWorkerPools } from "../pierre/worker"
|
||||
import { diff } from "../storybook/fixtures"
|
||||
|
||||
const docs = `### Overview
|
||||
Server-rendered diff hydration component for preloaded Pierre diff output.
|
||||
|
||||
Use alongside server routes that preload diffs.
|
||||
Pair with \`DiffChanges\` for summaries.
|
||||
|
||||
### API
|
||||
- Required: \`before\`, \`after\`, and \`preloadedDiff\` from \`preloadMultiFileDiff\`.
|
||||
- Optional: \`diffStyle\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`.
|
||||
|
||||
### Variants and states
|
||||
- Unified/split styles (preloaded must match the style used during preload).
|
||||
|
||||
### Behavior
|
||||
- Hydrates pre-rendered diff HTML into a live diff instance.
|
||||
- Requires a worker pool provider for syntax highlighting.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm keyboard behavior from the Pierre diff engine.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="diff"\` with Pierre CSS variables and theme tokens.
|
||||
|
||||
`
|
||||
|
||||
const load = async () => {
|
||||
return preloadMultiFileDiff({
|
||||
oldFile: diff.before,
|
||||
newFile: diff.after,
|
||||
options: createDefaultOptions("unified"),
|
||||
})
|
||||
}
|
||||
|
||||
const loadSplit = async () => {
|
||||
return preloadMultiFileDiff({
|
||||
oldFile: diff.before,
|
||||
newFile: diff.after,
|
||||
options: createDefaultOptions("split"),
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "UI/DiffSSR",
|
||||
id: "components-diff-ssr",
|
||||
component: mod.Diff,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => {
|
||||
const [data] = createResource(load)
|
||||
return (
|
||||
<WorkerPoolProvider pools={getWorkerPools()}>
|
||||
<Show when={data()} fallback={<div>Loading pre-rendered diff...</div>}>
|
||||
{(preloaded) => (
|
||||
<div style={{ "max-width": "960px" }}>
|
||||
<mod.Diff before={diff.before} after={diff.after} diffStyle="unified" preloadedDiff={preloaded()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</WorkerPoolProvider>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Split = {
|
||||
render: () => {
|
||||
const [data] = createResource(loadSplit)
|
||||
return (
|
||||
<WorkerPoolProvider pools={getWorkerPools()}>
|
||||
<Show when={data()} fallback={<div>Loading pre-rendered diff...</div>}>
|
||||
{(preloaded) => (
|
||||
<div style={{ "max-width": "960px" }}>
|
||||
<mod.Diff before={diff.before} after={diff.after} diffStyle="split" preloadedDiff={preloaded()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</WorkerPoolProvider>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./diff"
|
||||
import { create } from "../storybook/scaffold"
|
||||
import { diff } from "../storybook/fixtures"
|
||||
|
||||
const docs = `### Overview
|
||||
Render a code diff with OpenCode styling using the Pierre diff engine.
|
||||
|
||||
Pair with \`DiffChanges\` for summary counts.
|
||||
Use \`LineComment\` or external UI for annotation workflows.
|
||||
|
||||
### API
|
||||
- Required: \`before\` and \`after\` file contents (name + contents).
|
||||
- Optional: \`diffStyle\` ("unified" | "split"), \`annotations\`, \`selectedLines\`, \`commentedLines\`.
|
||||
- Optional interaction: \`enableLineSelection\`, \`onLineSelectionEnd\`.
|
||||
- Passes through Pierre FileDiff options (see component source).
|
||||
|
||||
### Variants and states
|
||||
- Unified and split diff styles.
|
||||
- Optional line selection + commented line highlighting.
|
||||
|
||||
### Behavior
|
||||
- Re-renders when \`before\`/\`after\` or diff options change.
|
||||
- Line selection uses mouse drag/selection when enabled.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm keyboard behavior from the Pierre diff engine.
|
||||
- Provide surrounding labels or headings when used as a standalone view.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="diff"\` and Pierre CSS variables from \`styleVariables\`.
|
||||
- Colors derive from theme tokens (diff add/delete, background, text).
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/Diff",
|
||||
mod,
|
||||
args: {
|
||||
before: diff.before,
|
||||
after: diff.after,
|
||||
diffStyle: "unified",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/Diff",
|
||||
id: "components-diff",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
diffStyle: {
|
||||
control: "select",
|
||||
options: ["unified", "split"],
|
||||
},
|
||||
enableLineSelection: {
|
||||
control: "boolean",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Unified = story.Basic
|
||||
|
||||
export const Split = {
|
||||
args: {
|
||||
diffStyle: "split",
|
||||
},
|
||||
}
|
||||
|
||||
export const Selectable = {
|
||||
args: {
|
||||
enableLineSelection: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const SelectedLines = {
|
||||
args: {
|
||||
selectedLines: { start: 2, end: 4 },
|
||||
},
|
||||
}
|
||||
|
||||
export const CommentedLines = {
|
||||
args: {
|
||||
commentedLines: [
|
||||
{ start: 1, end: 1 },
|
||||
{ start: 4, end: 4 },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -608,29 +608,8 @@
|
||||
cursor: pointer;
|
||||
|
||||
[data-slot="context-tool-group-title"] {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-label"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-summary"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
onCleanup,
|
||||
@@ -47,6 +48,42 @@ import { checksum } from "@opencode-ai/util/encode"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { AnimatedCountList } from "./tool-count-summary"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
import { animate } from "motion"
|
||||
|
||||
function ShellSubmessage(props: { text: string; animate?: boolean }) {
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
let valueRef: HTMLSpanElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
if (!props.animate) return
|
||||
requestAnimationFrame(() => {
|
||||
if (widthRef) {
|
||||
animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
|
||||
}
|
||||
if (valueRef) {
|
||||
animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<span data-component="shell-submessage">
|
||||
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<span
|
||||
ref={valueRef}
|
||||
data-slot="shell-submessage-value"
|
||||
style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface Diagnostic {
|
||||
range: {
|
||||
@@ -272,6 +309,102 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
type PartGroup =
|
||||
| {
|
||||
key: string
|
||||
type: "part"
|
||||
ref: PartRef
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
type: "context"
|
||||
refs: PartRef[]
|
||||
}
|
||||
|
||||
function sameRef(a: PartRef, b: PartRef) {
|
||||
return a.messageID === b.messageID && a.partID === b.partID
|
||||
}
|
||||
|
||||
function sameGroup(a: PartGroup, b: PartGroup) {
|
||||
if (a === b) return true
|
||||
if (a.key !== b.key) return false
|
||||
if (a.type !== b.type) return false
|
||||
if (a.type === "part") {
|
||||
if (b.type !== "part") return false
|
||||
return sameRef(a.ref, b.ref)
|
||||
}
|
||||
if (b.type !== "context") return false
|
||||
if (a.refs.length !== b.refs.length) return false
|
||||
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
|
||||
}
|
||||
|
||||
function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((item, i) => sameGroup(item, b[i]!))
|
||||
}
|
||||
|
||||
function groupParts(parts: { messageID: string; part: PartType }[]) {
|
||||
const result: PartGroup[] = []
|
||||
let start = -1
|
||||
|
||||
const flush = (end: number) => {
|
||||
if (start < 0) return
|
||||
const first = parts[start]
|
||||
const last = parts[end]
|
||||
if (!first || !last) {
|
||||
start = -1
|
||||
return
|
||||
}
|
||||
result.push({
|
||||
key: `context:${first.part.id}`,
|
||||
type: "context",
|
||||
refs: parts.slice(start, end + 1).map((item) => ({
|
||||
messageID: item.messageID,
|
||||
partID: item.part.id,
|
||||
})),
|
||||
})
|
||||
start = -1
|
||||
}
|
||||
|
||||
parts.forEach((item, index) => {
|
||||
if (isContextGroupTool(item.part)) {
|
||||
if (start < 0) start = index
|
||||
return
|
||||
}
|
||||
|
||||
flush(index - 1)
|
||||
result.push({
|
||||
key: `part:${item.messageID}:${item.part.id}`,
|
||||
type: "part",
|
||||
ref: {
|
||||
messageID: item.messageID,
|
||||
partID: item.part.id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
flush(parts.length - 1)
|
||||
return result
|
||||
}
|
||||
|
||||
function partByID(parts: readonly PartType[], partID: string) {
|
||||
return parts.find((part) => part.id === partID)
|
||||
}
|
||||
|
||||
function renderable(part: PartType, showReasoningSummaries = true) {
|
||||
if (part.type === "tool") {
|
||||
if (HIDDEN_TOOLS.has(part.tool)) return false
|
||||
@@ -304,98 +437,68 @@ export function AssistantParts(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyTools: ToolPart[] = []
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const keys: string[] = []
|
||||
const items: Record<
|
||||
string,
|
||||
{ type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] }
|
||||
> = {}
|
||||
const push = (
|
||||
key: string,
|
||||
item: { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] },
|
||||
) => {
|
||||
keys.push(key)
|
||||
items[key] = item
|
||||
}
|
||||
const grouped = createMemo(
|
||||
() =>
|
||||
groupParts(
|
||||
props.messages.flatMap((message) =>
|
||||
list(data.store.part?.[message.id], emptyParts)
|
||||
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
|
||||
.map((part) => ({
|
||||
messageID: message.id,
|
||||
part,
|
||||
})),
|
||||
),
|
||||
),
|
||||
[] as PartGroup[],
|
||||
{ equals: sameGroups },
|
||||
)
|
||||
|
||||
const parts = props.messages.flatMap((message) =>
|
||||
list(data.store.part?.[message.id], emptyParts)
|
||||
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
|
||||
.map((part) => ({ message, part })),
|
||||
)
|
||||
|
||||
let start = -1
|
||||
|
||||
const flush = (end: number) => {
|
||||
if (start < 0) return
|
||||
const first = parts[start]
|
||||
const last = parts[end]
|
||||
if (!first || !last) {
|
||||
start = -1
|
||||
return
|
||||
}
|
||||
push(`context:${first.part.id}`, {
|
||||
type: "context",
|
||||
parts: parts
|
||||
.slice(start, end + 1)
|
||||
.map((x) => x.part)
|
||||
.filter((part): part is ToolPart => isContextGroupTool(part)),
|
||||
})
|
||||
start = -1
|
||||
}
|
||||
|
||||
parts.forEach((item, index) => {
|
||||
if (isContextGroupTool(item.part)) {
|
||||
if (start < 0) start = index
|
||||
return
|
||||
}
|
||||
|
||||
flush(index - 1)
|
||||
push(`part:${item.message.id}:${item.part.id}`, { type: "part", part: item.part, message: item.message })
|
||||
})
|
||||
|
||||
flush(parts.length - 1)
|
||||
|
||||
return { keys, items }
|
||||
})
|
||||
|
||||
const last = createMemo(() => grouped().keys.at(-1))
|
||||
const last = createMemo(() => grouped().at(-1)?.key)
|
||||
|
||||
return (
|
||||
<For each={grouped().keys}>
|
||||
{(key) => {
|
||||
const item = createMemo(() => grouped().items[key])
|
||||
const ctx = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "context") return
|
||||
return value
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "part") return
|
||||
return value
|
||||
})
|
||||
const tail = createMemo(() => last() === key)
|
||||
<For each={grouped()}>
|
||||
{(entry) => {
|
||||
if (entry.type === "context") {
|
||||
const parts = createMemo(
|
||||
() =>
|
||||
entry.refs
|
||||
.map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
|
||||
.filter((part): part is ToolPart => !!part && isContextGroupTool(part)),
|
||||
emptyTools,
|
||||
{ equals: same },
|
||||
)
|
||||
const busy = createMemo(() => props.working && last() === entry.key)
|
||||
|
||||
return (
|
||||
<Show when={parts().length > 0}>
|
||||
<ContextToolGroup parts={parts()} busy={busy()} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const message = createMemo(() => props.messages.find((item) => item.id === entry.ref.messageID))
|
||||
const part = createMemo(() =>
|
||||
partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={ctx()}>
|
||||
{(entry) => <ContextToolGroup parts={entry().parts} busy={props.working && tail()} />}
|
||||
</Show>
|
||||
<Show when={part()}>
|
||||
{(entry) => (
|
||||
<Part
|
||||
part={entry().part}
|
||||
message={entry().message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
defaultOpen={partDefaultOpen(entry().part, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<Show when={part()}>
|
||||
{(part) => (
|
||||
<Part
|
||||
part={part()}
|
||||
message={message()}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
defaultOpen={partDefaultOpen(part(), props.shellToolDefaultOpen, props.editToolDefaultOpen)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
@@ -469,23 +572,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
|
||||
}
|
||||
}
|
||||
|
||||
function contextToolSummary(parts: ToolPart[], i18n: ReturnType<typeof useI18n>) {
|
||||
function contextToolSummary(parts: ToolPart[]) {
|
||||
const read = parts.filter((part) => part.tool === "read").length
|
||||
const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
|
||||
const list = parts.filter((part) => part.tool === "list").length
|
||||
return [
|
||||
read
|
||||
? i18n.t(read === 1 ? "ui.messagePart.context.read.one" : "ui.messagePart.context.read.other", { count: read })
|
||||
: undefined,
|
||||
search
|
||||
? i18n.t(search === 1 ? "ui.messagePart.context.search.one" : "ui.messagePart.context.search.other", {
|
||||
count: search,
|
||||
})
|
||||
: undefined,
|
||||
list
|
||||
? i18n.t(list === 1 ? "ui.messagePart.context.list.one" : "ui.messagePart.context.list.other", { count: list })
|
||||
: undefined,
|
||||
].filter((value): value is string => !!value)
|
||||
return { read, search, list }
|
||||
}
|
||||
|
||||
export function registerPartComponent(type: string, component: PartComponent) {
|
||||
@@ -525,78 +616,49 @@ export function AssistantMessageDisplay(props: {
|
||||
showAssistantCopyPartID?: string | null
|
||||
showReasoningSummaries?: boolean
|
||||
}) {
|
||||
const grouped = createMemo(() => {
|
||||
const keys: string[] = []
|
||||
const items: Record<string, { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }> = {}
|
||||
const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => {
|
||||
keys.push(key)
|
||||
items[key] = item
|
||||
}
|
||||
|
||||
const parts = props.parts
|
||||
let start = -1
|
||||
|
||||
const flush = (end: number) => {
|
||||
if (start < 0) return
|
||||
const first = parts[start]
|
||||
const last = parts[end]
|
||||
if (!first || !last) {
|
||||
start = -1
|
||||
return
|
||||
}
|
||||
push(`context:${first.id}`, {
|
||||
type: "context",
|
||||
parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)),
|
||||
})
|
||||
start = -1
|
||||
}
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
if (!renderable(part, props.showReasoningSummaries ?? true)) return
|
||||
|
||||
if (isContextGroupTool(part)) {
|
||||
if (start < 0) start = index
|
||||
return
|
||||
}
|
||||
|
||||
flush(index - 1)
|
||||
push(`part:${part.id}`, { type: "part", part })
|
||||
})
|
||||
|
||||
flush(parts.length - 1)
|
||||
|
||||
return { keys, items }
|
||||
})
|
||||
const emptyTools: ToolPart[] = []
|
||||
const grouped = createMemo(
|
||||
() =>
|
||||
groupParts(
|
||||
props.parts
|
||||
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
|
||||
.map((part) => ({
|
||||
messageID: props.message.id,
|
||||
part,
|
||||
})),
|
||||
),
|
||||
[] as PartGroup[],
|
||||
{ equals: sameGroups },
|
||||
)
|
||||
|
||||
return (
|
||||
<For each={grouped().keys}>
|
||||
{(key) => {
|
||||
const item = createMemo(() => grouped().items[key])
|
||||
const ctx = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "context") return
|
||||
return value
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "part") return
|
||||
return value
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<Show when={ctx()}>{(entry) => <ContextToolGroup parts={entry().parts} />}</Show>
|
||||
<Show when={part()}>
|
||||
{(entry) => (
|
||||
<Part
|
||||
part={entry().part}
|
||||
message={props.message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
/>
|
||||
)}
|
||||
<For each={grouped()}>
|
||||
{(entry) => {
|
||||
if (entry.type === "context") {
|
||||
const parts = createMemo(
|
||||
() =>
|
||||
entry.refs
|
||||
.map((ref) => partByID(props.parts, ref.partID))
|
||||
.filter((part): part is ToolPart => !!part && isContextGroupTool(part)),
|
||||
emptyTools,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={parts().length > 0}>
|
||||
<ContextToolGroup parts={parts()} />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const part = createMemo(() => partByID(props.parts, entry.ref.partID))
|
||||
|
||||
return (
|
||||
<Show when={part()}>
|
||||
{(part) => (
|
||||
<Part part={part()} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} />
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
@@ -610,33 +672,53 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
() =>
|
||||
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
|
||||
)
|
||||
const summary = createMemo(() => contextToolSummary(props.parts, i18n))
|
||||
const details = createMemo(() => summary().join(", "))
|
||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
||||
|
||||
return (
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="context-tool-group-trigger">
|
||||
<Show
|
||||
when={pending()}
|
||||
fallback={
|
||||
<span data-slot="context-tool-group-title">
|
||||
<span data-slot="context-tool-group-label">{i18n.t("ui.sessionTurn.status.gatheredContext")}</span>
|
||||
<Show when={details().length}>
|
||||
<span data-slot="context-tool-group-summary">{details()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
<span
|
||||
data-slot="context-tool-group-title"
|
||||
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
|
||||
>
|
||||
<span data-slot="context-tool-group-title">
|
||||
<span data-slot="context-tool-group-label">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.gatheringContext")} />
|
||||
</span>
|
||||
<Show when={details().length}>
|
||||
<span data-slot="context-tool-group-summary">{details()}</span>
|
||||
</Show>
|
||||
<span data-slot="context-tool-group-label" class="shrink-0">
|
||||
<ToolStatusTitle
|
||||
active={pending()}
|
||||
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
|
||||
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
|
||||
split={false}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
<span
|
||||
data-slot="context-tool-group-summary"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
|
||||
>
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
{
|
||||
key: "read",
|
||||
count: summary().read,
|
||||
one: i18n.t("ui.messagePart.context.read.one"),
|
||||
other: i18n.t("ui.messagePart.context.read.other"),
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
count: summary().search,
|
||||
one: i18n.t("ui.messagePart.context.search.one"),
|
||||
other: i18n.t("ui.messagePart.context.search.other"),
|
||||
},
|
||||
{
|
||||
key: "list",
|
||||
count: summary().list,
|
||||
one: i18n.t("ui.messagePart.context.list.one"),
|
||||
other: i18n.t("ui.messagePart.context.list.other"),
|
||||
},
|
||||
]}
|
||||
fallback=""
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
@@ -654,9 +736,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<Show when={running} fallback={trigger.title}>
|
||||
<TextShimmer text={trigger.title} />
|
||||
</Show>
|
||||
<TextShimmer text={trigger.title} active={running} />
|
||||
</span>
|
||||
<Show when={!running && trigger.subtitle}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
|
||||
@@ -1319,9 +1399,7 @@ ToolRegistry.register({
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<Show when={pending()} fallback={i18n.t("ui.tool.webfetch")}>
|
||||
<TextShimmer text={i18n.t("ui.tool.webfetch")} />
|
||||
</Show>
|
||||
<TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending() && url()}>
|
||||
<a
|
||||
@@ -1436,6 +1514,8 @@ ToolRegistry.register({
|
||||
name: "bash",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
const sawPending = pending()
|
||||
const text = createMemo(() => {
|
||||
const cmd = props.input.command ?? props.metadata.command ?? ""
|
||||
const out = stripAnsi(props.output || props.metadata.output || "")
|
||||
@@ -1455,10 +1535,18 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.shell"),
|
||||
subtitle: props.input.description,
|
||||
}}
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending() && props.input.description}>
|
||||
<ShellSubmessage text={props.input.description} animate={sawPending} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-component="bash-output">
|
||||
<div data-slot="bash-copy">
|
||||
@@ -1508,9 +1596,7 @@ ToolRegistry.register({
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.edit")}>
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} />
|
||||
</Show>
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{filename()}</span>
|
||||
@@ -1580,9 +1666,7 @@ ToolRegistry.register({
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.write")}>
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.write")} />
|
||||
</Show>
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{filename()}</span>
|
||||
@@ -1774,9 +1858,7 @@ ToolRegistry.register({
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<Show when={pending()} fallback={i18n.t("ui.tool.patch")}>
|
||||
<TextShimmer text={i18n.t("ui.tool.patch")} />
|
||||
</Show>
|
||||
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{getFilename(file().relativePath)}</span>
|
||||
|
||||
45
packages/ui/src/components/motion-spring.tsx
Normal file
45
packages/ui/src/components/motion-spring.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { attachSpring, motionValue } from "motion"
|
||||
import type { SpringOptions } from "motion"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
|
||||
const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
||||
a?.visualDuration === b?.visualDuration &&
|
||||
a?.bounce === b?.bounce &&
|
||||
a?.stiffness === b?.stiffness &&
|
||||
a?.damping === b?.damping &&
|
||||
a?.mass === b?.mass &&
|
||||
a?.velocity === b?.velocity
|
||||
|
||||
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
|
||||
const read = () => (typeof options === "function" ? options() : options)
|
||||
const [value, setValue] = createSignal(target())
|
||||
const source = motionValue(value())
|
||||
const spring = motionValue(value())
|
||||
let config = read()
|
||||
let stop = attachSpring(spring, source, config)
|
||||
let off = spring.on("change", (next: number) => setValue(next))
|
||||
|
||||
createEffect(() => {
|
||||
source.set(target())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!options) return
|
||||
const next = read()
|
||||
if (eq(config, next)) return
|
||||
config = next
|
||||
stop()
|
||||
stop = attachSpring(spring, source, next)
|
||||
setValue(spring.get())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
off()
|
||||
stop()
|
||||
spring.destroy()
|
||||
source.destroy()
|
||||
})
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -48,9 +48,9 @@
|
||||
transition:
|
||||
opacity 200ms ease-out,
|
||||
box-shadow 100ms ease-in-out,
|
||||
width 200ms ease-out,
|
||||
height 200ms ease-out,
|
||||
transform 200ms ease-out;
|
||||
width 200ms cubic-bezier(0.22, 1.2, 0.36, 1),
|
||||
height 200ms cubic-bezier(0.22, 1.2, 0.36, 1),
|
||||
transform 300ms cubic-bezier(0.22, 1.2, 0.36, 1);
|
||||
will-change: transform;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -60,16 +60,13 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-thinking-heading"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-weaker);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
[data-component="text-reveal"].session-turn-thinking-heading {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-weaker);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.error-card {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionStatus } from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useFileComponent } from "../context/file"
|
||||
|
||||
@@ -15,6 +16,7 @@ import { DiffChanges } from "./diff-changes"
|
||||
import { Icon } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { SessionRetry } from "./session-retry"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
@@ -142,6 +144,9 @@ export function SessionTurn(
|
||||
showReasoningSummaries?: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
active?: boolean
|
||||
queued?: boolean
|
||||
status?: SessionStatus
|
||||
onUserInteracted?: () => void
|
||||
classes?: {
|
||||
root?: string
|
||||
@@ -187,6 +192,7 @@ export function SessionTurn(
|
||||
})
|
||||
|
||||
const pending = createMemo(() => {
|
||||
if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
return messages.findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
@@ -204,6 +210,7 @@ export function SessionTurn(
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (typeof props.active === "boolean") return props.active
|
||||
const msg = message()
|
||||
const parent = pendingUser()
|
||||
if (!msg || !parent) return false
|
||||
@@ -211,6 +218,7 @@ export function SessionTurn(
|
||||
})
|
||||
|
||||
const queued = createMemo(() => {
|
||||
if (typeof props.queued === "boolean") return props.queued
|
||||
const id = message()?.id
|
||||
if (!id) return false
|
||||
if (!pendingUser()) return false
|
||||
@@ -305,7 +313,11 @@ export function SessionTurn(
|
||||
return unwrap(String(msg))
|
||||
})
|
||||
|
||||
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
|
||||
const status = createMemo(() => {
|
||||
if (props.status !== undefined) return props.status
|
||||
if (typeof props.active === "boolean" && !props.active) return idle
|
||||
return data.store.session_status[props.sessionID] ?? idle
|
||||
})
|
||||
const working = createMemo(() => status().type !== "idle" && active())
|
||||
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
|
||||
|
||||
@@ -410,8 +422,13 @@ export function SessionTurn(
|
||||
<Show when={showThinking()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!showReasoningSummaries() && reasoningHeading()}>
|
||||
{(text) => <span data-slot="session-turn-thinking-heading">{text()}</span>}
|
||||
<Show when={!showReasoningSummaries()}>
|
||||
<TextReveal
|
||||
text={reasoningHeading()}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={700}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
329
packages/ui/src/components/shell-submessage-motion.stories.tsx
Normal file
329
packages/ui/src/components/shell-submessage-motion.stories.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
import { animate } from "motion"
|
||||
|
||||
export default {
|
||||
title: "UI/Shell Submessage Motion",
|
||||
id: "components-shell-submessage-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Interactive playground for animating the Shell tool subtitle ("submessage") in the timeline trigger row.
|
||||
|
||||
### Production component path
|
||||
- Trigger layout: \`packages/ui/src/components/basic-tool.tsx\`
|
||||
- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`)
|
||||
|
||||
### What this playground tunes
|
||||
- Width reveal (spring-driven pixel width via \`useSpring\`)
|
||||
- Opacity fade
|
||||
- Blur settle`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const sliderLabel = {
|
||||
"font-size": "11px",
|
||||
"font-family": "monospace",
|
||||
color: "var(--color-text-weak, #666)",
|
||||
"min-width": "84px",
|
||||
"flex-shrink": "0",
|
||||
"text-align": "right",
|
||||
}
|
||||
|
||||
const sliderValue = {
|
||||
"font-family": "monospace",
|
||||
"font-size": "11px",
|
||||
color: "var(--color-text-weak, #aaa)",
|
||||
"min-width": "76px",
|
||||
}
|
||||
|
||||
const shellCss = `
|
||||
[data-component="shell-submessage-scene"] [data-component="tool-trigger"] [data-slot="basic-tool-tool-info-main"] {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
filter: blur(var(--shell-sub-blur, 2px));
|
||||
transition-property: opacity, filter;
|
||||
transition-duration: var(--shell-sub-fade-ms, 320ms);
|
||||
transition-timing-function: var(--shell-sub-fade-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
}
|
||||
`
|
||||
|
||||
const ease = {
|
||||
smooth: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
snappy: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
standard: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||
linear: "linear",
|
||||
}
|
||||
|
||||
function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!widthRef) return
|
||||
if (props.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
ref?.setAttribute("data-visible", "")
|
||||
animate(
|
||||
widthRef!,
|
||||
{ width: "auto" },
|
||||
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
|
||||
)
|
||||
})
|
||||
} else {
|
||||
ref?.removeAttribute("data-visible")
|
||||
animate(
|
||||
widthRef,
|
||||
{ width: "0px" },
|
||||
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<span ref={ref} data-component="shell-submessage">
|
||||
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: "0px" }}>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<span data-slot="shell-submessage-value">{props.text || "\u00A0"}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [text, setText] = createSignal("Prints five topic blocks between timed commands")
|
||||
const [show, setShow] = createSignal(true)
|
||||
const [visualDuration, setVisualDuration] = createSignal(0.35)
|
||||
const [bounce, setBounce] = createSignal(0)
|
||||
const [fadeMs, setFadeMs] = createSignal(320)
|
||||
const [blur, setBlur] = createSignal(2)
|
||||
const [fadeEase, setFadeEase] = createSignal<keyof typeof ease>("snappy")
|
||||
const [auto, setAuto] = createSignal(false)
|
||||
let replayTimer
|
||||
let autoTimer
|
||||
|
||||
const replay = () => {
|
||||
setShow(false)
|
||||
if (replayTimer) clearTimeout(replayTimer)
|
||||
replayTimer = setTimeout(() => {
|
||||
setShow(true)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const stopAuto = () => {
|
||||
if (autoTimer) clearInterval(autoTimer)
|
||||
autoTimer = undefined
|
||||
setAuto(false)
|
||||
}
|
||||
|
||||
const toggleAuto = () => {
|
||||
if (auto()) {
|
||||
stopAuto()
|
||||
return
|
||||
}
|
||||
setAuto(true)
|
||||
autoTimer = setInterval(replay, 2200)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (replayTimer) clearTimeout(replayTimer)
|
||||
if (autoTimer) clearInterval(autoTimer)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="shell-submessage-scene"
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "20px",
|
||||
padding: "20px",
|
||||
"max-width": "860px",
|
||||
"--shell-sub-fade-ms": `${fadeMs()}ms`,
|
||||
"--shell-sub-blur": `${blur()}px`,
|
||||
"--shell-sub-fade-ease": ease[fadeEase()],
|
||||
}}
|
||||
>
|
||||
<style>{shellCss}</style>
|
||||
|
||||
<BasicTool
|
||||
icon="console"
|
||||
defaultOpen
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">Shell</span>
|
||||
<SpringSubmessage text={text()} visible={show()} visualDuration={visualDuration()} bounce={bounce()} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
"border-radius": "8px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-secondary, #161616)",
|
||||
padding: "14px 16px",
|
||||
"font-family": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
"font-size": "18px",
|
||||
color: "var(--color-text, #eee)",
|
||||
"white-space": "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{"$ cat <<'TOPIC1'"}
|
||||
</div>
|
||||
</BasicTool>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={replay} style={btn()}>
|
||||
Replay entry
|
||||
</button>
|
||||
<button onClick={() => setShow((v) => !v)} style={btn(show())}>
|
||||
{show() ? "Hide subtitle" : "Show subtitle"}
|
||||
</button>
|
||||
<button onClick={toggleAuto} style={btn(auto())}>
|
||||
{auto() ? "Stop auto replay" : "Auto replay"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
"border-top": "1px solid var(--color-divider, #333)",
|
||||
"padding-top": "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>subtitle</span>
|
||||
<input
|
||||
value={text()}
|
||||
onInput={(e) => setText(e.currentTarget.value)}
|
||||
style={{
|
||||
width: "420px",
|
||||
"max-width": "100%",
|
||||
padding: "6px 8px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>visualDuration</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.05}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
value={visualDuration()}
|
||||
onInput={(e) => setVisualDuration(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{visualDuration().toFixed(2)}s</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
value={bounce()}
|
||||
onInput={(e) => setBounce(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{bounce().toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>fade ease</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setFadeEase((v) =>
|
||||
v === "snappy" ? "smooth" : v === "smooth" ? "standard" : v === "standard" ? "linear" : "snappy",
|
||||
)
|
||||
}
|
||||
style={btn()}
|
||||
>
|
||||
{fadeEase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>fade</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1400}
|
||||
step={10}
|
||||
value={fadeMs()}
|
||||
onInput={(e) => setFadeMs(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{fadeMs()}ms</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>blur</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={14}
|
||||
step={0.5}
|
||||
value={blur()}
|
||||
onInput={(e) => setBlur(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{blur()}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
23
packages/ui/src/components/shell-submessage.css
Normal file
23
packages/ui/src/components/shell-submessage.css
Normal file
@@ -0,0 +1,23 @@
|
||||
[data-component="shell-submessage"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
144
packages/ui/src/components/text-reveal.css
Normal file
144
packages/ui/src/components/text-reveal.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* TextReveal — mask-position wipe animation
|
||||
*
|
||||
* Instead of sliding text through a fixed mask (odometer style),
|
||||
* the mask itself sweeps across each span to reveal/hide text.
|
||||
*
|
||||
* Direction: top-to-bottom. New text drops in from above, old text exits downward.
|
||||
*
|
||||
* Entering: gradient reveals top-to-bottom (top of text appears first).
|
||||
* gradient(to bottom, white 33%, transparent 33%+edge)
|
||||
* pos 0 100% = transparent covers element = hidden
|
||||
* pos 0 0% = white covers element = visible
|
||||
*
|
||||
* Leaving: gradient hides top-to-bottom (top of text disappears first).
|
||||
* gradient(to top, white 33%, transparent 33%+edge)
|
||||
* pos 0 100% = white covers element = visible
|
||||
* pos 0 0% = transparent covers element = hidden
|
||||
*
|
||||
* Both transition from 0 100% (swap) → 0 0% (settled).
|
||||
*/
|
||||
|
||||
[data-component="text-reveal"] {
|
||||
--_edge: var(--text-reveal-edge, 17%);
|
||||
--_dur: var(--text-reveal-duration, 450ms);
|
||||
--_spring: var(--text-reveal-spring, cubic-bezier(0.34, 1.08, 0.64, 1));
|
||||
--_spring-soft: var(--text-reveal-spring-soft, cubic-bezier(0.34, 1, 0.64, 1));
|
||||
--_travel: var(--text-reveal-travel, 0px);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
|
||||
[data-slot="text-reveal-track"] {
|
||||
display: grid;
|
||||
min-height: 20px;
|
||||
line-height: 20px;
|
||||
justify-items: start;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
transition: width var(--_dur) var(--_spring-soft);
|
||||
}
|
||||
|
||||
[data-slot="text-reveal-entering"],
|
||||
[data-slot="text-reveal-leaving"] {
|
||||
grid-area: 1 / 1;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
text-align: start;
|
||||
mask-size: 100% 300%;
|
||||
-webkit-mask-size: 100% 300%;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
transition-duration: var(--_dur);
|
||||
transition-timing-function: var(--_spring);
|
||||
}
|
||||
|
||||
/* ── entering: reveal top-to-bottom ──
|
||||
* Gradient(to top): white at bottom, transparent at top of mask.
|
||||
* Settled pos 0 100% = white covers element = visible
|
||||
* Swap pos 0 0% = transparent covers = hidden
|
||||
* Slides from above: translateY(-travel) → translateY(0)
|
||||
*/
|
||||
[data-slot="text-reveal-entering"] {
|
||||
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||
mask-position: 0 100%;
|
||||
-webkit-mask-position: 0 100%;
|
||||
transition-property: mask-position, -webkit-mask-position, transform;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── leaving: hide top-to-bottom + slide downward ──
|
||||
* Gradient(to bottom): white at top, transparent at bottom of mask.
|
||||
* Swap pos 0 0% = white covers element = visible
|
||||
* Settled pos 0 100% = transparent covers = hidden
|
||||
* Slides down: translateY(0) → translateY(travel)
|
||||
*/
|
||||
[data-slot="text-reveal-leaving"] {
|
||||
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||
mask-position: 0 100%;
|
||||
-webkit-mask-position: 0 100%;
|
||||
transition-property: mask-position, -webkit-mask-position, transform;
|
||||
transform: translateY(var(--_travel));
|
||||
}
|
||||
|
||||
/* ── swapping: instant reset ──
|
||||
* Snap entering to hidden (above), leaving to visible (center).
|
||||
*/
|
||||
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
|
||||
mask-position: 0 0%;
|
||||
-webkit-mask-position: 0 0%;
|
||||
transform: translateY(calc(var(--_travel) * -1));
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
|
||||
mask-position: 0 0%;
|
||||
-webkit-mask-position: 0 0%;
|
||||
transform: translateY(0);
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
/* ── not ready: kill all transitions ── */
|
||||
&[data-ready="false"] [data-slot="text-reveal-track"] {
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
&[data-ready="false"] [data-slot="text-reveal-entering"],
|
||||
&[data-ready="false"] [data-slot="text-reveal-leaving"] {
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
&[data-truncate="true"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&[data-truncate="true"] [data-slot="text-reveal-track"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&[data-truncate="true"] [data-slot="text-reveal-entering"],
|
||||
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="text-reveal"] [data-slot="text-reveal-track"] {
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
[data-component="text-reveal"] [data-slot="text-reveal-entering"],
|
||||
[data-component="text-reveal"] [data-slot="text-reveal-leaving"] {
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
}
|
||||
248
packages/ui/src/components/text-reveal.stories.tsx
Normal file
248
packages/ui/src/components/text-reveal.stories.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
|
||||
export default {
|
||||
title: "UI/TextReveal",
|
||||
id: "components-text-reveal",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Playground for the TextReveal text transition component.
|
||||
|
||||
**Hybrid** — mask wipe + vertical slide: gradient sweeps AND text moves downward.
|
||||
|
||||
**Wipe only** — pure mask wipe: gradient sweeps top-to-bottom, text stays in place.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TEXTS = [
|
||||
"Refactor ToolStatusTitle DOM measurement",
|
||||
"Remove inline measure nodes",
|
||||
"Run typechecks and report changes",
|
||||
"Verify reduced-motion behavior",
|
||||
"Review diff for animation edge cases",
|
||||
"Check keyboard semantics",
|
||||
undefined,
|
||||
"Planning key generation details",
|
||||
"Analyzing error handling",
|
||||
"Considering edge cases",
|
||||
]
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "5px 12px",
|
||||
"border-radius": "6px",
|
||||
border: accent ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "12px",
|
||||
}) as const
|
||||
|
||||
const sliderLabel = {
|
||||
width: "90px",
|
||||
"font-size": "12px",
|
||||
color: "var(--color-text-secondary, #a3a3a3)",
|
||||
"flex-shrink": "0",
|
||||
} as const
|
||||
|
||||
const cardStyle = {
|
||||
padding: "20px 24px",
|
||||
"border-radius": "10px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-element, #1a1a1a)",
|
||||
display: "grid",
|
||||
gap: "12px",
|
||||
} as const
|
||||
|
||||
const cardLabel = {
|
||||
"font-size": "11px",
|
||||
"font-family": "monospace",
|
||||
color: "var(--color-text-weak, #666)",
|
||||
} as const
|
||||
|
||||
const previewRow = {
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
"font-size": "14px",
|
||||
"font-weight": "500",
|
||||
"line-height": "20px",
|
||||
color: "var(--text-weak, #aaa)",
|
||||
"min-height": "20px",
|
||||
overflow: "visible",
|
||||
} as const
|
||||
|
||||
const headingSlot = {
|
||||
"min-width": "0",
|
||||
overflow: "visible",
|
||||
color: "var(--text-weaker, #888)",
|
||||
"font-weight": "400",
|
||||
} as const
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [index, setIndex] = createSignal(0)
|
||||
const [cycling, setCycling] = createSignal(false)
|
||||
const [growOnly, setGrowOnly] = createSignal(true)
|
||||
|
||||
const [duration, setDuration] = createSignal(600)
|
||||
const [bounce, setBounce] = createSignal(1.0)
|
||||
const [bounceSoft, setBounceSoft] = createSignal(1.0)
|
||||
|
||||
const [hybridTravel, setHybridTravel] = createSignal(25)
|
||||
const [hybridEdge, setHybridEdge] = createSignal(17)
|
||||
|
||||
const [edge, setEdge] = createSignal(17)
|
||||
const [revealTravel, setRevealTravel] = createSignal(0)
|
||||
|
||||
let timer: number | undefined
|
||||
const text = () => TEXTS[index()]
|
||||
const next = () => setIndex((i) => (i + 1) % TEXTS.length)
|
||||
const prev = () => setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length)
|
||||
|
||||
const toggleCycle = () => {
|
||||
if (cycling()) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
setCycling(false)
|
||||
return
|
||||
}
|
||||
setCycling(true)
|
||||
const tick = () => {
|
||||
next()
|
||||
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
|
||||
}
|
||||
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
|
||||
const spring = () => `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`
|
||||
const springSoft = () => `cubic-bezier(0.34, ${bounceSoft()}, 0.64, 1)`
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "700px" }}>
|
||||
<div style={{ display: "grid", gap: "16px" }}>
|
||||
<div style={cardStyle}>
|
||||
<span style={cardLabel}>text-reveal (mask wipe + slide)</span>
|
||||
<div style={previewRow}>
|
||||
<span>Thinking</span>
|
||||
<span style={headingSlot}>
|
||||
<TextReveal
|
||||
class="text-14-regular"
|
||||
text={text()}
|
||||
duration={duration()}
|
||||
edge={hybridEdge()}
|
||||
travel={hybridTravel()}
|
||||
spring={spring()}
|
||||
springSoft={springSoft()}
|
||||
growOnly={growOnly()}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={cardStyle}>
|
||||
<span style={cardLabel}>text-reveal (mask wipe only)</span>
|
||||
<div style={previewRow}>
|
||||
<span>Thinking</span>
|
||||
<span style={headingSlot}>
|
||||
<TextReveal
|
||||
class="text-14-regular"
|
||||
text={text()}
|
||||
duration={duration()}
|
||||
edge={edge()}
|
||||
travel={revealTravel()}
|
||||
spring={spring()}
|
||||
springSoft={springSoft()}
|
||||
growOnly={growOnly()}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
|
||||
{TEXTS.map((t, i) => (
|
||||
<button onClick={() => setIndex(i)} style={btn(index() === i)}>
|
||||
{t ?? "(none)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={prev} style={btn()}>Prev</button>
|
||||
<button onClick={next} style={btn()}>Next</button>
|
||||
<button onClick={toggleCycle} style={btn(cycling())}>
|
||||
{cycling() ? "Stop cycle" : "Auto cycle"}
|
||||
</button>
|
||||
<button onClick={() => setGrowOnly((v) => !v)} style={btn(growOnly())}>
|
||||
{growOnly() ? "growOnly: on" : "growOnly: off"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "8px", "max-width": "480px" }}>
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)" }}>Hybrid (wipe + slide)</div>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>edge</span>
|
||||
<input type="range" min="1" max="40" step="1" value={hybridEdge()} onInput={(e) => setHybridEdge(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridEdge()}%</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>travel</span>
|
||||
<input type="range" min="0" max="40" step="1" value={hybridTravel()} onInput={(e) => setHybridTravel(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridTravel()}px</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>Shared</div>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>duration</span>
|
||||
<input type="range" min="100" max="1400" step="10" value={duration()} onInput={(e) => setDuration(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{duration()}ms</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce</span>
|
||||
<input type="range" min="1" max="2" step="0.01" value={bounce()} onInput={(e) => setBounce(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounce().toFixed(2)}</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce soft</span>
|
||||
<input type="range" min="1" max="1.5" step="0.01" value={bounceSoft()} onInput={(e) => setBounceSoft(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounceSoft().toFixed(2)}</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>Wipe only</div>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>edge</span>
|
||||
<input type="range" min="1" max="40" step="1" value={edge()} onInput={(e) => setEdge(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{edge()}%</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>travel</span>
|
||||
<input type="range" min="0" max="16" step="1" value={revealTravel()} onInput={(e) => setRevealTravel(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{revealTravel()}px</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #888)", "font-family": "monospace" }}>
|
||||
text: {text() ?? "(none)"} · growOnly: {growOnly() ? "on" : "off"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
130
packages/ui/src/components/text-reveal.tsx
Normal file
130
packages/ui/src/components/text-reveal.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||
|
||||
const px = (value: number | string | undefined, fallback: number) => {
|
||||
if (typeof value === "number") return `${value}px`
|
||||
if (typeof value === "string") return value
|
||||
return `${fallback}px`
|
||||
}
|
||||
|
||||
const ms = (value: number | string | undefined, fallback: number) => {
|
||||
if (typeof value === "number") return `${value}ms`
|
||||
if (typeof value === "string") return value
|
||||
return `${fallback}ms`
|
||||
}
|
||||
|
||||
const pct = (value: number | undefined, fallback: number) => {
|
||||
const v = value ?? fallback
|
||||
return `${v}%`
|
||||
}
|
||||
|
||||
export function TextReveal(props: {
|
||||
text?: string
|
||||
class?: string
|
||||
duration?: number | string
|
||||
/** Gradient edge softness as a percentage of the mask (0 = hard wipe, 17 = soft). */
|
||||
edge?: number
|
||||
/** Optional small vertical travel for entering text (px). Default 0. */
|
||||
travel?: number | string
|
||||
spring?: string
|
||||
springSoft?: string
|
||||
growOnly?: boolean
|
||||
truncate?: boolean
|
||||
}) {
|
||||
const [cur, setCur] = createSignal(props.text)
|
||||
const [old, setOld] = createSignal<string | undefined>()
|
||||
const [width, setWidth] = createSignal("auto")
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [swapping, setSwapping] = createSignal(false)
|
||||
let inRef: HTMLSpanElement | undefined
|
||||
let outRef: HTMLSpanElement | undefined
|
||||
let rootRef: HTMLSpanElement | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const win = () => inRef?.scrollWidth ?? 0
|
||||
const wout = () => outRef?.scrollWidth ?? 0
|
||||
|
||||
const widen = (next: number) => {
|
||||
if (next <= 0) return
|
||||
if (props.growOnly ?? true) {
|
||||
const prev = Number.parseFloat(width())
|
||||
if (Number.isFinite(prev) && next <= prev) return
|
||||
}
|
||||
setWidth(`${next}px`)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.text,
|
||||
(next, prev) => {
|
||||
if (next === prev) return
|
||||
setSwapping(true)
|
||||
setOld(prev)
|
||||
setCur(next)
|
||||
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
widen(Math.max(win(), wout()))
|
||||
rootRef?.offsetHeight
|
||||
setSwapping(false)
|
||||
return
|
||||
}
|
||||
if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
widen(Math.max(win(), wout()))
|
||||
rootRef?.offsetHeight
|
||||
setSwapping(false)
|
||||
frame = undefined
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
widen(win())
|
||||
const fonts = typeof document !== "undefined" ? document.fonts : undefined
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
if (!fonts) {
|
||||
requestAnimationFrame(() => setReady(true))
|
||||
return
|
||||
}
|
||||
fonts.ready.finally(() => {
|
||||
widen(win())
|
||||
requestAnimationFrame(() => setReady(true))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined || typeof cancelAnimationFrame !== "function") return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={rootRef}
|
||||
data-component="text-reveal"
|
||||
data-ready={ready() ? "true" : "false"}
|
||||
data-swapping={swapping() ? "true" : "false"}
|
||||
data-truncate={props.truncate ? "true" : "false"}
|
||||
class={props.class}
|
||||
aria-label={props.text ?? ""}
|
||||
style={{
|
||||
"--text-reveal-duration": ms(props.duration, 450),
|
||||
"--text-reveal-edge": pct(props.edge, 17),
|
||||
"--text-reveal-travel": px(props.travel, 0),
|
||||
"--text-reveal-spring": props.spring ?? "cubic-bezier(0.34, 1.08, 0.64, 1)",
|
||||
"--text-reveal-spring-soft": props.springSoft ?? "cubic-bezier(0.34, 1, 0.64, 1)",
|
||||
}}
|
||||
>
|
||||
<span data-slot="text-reveal-track" style={{ width: props.truncate ? "100%" : width() }}>
|
||||
<span data-slot="text-reveal-entering" ref={inRef}>
|
||||
{cur() ?? "\u00A0"}
|
||||
</span>
|
||||
<span data-slot="text-reveal-leaving" ref={outRef}>
|
||||
{old() ?? "\u00A0"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,119 @@
|
||||
[data-component="text-shimmer"] {
|
||||
--text-shimmer-step: 45ms;
|
||||
--text-shimmer-duration: 1200ms;
|
||||
--text-shimmer-swap: 220ms;
|
||||
--text-shimmer-index: 0;
|
||||
--text-shimmer-angle: 90deg;
|
||||
--text-shimmer-spread: 5.2ch;
|
||||
--text-shimmer-size: 360%;
|
||||
--text-shimmer-base-color: var(--text-weak);
|
||||
--text-shimmer-peak-color: var(--text-strong);
|
||||
--text-shimmer-sweep: linear-gradient(
|
||||
var(--text-shimmer-angle),
|
||||
transparent calc(50% - var(--text-shimmer-spread)),
|
||||
var(--text-shimmer-peak-color) 50%,
|
||||
transparent calc(50% + var(--text-shimmer-spread))
|
||||
);
|
||||
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
|
||||
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
|
||||
display: inline-grid;
|
||||
white-space: pre;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] {
|
||||
animation-name: text-shimmer-char;
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
grid-area: 1 / 1;
|
||||
white-space: pre;
|
||||
transition: opacity var(--text-shimmer-swap) ease-out;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
color: var(--text-weaker);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] {
|
||||
animation-name: text-shimmer-sweep;
|
||||
animation-duration: var(--text-shimmer-duration);
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index));
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index) * -1);
|
||||
will-change: background-position;
|
||||
}
|
||||
|
||||
@keyframes text-shimmer-char {
|
||||
0%,
|
||||
@keyframes text-shimmer-sweep {
|
||||
0% {
|
||||
background-position:
|
||||
100% 0,
|
||||
0 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
color: var(--text-weaker);
|
||||
background-position:
|
||||
0% 0,
|
||||
0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-image: var(--text-shimmer-sweep), var(--text-shimmer-base);
|
||||
background-size:
|
||||
var(--text-shimmer-size) 100%,
|
||||
100% 100%;
|
||||
background-position:
|
||||
100% 0,
|
||||
0 0;
|
||||
background-repeat: no-repeat;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
30% {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
55% {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
75% {
|
||||
color: var(--text-strong);
|
||||
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-base"] {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
animation: none !important;
|
||||
color: inherit;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./text-shimmer"
|
||||
import { useArgs } from "storybook/preview-api"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
@@ -9,13 +10,14 @@ Use for pending states inside buttons or list rows.
|
||||
|
||||
### API
|
||||
- Required: \`text\` string.
|
||||
- Optional: \`as\`, \`active\`, \`stepMs\`, \`durationMs\`.
|
||||
- Optional: \`as\`, \`active\`, \`offset\`, \`class\`.
|
||||
|
||||
### Variants and states
|
||||
- Active/inactive state via \`active\`.
|
||||
|
||||
### Behavior
|
||||
- Characters animate with staggered delays.
|
||||
- Uses a moving gradient sweep clipped to text.
|
||||
- \`offset\` lets multiple shimmers run out-of-phase.
|
||||
|
||||
### Accessibility
|
||||
- Uses \`aria-label\` with the full text.
|
||||
@@ -25,13 +27,27 @@ Use for pending states inside buttons or list rows.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/TextShimmer", mod, args: { text: "Loading..." } })
|
||||
const defaults = {
|
||||
text: "Loading...",
|
||||
active: true,
|
||||
class: "text-14-medium text-text-strong",
|
||||
offset: 0,
|
||||
} as const
|
||||
|
||||
const story = create({ title: "UI/TextShimmer", mod, args: defaults })
|
||||
|
||||
export default {
|
||||
title: "UI/TextShimmer",
|
||||
id: "components-text-shimmer",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
args: defaults,
|
||||
argTypes: {
|
||||
text: { control: "text" },
|
||||
class: { control: "text" },
|
||||
active: { control: "boolean" },
|
||||
offset: { control: { type: "range", min: 0, max: 80, step: 1 } },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -41,7 +57,32 @@ export default {
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
export const Basic = {
|
||||
args: defaults,
|
||||
render: (args) => {
|
||||
const [, updateArgs] = useArgs()
|
||||
const reset = () => updateArgs(defaults)
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "12px", "justify-items": "start" }}>
|
||||
<mod.TextShimmer {...args} />
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
"font-size": "12px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reset controls
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Inactive = {
|
||||
args: {
|
||||
@@ -49,11 +90,3 @@ export const Inactive = {
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomTiming = {
|
||||
args: {
|
||||
text: "Custom timing",
|
||||
stepMs: 80,
|
||||
durationMs: 1800,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, createMemo, type ValidComponent } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type ValidComponent } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
||||
@@ -6,31 +6,56 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
||||
class?: string
|
||||
as?: T
|
||||
active?: boolean
|
||||
stepMs?: number
|
||||
durationMs?: number
|
||||
offset?: number
|
||||
}) => {
|
||||
const chars = createMemo(() => Array.from(props.text))
|
||||
const active = () => props.active ?? true
|
||||
const active = createMemo(() => props.active ?? true)
|
||||
const offset = createMemo(() => props.offset ?? 0)
|
||||
const [run, setRun] = createSignal(active())
|
||||
const swap = 220
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
if (active()) {
|
||||
setRun(true)
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
setRun(false)
|
||||
}, swap)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
clearTimeout(timer)
|
||||
})
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={props.as || "span"}
|
||||
component={props.as ?? "span"}
|
||||
data-component="text-shimmer"
|
||||
data-active={active()}
|
||||
data-active={active() ? "true" : "false"}
|
||||
class={props.class}
|
||||
aria-label={props.text}
|
||||
style={{
|
||||
"--text-shimmer-step": `${props.stepMs ?? 45}ms`,
|
||||
"--text-shimmer-duration": `${props.durationMs ?? 1200}ms`,
|
||||
"--text-shimmer-swap": `${swap}ms`,
|
||||
"--text-shimmer-index": `${offset()}`,
|
||||
}}
|
||||
>
|
||||
<For each={chars()}>
|
||||
{(char, index) => (
|
||||
<span data-slot="text-shimmer-char" aria-hidden="true" style={{ "--text-shimmer-index": `${index()}` }}>
|
||||
{char}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<span data-slot="text-shimmer-char">
|
||||
<span data-slot="text-shimmer-char-base" aria-hidden="true">
|
||||
{props.text}
|
||||
</span>
|
||||
<span data-slot="text-shimmer-char-shimmer" data-run={run() ? "true" : "false"} aria-hidden="true">
|
||||
{props.text}
|
||||
</span>
|
||||
</span>
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
27
packages/ui/src/components/text-strikethrough.css
Normal file
27
packages/ui/src/components/text-strikethrough.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* TextStrikethrough — spring-animated strikethrough line
|
||||
*
|
||||
* Draws a line-through from left to right using clip-path on a
|
||||
* transparent-text overlay that carries the text-decoration.
|
||||
* Grid stacking (grid-area: 1/1) layers the overlay on the base text.
|
||||
*
|
||||
* Key trick: -webkit-text-fill-color hides the glyph paint while
|
||||
* keeping `color` (and therefore `currentColor` / text-decoration-color)
|
||||
* set to the real inherited text color.
|
||||
*/
|
||||
|
||||
[data-component="text-strikethrough"] {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
[data-slot="text-strikethrough-line"] {
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-decoration-line: line-through;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="text-strikethrough-line"] {
|
||||
clip-path: none !important;
|
||||
}
|
||||
}
|
||||
279
packages/ui/src/components/text-strikethrough.stories.tsx
Normal file
279
packages/ui/src/components/text-strikethrough.stories.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useSpring } from "./motion-spring"
|
||||
import { TextStrikethrough } from "./text-strikethrough"
|
||||
|
||||
const TEXT_SHORT = "Remove inline measure nodes"
|
||||
const TEXT_MED = "Remove inline measure nodes and keep width morph behavior intact"
|
||||
const TEXT_LONG =
|
||||
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)"
|
||||
|
||||
const btn = (active?: boolean) =>
|
||||
({
|
||||
padding: "8px 18px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #444)",
|
||||
background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "14px",
|
||||
"font-weight": "500",
|
||||
}) as const
|
||||
|
||||
const heading = {
|
||||
"font-size": "11px",
|
||||
"font-weight": "600",
|
||||
"text-transform": "uppercase" as const,
|
||||
"letter-spacing": "0.05em",
|
||||
color: "var(--text-weak, #888)",
|
||||
"margin-bottom": "4px",
|
||||
}
|
||||
|
||||
const card = {
|
||||
padding: "16px 20px",
|
||||
"border-radius": "10px",
|
||||
border: "1px solid var(--border-weak-base, #333)",
|
||||
background: "var(--surface-base, #1a1a1a)",
|
||||
}
|
||||
|
||||
/* ─── Variant A: scaleX pseudo-line at 50% ─── */
|
||||
function VariantA(props: { active: boolean; text: string }) {
|
||||
const progress = useSpring(
|
||||
() => (props.active ? 1 : 0),
|
||||
() => ({ visualDuration: 0.35, bounce: 0 }),
|
||||
)
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "block",
|
||||
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
|
||||
transition: "color 220ms ease",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
right: "0",
|
||||
top: "50%",
|
||||
height: "1.5px",
|
||||
background: "currentColor",
|
||||
"transform-origin": "left center",
|
||||
transform: `scaleX(${progress()})`,
|
||||
"pointer-events": "none",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Variant D: background-image line ─── */
|
||||
function VariantD(props: { active: boolean; text: string }) {
|
||||
const progress = useSpring(
|
||||
() => (props.active ? 1 : 0),
|
||||
() => ({ visualDuration: 0.35, bounce: 0 }),
|
||||
)
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
|
||||
transition: "color 220ms ease",
|
||||
"background-image": "linear-gradient(currentColor, currentColor)",
|
||||
"background-repeat": "no-repeat",
|
||||
"background-size": `${progress() * 100}% 1.5px`,
|
||||
"background-position": "left center",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Variant E: grid stacking + clip-path (container %) ─── */
|
||||
function VariantE(props: { active: boolean; text: string }) {
|
||||
const progress = useSpring(
|
||||
() => (props.active ? 1 : 0),
|
||||
() => ({ visualDuration: 0.35, bounce: 0 }),
|
||||
)
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "grid",
|
||||
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
|
||||
transition: "color 220ms ease",
|
||||
}}
|
||||
>
|
||||
<span style={{ "grid-area": "1 / 1" }}>{props.text}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
"grid-area": "1 / 1",
|
||||
"text-decoration": "line-through",
|
||||
"pointer-events": "none",
|
||||
"clip-path": `inset(0 ${(1 - progress()) * 100}% 0 0)`,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Variant F: grid stacking + clip-path mapped to text width ─── */
|
||||
function VariantF(props: { active: boolean; text: string }) {
|
||||
const progress = useSpring(
|
||||
() => (props.active ? 1 : 0),
|
||||
() => ({ visualDuration: 0.35, bounce: 0 }),
|
||||
)
|
||||
let baseRef: HTMLSpanElement | undefined
|
||||
let containerRef: HTMLSpanElement | undefined
|
||||
const [textWidth, setTextWidth] = createSignal(0)
|
||||
const [containerWidth, setContainerWidth] = createSignal(0)
|
||||
|
||||
const measure = () => {
|
||||
if (baseRef) setTextWidth(baseRef.scrollWidth)
|
||||
if (containerRef) setContainerWidth(containerRef.offsetWidth)
|
||||
}
|
||||
|
||||
onMount(measure)
|
||||
createEffect(() => {
|
||||
const el = containerRef
|
||||
if (!el) return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
const clipRight = () => {
|
||||
const cw = containerWidth()
|
||||
const tw = textWidth()
|
||||
if (cw <= 0 || tw <= 0) return `${(1 - progress()) * 100}%`
|
||||
const revealed = progress() * tw
|
||||
const remaining = Math.max(0, cw - revealed)
|
||||
return `${remaining}px`
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: "grid",
|
||||
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
|
||||
transition: "color 220ms ease",
|
||||
}}
|
||||
>
|
||||
<span ref={baseRef} style={{ "grid-area": "1 / 1" }}>
|
||||
{props.text}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
"grid-area": "1 / 1",
|
||||
"text-decoration": "line-through",
|
||||
"pointer-events": "none",
|
||||
"clip-path": `inset(0 ${clipRight()} 0 0)`,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "UI/Text Strikethrough",
|
||||
id: "components-text-strikethrough",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Animated Strikethrough Variants
|
||||
|
||||
- **A** — scaleX line at 50% (single line only)
|
||||
- **D** — background-image line (single line only)
|
||||
- **E** — grid stacking + clip-path (container %)
|
||||
- **F** — grid stacking + clip-path mapped to text width (the real component)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [active, setActive] = createSignal(false)
|
||||
const toggle = () => setActive((v) => !v)
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "24px", padding: "24px", "max-width": "700px" }}>
|
||||
<button onClick={toggle} style={btn(active())}>
|
||||
{active() ? "Undo strikethrough" : "Strike through all"}
|
||||
</button>
|
||||
|
||||
<div style={card}>
|
||||
<div style={heading}>F — grid stacking + clip mapped to text width (THE COMPONENT)</div>
|
||||
<TextStrikethrough
|
||||
active={active()}
|
||||
text={TEXT_SHORT}
|
||||
style={{
|
||||
color: active() ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
|
||||
transition: "color 220ms ease",
|
||||
}}
|
||||
/>
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<TextStrikethrough
|
||||
active={active()}
|
||||
text={TEXT_MED}
|
||||
style={{
|
||||
color: active() ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
|
||||
transition: "color 220ms ease",
|
||||
}}
|
||||
/>
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<TextStrikethrough
|
||||
active={active()}
|
||||
text={TEXT_LONG}
|
||||
style={{
|
||||
color: active() ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
|
||||
transition: "color 220ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<div style={heading}>F (inline) — same but just inline variants</div>
|
||||
<VariantF active={active()} text={TEXT_SHORT} />
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<VariantF active={active()} text={TEXT_MED} />
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<VariantF active={active()} text={TEXT_LONG} />
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<div style={heading}>E — grid stacking + clip-path (container %)</div>
|
||||
<VariantE active={active()} text={TEXT_SHORT} />
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<VariantE active={active()} text={TEXT_MED} />
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<VariantE active={active()} text={TEXT_LONG} />
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<div style={heading}>A — scaleX line at 50%</div>
|
||||
<VariantA active={active()} text={TEXT_SHORT} />
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<VariantA active={active()} text={TEXT_LONG} />
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<div style={heading}>D — background-image line</div>
|
||||
<VariantD active={active()} text={TEXT_SHORT} />
|
||||
<div style={{ "margin-top": "12px" }} />
|
||||
<VariantD active={active()} text={TEXT_LONG} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
85
packages/ui/src/components/text-strikethrough.tsx
Normal file
85
packages/ui/src/components/text-strikethrough.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useSpring } from "./motion-spring"
|
||||
|
||||
export function TextStrikethrough(props: {
|
||||
/** Whether the strikethrough is active (line drawn across). */
|
||||
active: boolean
|
||||
/** The text to display. Rendered twice internally (base + decoration overlay). */
|
||||
text: string
|
||||
/** Spring visual duration in seconds. Default 0.35. */
|
||||
visualDuration?: number
|
||||
class?: string
|
||||
style?: JSX.CSSProperties
|
||||
}) {
|
||||
const progress = useSpring(
|
||||
() => (props.active ? 1 : 0),
|
||||
() => ({ visualDuration: props.visualDuration ?? 0.35, bounce: 0 }),
|
||||
)
|
||||
|
||||
let baseRef: HTMLSpanElement | undefined
|
||||
let containerRef: HTMLSpanElement | undefined
|
||||
const [textWidth, setTextWidth] = createSignal(0)
|
||||
const [containerWidth, setContainerWidth] = createSignal(0)
|
||||
|
||||
const measure = () => {
|
||||
if (baseRef) setTextWidth(baseRef.scrollWidth)
|
||||
if (containerRef) setContainerWidth(containerRef.offsetWidth)
|
||||
}
|
||||
|
||||
onMount(measure)
|
||||
|
||||
createEffect(() => {
|
||||
const el = containerRef
|
||||
if (!el) return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
// Revealed pixels from left = progress * textWidth
|
||||
const revealedPx = () => {
|
||||
const tw = textWidth()
|
||||
return tw > 0 ? progress() * tw : 0
|
||||
}
|
||||
|
||||
// Overlay clip: hide everything to the right of revealed area
|
||||
const overlayClip = () => {
|
||||
const cw = containerWidth()
|
||||
const tw = textWidth()
|
||||
if (cw <= 0 || tw <= 0) return `inset(0 ${(1 - progress()) * 100}% 0 0)`
|
||||
const remaining = Math.max(0, cw - revealedPx())
|
||||
return `inset(0 ${remaining}px 0 0)`
|
||||
}
|
||||
|
||||
// Base clip: hide everything to the left of revealed area (complementary)
|
||||
const baseClip = () => {
|
||||
const px = revealedPx()
|
||||
if (px <= 0.5) return "none"
|
||||
return `inset(0 0 0 ${px}px)`
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-component="text-strikethrough"
|
||||
class={props.class}
|
||||
style={{ display: "grid", ...props.style }}
|
||||
ref={containerRef}
|
||||
>
|
||||
<span ref={baseRef} style={{ "grid-area": "1 / 1", "clip-path": baseClip() }}>
|
||||
{props.text}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
"grid-area": "1 / 1",
|
||||
"text-decoration": "line-through",
|
||||
"pointer-events": "none",
|
||||
"clip-path": overlayClip(),
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
837
packages/ui/src/components/thinking-heading.stories.tsx
Normal file
837
packages/ui/src/components/thinking-heading.stories.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal, createEffect, on, onMount, onCleanup } from "solid-js"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
|
||||
export default {
|
||||
title: "UI/ThinkingHeading",
|
||||
id: "components-thinking-heading",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Playground for animating the secondary heading beside "Thinking".
|
||||
|
||||
Uses TextReveal for the production heading animation with tunable
|
||||
duration, travel, bounce, and fade controls.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const HEADINGS = [
|
||||
"Planning key generation details",
|
||||
"Analyzing error handling",
|
||||
undefined,
|
||||
"Reviewing authentication flow",
|
||||
"Considering edge cases",
|
||||
"Evaluating performance",
|
||||
"Structuring the response",
|
||||
"Checking type safety",
|
||||
"Designing the API surface",
|
||||
"Mapping dependencies",
|
||||
"Outlining test strategy",
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS
|
||||
//
|
||||
// Custom properties driven by sliders:
|
||||
// --h-duration transition duration (e.g. "600ms")
|
||||
// --h-duration-raw unitless number for calc (e.g. "600")
|
||||
// --h-blur blur radius (e.g. "4px")
|
||||
// --h-travel vertical travel distance (e.g. "18px")
|
||||
// --h-spring full cubic-bezier for movement (set from bounce slider)
|
||||
// --h-spring-soft softer version for width transitions
|
||||
// --h-mask-size fade depth at top/bottom of odometer mask
|
||||
// --h-mask-pad base padding-block on odometer track
|
||||
// --h-mask-height extra vertical mask area per side
|
||||
// --h-mask-bg background color for fade overlays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STYLES = `
|
||||
/* ── shared base ────────────────────────────────────────────────── */
|
||||
[data-variant] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-variant] [data-slot="track"] {
|
||||
display: grid;
|
||||
overflow: visible;
|
||||
min-height: 20px;
|
||||
justify-items: start;
|
||||
align-items: center;
|
||||
transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1));
|
||||
}
|
||||
|
||||
[data-variant] [data-slot="entering"],
|
||||
[data-variant] [data-slot="leaving"] {
|
||||
grid-area: 1 / 1;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
/* kill transitions before fonts are ready */
|
||||
[data-variant][data-ready="false"] [data-slot="track"],
|
||||
[data-variant][data-ready="false"] [data-slot="entering"],
|
||||
[data-variant][data-ready="false"] [data-slot="leaving"] {
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── 1. spring-up ───────────────────────────────────────────────── *
|
||||
* New text rises from below, old text exits upward. */
|
||||
|
||||
[data-variant="spring-up"] [data-slot="entering"],
|
||||
[data-variant="spring-up"] [data-slot="leaving"] {
|
||||
transition-property: transform, opacity, filter;
|
||||
transition-duration:
|
||||
var(--h-duration, 600ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
|
||||
transition-timing-function: var(--h-spring), ease-out, ease-out;
|
||||
}
|
||||
[data-variant="spring-up"] [data-slot="entering"] {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
[data-variant="spring-up"] [data-slot="leaving"] {
|
||||
transform: translateY(calc(var(--h-travel, 18px) * -1));
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 0px));
|
||||
}
|
||||
[data-variant="spring-up"][data-swapping="true"] [data-slot="entering"] {
|
||||
transform: translateY(var(--h-travel, 18px));
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 0px));
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
[data-variant="spring-up"][data-swapping="true"] [data-slot="leaving"] {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── 2. spring-down ─────────────────────────────────────────────── *
|
||||
* New text drops from above, old text exits downward. */
|
||||
|
||||
[data-variant="spring-down"] [data-slot="entering"],
|
||||
[data-variant="spring-down"] [data-slot="leaving"] {
|
||||
transition-property: transform, opacity, filter;
|
||||
transition-duration:
|
||||
var(--h-duration, 600ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
|
||||
transition-timing-function: var(--h-spring), ease-out, ease-out;
|
||||
}
|
||||
[data-variant="spring-down"] [data-slot="entering"] {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
[data-variant="spring-down"] [data-slot="leaving"] {
|
||||
transform: translateY(var(--h-travel, 18px));
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 0px));
|
||||
}
|
||||
[data-variant="spring-down"][data-swapping="true"] [data-slot="entering"] {
|
||||
transform: translateY(calc(var(--h-travel, 18px) * -1));
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 0px));
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
[data-variant="spring-down"][data-swapping="true"] [data-slot="leaving"] {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── 3. spring-pop ──────────────────────────────────────────────── *
|
||||
* Scale + slight vertical shift + blur. Playful, bouncy. */
|
||||
|
||||
[data-variant="spring-pop"] [data-slot="entering"],
|
||||
[data-variant="spring-pop"] [data-slot="leaving"] {
|
||||
transition-property: transform, opacity, filter;
|
||||
transition-duration:
|
||||
var(--h-duration, 600ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.55 * 1ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.55 * 1ms);
|
||||
transition-timing-function: var(--h-spring), ease-out, ease-out;
|
||||
transform-origin: left center;
|
||||
}
|
||||
[data-variant="spring-pop"] [data-slot="entering"] {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
[data-variant="spring-pop"] [data-slot="leaving"] {
|
||||
transform: translateY(calc(var(--h-travel, 18px) * -0.35)) scale(0.92);
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 3px));
|
||||
}
|
||||
[data-variant="spring-pop"][data-swapping="true"] [data-slot="entering"] {
|
||||
transform: translateY(calc(var(--h-travel, 18px) * 0.35)) scale(0.92);
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 3px));
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
[data-variant="spring-pop"][data-swapping="true"] [data-slot="leaving"] {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── 4. spring-blur ─────────────────────────────────────────────── *
|
||||
* Pure crossfade with heavy blur. No vertical movement. *
|
||||
* Width still animates with spring. */
|
||||
|
||||
[data-variant="spring-blur"] [data-slot="entering"],
|
||||
[data-variant="spring-blur"] [data-slot="leaving"] {
|
||||
transition-property: opacity, filter;
|
||||
transition-duration:
|
||||
calc(var(--h-duration-raw, 600) * 0.75 * 1ms),
|
||||
var(--h-duration, 600ms);
|
||||
transition-timing-function: ease-out, var(--h-spring-soft);
|
||||
}
|
||||
[data-variant="spring-blur"] [data-slot="entering"] {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
[data-variant="spring-blur"] [data-slot="leaving"] {
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--h-blur, 4px) * 2));
|
||||
}
|
||||
[data-variant="spring-blur"][data-swapping="true"] [data-slot="entering"] {
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--h-blur, 4px) * 2));
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
[data-variant="spring-blur"][data-swapping="true"] [data-slot="leaving"] {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── 5. odometer ──────────────────────────────────────────────── *
|
||||
* Both texts scroll vertically through a clipped track. *
|
||||
* *
|
||||
* overflow:hidden clips at the padding-box edge. *
|
||||
* mask-image fades to transparent at that same edge. *
|
||||
* Result: content is invisible at the clip boundary → no hard *
|
||||
* edge ever visible. Padding + mask height extend the clip area *
|
||||
* so text has room to travel through the gradient fade zone. *
|
||||
* *
|
||||
* Uses transparent→white which works in both alpha & luminance *
|
||||
* mask modes (transparent=hidden, white=visible in both). */
|
||||
|
||||
[data-variant="odometer"] [data-slot="track"] {
|
||||
--h-mask-stop: min(var(--h-mask-size, 20px), calc(50% - 0.5px));
|
||||
--h-odo-shift: calc(
|
||||
100% + var(--h-travel, 18px) + var(--h-mask-height, 0px) + max(calc(var(--h-mask-pad, 28px) - 28px), 0px)
|
||||
);
|
||||
position: relative;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding-block: calc(var(--h-mask-pad, 28px) + var(--h-mask-height, 0px));
|
||||
margin-block: calc((var(--h-mask-pad, 28px) + var(--h-mask-height, 0px)) * -1);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
white var(--h-mask-stop),
|
||||
white calc(100% - var(--h-mask-stop)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
white var(--h-mask-stop),
|
||||
white calc(100% - var(--h-mask-stop)),
|
||||
transparent 100%
|
||||
);
|
||||
transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1));
|
||||
}
|
||||
|
||||
/* on swap, jump width instantly to the max of both texts */
|
||||
[data-variant="odometer"][data-swapping="true"] [data-slot="track"] {
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
[data-variant="odometer"] [data-slot="entering"],
|
||||
[data-variant="odometer"] [data-slot="leaving"] {
|
||||
transition-property: transform;
|
||||
transition-duration: var(--h-duration, 600ms);
|
||||
transition-timing-function: var(--h-spring);
|
||||
opacity: 1;
|
||||
}
|
||||
/* settled: entering in view, leaving pushed below */
|
||||
[data-variant="odometer"] [data-slot="entering"] {
|
||||
transform: translateY(0);
|
||||
}
|
||||
[data-variant="odometer"] [data-slot="leaving"] {
|
||||
transform: translateY(var(--h-odo-shift));
|
||||
}
|
||||
/* swapping: snap entering above, leaving in-place */
|
||||
[data-variant="odometer"][data-swapping="true"] [data-slot="entering"] {
|
||||
transform: translateY(calc(var(--h-odo-shift) * -1));
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
[data-variant="odometer"][data-swapping="true"] [data-slot="leaving"] {
|
||||
transform: translateY(0);
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
/* ── odometer + blur ──────────────────────────────────────────── *
|
||||
* Optional: adds opacity + blur transitions on top of the *
|
||||
* positional odometer movement. */
|
||||
|
||||
[data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"],
|
||||
[data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] {
|
||||
transition-property: transform, opacity, filter;
|
||||
transition-duration:
|
||||
var(--h-duration, 600ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
|
||||
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
|
||||
}
|
||||
[data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"] {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
[data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] {
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 4px));
|
||||
}
|
||||
[data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="entering"] {
|
||||
opacity: 0;
|
||||
filter: blur(var(--h-blur, 4px));
|
||||
}
|
||||
[data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="leaving"] {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
/* ── debug: show fade zones ───────────────────────────────────── */
|
||||
[data-variant="odometer"][data-debug="true"] [data-slot="track"] {
|
||||
outline: 1px dashed rgba(255, 0, 0, 0.6);
|
||||
}
|
||||
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::before,
|
||||
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--h-mask-stop);
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 0, 0, 0.3), transparent);
|
||||
}
|
||||
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(255, 0, 0, 0.3), transparent);
|
||||
}
|
||||
|
||||
|
||||
/* ── slider styling ─────────────────────────────────────────────── */
|
||||
input[type="range"].heading-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 140px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-divider, #444);
|
||||
outline: none;
|
||||
}
|
||||
input[type="range"].heading-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, #58f);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animated heading component
|
||||
//
|
||||
// Width is measured via scrollWidth (NOT Range.getBoundingClientRect) because
|
||||
// getBoundingClientRect includes CSS transforms — so scale(0.92) during the
|
||||
// swap phase would measure 92% of the real width and permanently clip text.
|
||||
// scrollWidth returns the layout/intrinsic width, unaffected by transforms.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AnimatedHeading(props) {
|
||||
const [current, setCurrent] = createSignal(props.text)
|
||||
const [leaving, setLeaving] = createSignal(undefined)
|
||||
const [width, setWidth] = createSignal("auto")
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [swapping, setSwapping] = createSignal(false)
|
||||
let enterRef
|
||||
let leaveRef
|
||||
let containerRef
|
||||
let frame
|
||||
|
||||
const measureEnter = () => enterRef?.scrollWidth ?? 0
|
||||
const measureLeave = () => leaveRef?.scrollWidth ?? 0
|
||||
const widen = (px) => {
|
||||
if (px <= 0) return
|
||||
const w = Number.parseFloat(width())
|
||||
if (Number.isFinite(w) && px <= w) return
|
||||
setWidth(`${px}px`)
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
if (!current()) {
|
||||
setWidth("0px")
|
||||
return
|
||||
}
|
||||
const px = measureEnter()
|
||||
if (px > 0) setWidth(`${px}px`)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.text,
|
||||
(next, prev) => {
|
||||
if (next === prev) return
|
||||
setSwapping(true)
|
||||
setLeaving(prev)
|
||||
setCurrent(next)
|
||||
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
// For odometer keep width as a grow-only max so heading never shrinks.
|
||||
if (props.variant === "odometer") {
|
||||
const enterW = measureEnter()
|
||||
const leaveW = measureLeave()
|
||||
widen(Math.max(enterW, leaveW))
|
||||
containerRef?.offsetHeight // reflow with max width + swap positions
|
||||
setSwapping(false)
|
||||
} else {
|
||||
containerRef?.offsetHeight
|
||||
setSwapping(false)
|
||||
measure()
|
||||
}
|
||||
frame = undefined
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
measure()
|
||||
document.fonts?.ready.finally(() => {
|
||||
measure()
|
||||
requestAnimationFrame(() => setReady(true))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
data-variant={props.variant}
|
||||
data-ready={ready()}
|
||||
data-swapping={swapping()}
|
||||
data-debug={props.debug ? "true" : undefined}
|
||||
data-odo-blur={props.odoBlur ? "true" : undefined}
|
||||
>
|
||||
<span data-slot="track" style={{ width: width() }}>
|
||||
<span data-slot="entering" ref={enterRef}>
|
||||
{current() ?? "\u00A0"}
|
||||
</span>
|
||||
<span data-slot="leaving" ref={leaveRef}>
|
||||
{leaving() ?? "\u00A0"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Button / layout styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const btn = (accent) => ({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-danger-fill, #c33)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
})
|
||||
|
||||
const smallBtn = (active) => ({
|
||||
padding: "4px 12px",
|
||||
"border-radius": "6px",
|
||||
border: active ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
|
||||
background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "12px",
|
||||
})
|
||||
|
||||
const sliderLabel = {
|
||||
"font-size": "11px",
|
||||
"font-family": "monospace",
|
||||
color: "var(--color-text-weak, #666)",
|
||||
"min-width": "70px",
|
||||
"flex-shrink": "0",
|
||||
"text-align": "right",
|
||||
}
|
||||
|
||||
const sliderValue = {
|
||||
"font-family": "monospace",
|
||||
"font-size": "11px",
|
||||
color: "var(--color-text-weak, #aaa)",
|
||||
"min-width": "60px",
|
||||
}
|
||||
|
||||
const cardLabel = {
|
||||
"font-size": "11px",
|
||||
"font-family": "monospace",
|
||||
color: "var(--color-text-weak, #666)",
|
||||
}
|
||||
|
||||
const thinkingRow = {
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
"min-width": "0",
|
||||
"font-size": "14px",
|
||||
"font-weight": "500",
|
||||
"line-height": "20px",
|
||||
"min-height": "20px",
|
||||
color: "var(--text-weak, #aaa)",
|
||||
}
|
||||
|
||||
const headingSlot = {
|
||||
"min-width": "0",
|
||||
overflow: "visible",
|
||||
"white-space": "nowrap",
|
||||
color: "var(--text-weaker, #888)",
|
||||
"font-weight": "400",
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
padding: "16px 20px",
|
||||
"border-radius": "10px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--h-mask-bg, #1a1a1a)",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VARIANTS: { key: string; label: string }[] = []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Story
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [heading, setHeading] = createSignal(HEADINGS[0])
|
||||
const [headingIndex, setHeadingIndex] = createSignal(0)
|
||||
const [active, setActive] = createSignal(true)
|
||||
const [cycling, setCycling] = createSignal(false)
|
||||
let cycleTimer
|
||||
|
||||
// tunable params
|
||||
const [duration, setDuration] = createSignal(550)
|
||||
const [blur, setBlur] = createSignal(2)
|
||||
const [travel, setTravel] = createSignal(4)
|
||||
const [bounce, setBounce] = createSignal(1.35)
|
||||
const [maskSize, setMaskSize] = createSignal(12)
|
||||
const [maskPad, setMaskPad] = createSignal(9)
|
||||
const [maskHeight, setMaskHeight] = createSignal(0)
|
||||
const [debug, setDebug] = createSignal(false)
|
||||
const [odoBlur, setOdoBlur] = createSignal(false)
|
||||
|
||||
const nextHeading = () => {
|
||||
setHeadingIndex((i) => {
|
||||
const next = (i + 1) % HEADINGS.length
|
||||
setHeading(HEADINGS[next])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const prevHeading = () => {
|
||||
setHeadingIndex((i) => {
|
||||
const prev = (i - 1 + HEADINGS.length) % HEADINGS.length
|
||||
setHeading(HEADINGS[prev])
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCycling = () => {
|
||||
if (cycling()) {
|
||||
clearTimeout(cycleTimer)
|
||||
cycleTimer = undefined
|
||||
setCycling(false)
|
||||
return
|
||||
}
|
||||
setCycling(true)
|
||||
const tick = () => {
|
||||
if (!cycling()) return
|
||||
nextHeading()
|
||||
cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550))
|
||||
}
|
||||
cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550))
|
||||
}
|
||||
|
||||
const clearHeading = () => {
|
||||
setHeading(undefined)
|
||||
if (cycling()) {
|
||||
clearTimeout(cycleTimer)
|
||||
cycleTimer = undefined
|
||||
setCycling(false)
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (cycleTimer) clearTimeout(cycleTimer)
|
||||
})
|
||||
|
||||
const vars = () => ({
|
||||
"--h-duration": `${duration()}ms`,
|
||||
"--h-duration-raw": `${duration()}`,
|
||||
"--h-blur": `${blur()}px`,
|
||||
"--h-travel": `${travel()}px`,
|
||||
"--h-spring": `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`,
|
||||
"--h-spring-soft": `cubic-bezier(0.34, ${Math.max(bounce() * 0.7, 1)}, 0.64, 1)`,
|
||||
"--h-mask-size": `${maskSize()}px`,
|
||||
"--h-mask-pad": `${maskPad()}px`,
|
||||
"--h-mask-height": `${maskHeight()}px`,
|
||||
"--h-mask-bg": "#1a1a1a",
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "820px", ...vars() }}>
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* ── Variant cards ─────────────────────────────────── */}
|
||||
<div style={{ display: "grid", "grid-template-columns": "1fr", gap: "16px" }}>
|
||||
<div style={cardStyle}>
|
||||
<span style={cardLabel}>TextReveal (production)</span>
|
||||
<span style={thinkingRow}>
|
||||
<TextShimmer text="Thinking" active={active()} />
|
||||
<span style={headingSlot}>
|
||||
<TextReveal
|
||||
text={heading()}
|
||||
duration={duration()}
|
||||
travel={25}
|
||||
edge={17}
|
||||
spring={`cubic-bezier(0.34, ${bounce()}, 0.64, 1)`}
|
||||
springSoft={`cubic-bezier(0.34, ${Math.max(bounce() * 0.7, 1)}, 0.64, 1)`}
|
||||
growOnly
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{VARIANTS.map((v) => (
|
||||
<div style={cardStyle}>
|
||||
<span style={cardLabel}>{v.label}</span>
|
||||
<span style={thinkingRow}>
|
||||
<TextShimmer text="Thinking" active={active()} />
|
||||
<span style={headingSlot}>
|
||||
<AnimatedHeading
|
||||
text={heading()}
|
||||
variant={v.key}
|
||||
debug={v.key === "odometer" && debug()}
|
||||
odoBlur={v.key === "odometer" && odoBlur()}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Sliders ──────────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
"border-top": "1px solid var(--color-divider, #333)",
|
||||
"padding-top": "16px",
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>duration</span>
|
||||
<input
|
||||
type="range"
|
||||
class="heading-slider"
|
||||
min={200}
|
||||
max={1400}
|
||||
step={50}
|
||||
value={duration()}
|
||||
onInput={(e) => setDuration(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{duration()}ms</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>blur</span>
|
||||
<input
|
||||
type="range"
|
||||
class="heading-slider"
|
||||
min={0}
|
||||
max={16}
|
||||
step={0.5}
|
||||
value={blur()}
|
||||
onInput={(e) => setBlur(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{blur()}px</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>travel</span>
|
||||
<input
|
||||
type="range"
|
||||
class="heading-slider"
|
||||
min={4}
|
||||
max={120}
|
||||
step={1}
|
||||
value={travel()}
|
||||
onInput={(e) => setTravel(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{travel()}px</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce</span>
|
||||
<input
|
||||
type="range"
|
||||
class="heading-slider"
|
||||
min={1}
|
||||
max={2.2}
|
||||
step={0.05}
|
||||
value={bounce()}
|
||||
onInput={(e) => setBounce(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>
|
||||
{bounce().toFixed(2)} {bounce() <= 1.05 ? "(none)" : bounce() >= 1.9 ? "(heavy)" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>mask</span>
|
||||
<input
|
||||
type="range"
|
||||
class="heading-slider"
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
value={maskSize()}
|
||||
onInput={(e) => setMaskSize(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>
|
||||
{maskSize()}px {maskSize() === 0 ? "(hard)" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>mask pad</span>
|
||||
<input
|
||||
type="range"
|
||||
class="heading-slider"
|
||||
min={0}
|
||||
max={60}
|
||||
step={1}
|
||||
value={maskPad()}
|
||||
onInput={(e) => setMaskPad(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{maskPad()}px</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>mask height</span>
|
||||
<input
|
||||
type="range"
|
||||
class="heading-slider"
|
||||
min={0}
|
||||
max={80}
|
||||
step={1}
|
||||
value={maskHeight()}
|
||||
onInput={(e) => setMaskHeight(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{maskHeight()}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ─────────────────────────────────────── */}
|
||||
<div style={{ display: "grid", gap: "12px" }}>
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={toggleCycling} style={btn(cycling())}>
|
||||
{cycling() ? "Stop sim" : "Simulate jitter"}
|
||||
</button>
|
||||
<button onClick={prevHeading} style={btn()}>
|
||||
Prev
|
||||
</button>
|
||||
<button onClick={nextHeading} style={btn()}>
|
||||
Next
|
||||
</button>
|
||||
<button onClick={clearHeading} style={btn()}>
|
||||
Clear
|
||||
</button>
|
||||
<button onClick={() => setActive((v) => !v)} style={smallBtn(active())}>
|
||||
{active() ? "Shimmer: on" : "Shimmer: off"}
|
||||
</button>
|
||||
<button onClick={() => setDebug((v) => !v)} style={smallBtn(debug())}>
|
||||
{debug() ? "Debug mask: on" : "Debug mask"}
|
||||
</button>
|
||||
<button onClick={() => setOdoBlur((v) => !v)} style={smallBtn(odoBlur())}>
|
||||
{odoBlur() ? "Odo blur: on" : "Odo blur"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
|
||||
{HEADINGS.map((h, i) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setHeadingIndex(i)
|
||||
setHeading(h)
|
||||
}}
|
||||
style={smallBtn(headingIndex() === i)}
|
||||
>
|
||||
{h ?? "(no submessage)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
"font-size": "11px",
|
||||
color: "var(--color-text-weak, #888)",
|
||||
"font-family": "monospace",
|
||||
}}
|
||||
>
|
||||
heading: {heading() ?? "(none)"} · sim: {cycling() ? "on" : "off"} · bounce: {bounce().toFixed(2)} ·
|
||||
odo-blur: {odoBlur() ? "on" : "off"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
584
packages/ui/src/components/todo-panel-motion.stories.tsx
Normal file
584
packages/ui/src/components/todo-panel-motion.stories.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||
|
||||
export default {
|
||||
title: "UI/Todo Panel Motion",
|
||||
id: "components-todo-panel-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
This playground renders the real session composer region from app code.
|
||||
|
||||
### Source path
|
||||
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
|
||||
|
||||
### Includes
|
||||
- \`SessionTodoDock\` (real)
|
||||
- \`PromptInput\` (real)
|
||||
|
||||
No visual reimplementation layer is used for the dock/input stack.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const pool = [
|
||||
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
|
||||
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
|
||||
"Run typechecks/tests and report what changed",
|
||||
"Verify reduced-motion behavior in timeline",
|
||||
"Review diff for animation edge cases",
|
||||
"Document rollout notes in PR description",
|
||||
"Check keyboard and screen reader semantics",
|
||||
"Add storybook controls for iteration speed",
|
||||
]
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const css = `
|
||||
[data-component="todo-stage"] {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
[data-component="todo-preview"] {
|
||||
height: 560px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-component="todo-session-root"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
[data-component="todo-session-frame"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-component="todo-session-panel"] {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-content"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-scroll"] {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-spacer"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"] {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-weak);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"][data-strong="true"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
`
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const global = useGlobalSync()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const [step, setStep] = createSignal(1)
|
||||
const [dockOpenDuration, setDockOpenDuration] = createSignal(0.3)
|
||||
const [dockOpenBounce, setDockOpenBounce] = createSignal(0)
|
||||
const [dockCloseDuration, setDockCloseDuration] = createSignal(0.3)
|
||||
const [dockCloseBounce, setDockCloseBounce] = createSignal(0)
|
||||
const [drawerExpandDuration, setDrawerExpandDuration] = createSignal(0.3)
|
||||
const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0)
|
||||
const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3)
|
||||
const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0)
|
||||
const [subtitleDuration, setSubtitleDuration] = createSignal(600)
|
||||
const [subtitleAuto, setSubtitleAuto] = createSignal(true)
|
||||
const [subtitleTravel, setSubtitleTravel] = createSignal(25)
|
||||
const [subtitleEdge, setSubtitleEdge] = createSignal(17)
|
||||
const [countDuration, setCountDuration] = createSignal(600)
|
||||
const [countMask, setCountMask] = createSignal(18)
|
||||
const [countMaskHeight, setCountMaskHeight] = createSignal(0)
|
||||
const [countWidthDuration, setCountWidthDuration] = createSignal(560)
|
||||
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let composerRef
|
||||
let scrollRef
|
||||
|
||||
const todos = createMemo<Todo[]>(() => {
|
||||
const done = Math.max(0, Math.min(3, step()))
|
||||
return pool.slice(0, 3).map((content, i) => ({
|
||||
id: `todo-${i + 1}`,
|
||||
content,
|
||||
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
|
||||
}))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
global.todo.set("story-session", todos())
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
if (!scrollRef) return
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
|
||||
const collapsed = () =>
|
||||
!!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
|
||||
|
||||
const setCollapsed = (value: boolean) => {
|
||||
const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
|
||||
if (!(button instanceof HTMLButtonElement)) return
|
||||
if (collapsed() === value) return
|
||||
button.click()
|
||||
}
|
||||
|
||||
const openDock = () => {
|
||||
clear()
|
||||
setOpen(true)
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
frame = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const closeDock = () => {
|
||||
clear()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const dockOpen = () => open()
|
||||
|
||||
const toggleDock = () => {
|
||||
if (dockOpen()) {
|
||||
closeDock()
|
||||
return
|
||||
}
|
||||
openDock()
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
if (!dockOpen()) {
|
||||
openDock()
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
setCollapsed(true)
|
||||
frame = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setCollapsed(!collapsed())
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
setStep((value) => (value + 1) % 4)
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
return (
|
||||
<div data-component="todo-stage">
|
||||
<style>{css}</style>
|
||||
|
||||
<div data-component="todo-preview">
|
||||
<div data-component="todo-session-root">
|
||||
<div data-component="todo-session-frame">
|
||||
<div data-component="todo-session-panel">
|
||||
<div data-slot="todo-preview-content">
|
||||
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
|
||||
<div data-slot="todo-preview-spacer" />
|
||||
<div data-slot="todo-preview-msg" data-strong="true">
|
||||
Thinking Checking type safety
|
||||
</div>
|
||||
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={composerRef}>
|
||||
<SessionComposerRegion
|
||||
state={state}
|
||||
centered={false}
|
||||
inputRef={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
onSubmit={() => {}}
|
||||
onResponseSubmit={pin}
|
||||
setPromptDockRef={() => {}}
|
||||
dockOpenVisualDuration={dockOpenDuration()}
|
||||
dockOpenBounce={dockOpenBounce()}
|
||||
dockCloseVisualDuration={dockCloseDuration()}
|
||||
dockCloseBounce={dockCloseBounce()}
|
||||
drawerExpandVisualDuration={drawerExpandDuration()}
|
||||
drawerExpandBounce={drawerExpandBounce()}
|
||||
drawerCollapseVisualDuration={drawerCollapseDuration()}
|
||||
drawerCollapseBounce={drawerCollapseBounce()}
|
||||
subtitleDuration={subtitleDuration()}
|
||||
subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
|
||||
subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
|
||||
countDuration={countDuration()}
|
||||
countMask={countMask()}
|
||||
countMaskHeight={countMaskHeight()}
|
||||
countWidthDuration={countWidthDuration()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={toggleDock} style={btn(dockOpen())}>
|
||||
{dockOpen() ? "Animate close" : "Animate open"}
|
||||
</button>
|
||||
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
|
||||
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
|
||||
</button>
|
||||
<button onClick={cycle} style={btn(step() > 0)}>
|
||||
Cycle progress ({step()}/3 done)
|
||||
</button>
|
||||
{[0, 1, 2, 3].map((value) => (
|
||||
<button onClick={() => setStep(value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenDuration()}
|
||||
onInput={(event) => setDockOpenDuration(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockOpenDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenBounce()}
|
||||
onInput={(event) => setDockOpenBounce(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockOpenBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Dock close
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseDuration()}
|
||||
onInput={(event) => setDockCloseDuration(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockCloseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseBounce()}
|
||||
onInput={(event) => setDockCloseBounce(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockCloseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer expand
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandDuration()}
|
||||
onInput={(event) => setDrawerExpandDuration(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerExpandDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandBounce()}
|
||||
onInput={(event) => setDrawerExpandBounce(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerExpandBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer collapse
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseDuration()}
|
||||
onInput={(event) => setDrawerCollapseDuration(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerCollapseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseBounce()}
|
||||
onInput={(event) => setDrawerCollapseBounce(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerCollapseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Subtitle odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={subtitleDuration()}
|
||||
onInput={(event) => setSubtitleDuration(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(subtitleDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
auto fit
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitleAuto()}
|
||||
onInput={(event) => setSubtitleAuto(event.currentTarget.checked)}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{subtitleAuto() ? "on" : "off"}
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
travel
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleTravel()}
|
||||
onInput={(event) => setSubtitleTravel(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
edge
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleEdge()}
|
||||
onInput={(event) => setSubtitleEdge(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Count odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={countDuration()}
|
||||
onInput={(event) => setCountDuration(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="40"
|
||||
step="1"
|
||||
value={countMask()}
|
||||
onInput={(event) => setCountMask(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask height
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="14"
|
||||
step="1"
|
||||
value={countMaskHeight()}
|
||||
onInput={(event) => setCountMaskHeight(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
width spring
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1200"
|
||||
step="10"
|
||||
value={countWidthDuration()}
|
||||
onInput={(event) => setCountWidthDuration(event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countWidthDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
57
packages/ui/src/components/tool-count-label.css
Normal file
57
packages/ui/src/components/tool-count-label.css
Normal file
@@ -0,0 +1,57 @@
|
||||
[data-component="tool-count-label"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
gap: 0;
|
||||
|
||||
[data-slot="tool-count-label-before"] {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-word"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: pre;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-stem"] {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-suffix"] {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 0fr;
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
|
||||
overflow: hidden;
|
||||
transform: translateX(-0.04em);
|
||||
transition-property: grid-template-columns, opacity, filter, transform;
|
||||
transition-duration: 250ms, 250ms, 250ms, 250ms;
|
||||
transition-timing-function:
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-suffix"][data-active="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-suffix-inner"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="tool-count-label"] [data-slot="tool-count-label-suffix"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
58
packages/ui/src/components/tool-count-label.tsx
Normal file
58
packages/ui/src/components/tool-count-label.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { AnimatedNumber } from "./animated-number"
|
||||
|
||||
function split(text: string) {
|
||||
const match = /{{\s*count\s*}}/.exec(text)
|
||||
if (!match) return { before: "", after: text }
|
||||
if (match.index === undefined) return { before: "", after: text }
|
||||
return {
|
||||
before: text.slice(0, match.index),
|
||||
after: text.slice(match.index + match[0].length),
|
||||
}
|
||||
}
|
||||
|
||||
function common(one: string, other: string) {
|
||||
const a = Array.from(one)
|
||||
const b = Array.from(other)
|
||||
let i = 0
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
||||
return {
|
||||
stem: a.slice(0, i).join(""),
|
||||
one: a.slice(i).join(""),
|
||||
other: b.slice(i).join(""),
|
||||
}
|
||||
}
|
||||
|
||||
export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
|
||||
const one = createMemo(() => split(props.one))
|
||||
const other = createMemo(() => split(props.other))
|
||||
const singular = createMemo(() => Math.round(props.count) === 1)
|
||||
const active = createMemo(() => (singular() ? one() : other()))
|
||||
const suffix = createMemo(() => common(one().after, other().after))
|
||||
const splitSuffix = createMemo(
|
||||
() =>
|
||||
one().before === other().before &&
|
||||
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
|
||||
)
|
||||
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
|
||||
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
|
||||
const tail = createMemo(() => {
|
||||
if (!splitSuffix()) return ""
|
||||
if (singular()) return suffix().one
|
||||
return suffix().other
|
||||
})
|
||||
const showTail = createMemo(() => splitSuffix() && tail().length > 0)
|
||||
|
||||
return (
|
||||
<span data-component="tool-count-label" class={props.class}>
|
||||
<span data-slot="tool-count-label-before">{before()}</span>
|
||||
<AnimatedNumber value={props.count} />
|
||||
<span data-slot="tool-count-label-word">
|
||||
<span data-slot="tool-count-label-stem">{stem()}</span>
|
||||
<span data-slot="tool-count-label-suffix" data-active={showTail() ? "true" : "false"}>
|
||||
<span data-slot="tool-count-label-suffix-inner">{tail()}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
102
packages/ui/src/components/tool-count-summary.css
Normal file
102
packages/ui/src/components/tool-count-summary.css
Normal file
@@ -0,0 +1,102 @@
|
||||
[data-component="tool-count-summary"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
|
||||
[data-slot="tool-count-summary-empty"] {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: baseline;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scale(1);
|
||||
overflow: hidden;
|
||||
transform-origin: left center;
|
||||
transition-property: grid-template-columns, opacity, filter, transform;
|
||||
transition-duration:
|
||||
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms),
|
||||
var(--tool-motion-spring-ms, 480ms);
|
||||
transition-timing-function:
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-empty"][data-active="false"] {
|
||||
grid-template-columns: 0fr;
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.72));
|
||||
transform: translateY(0.05em) scale(0.985);
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-item"] {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 0fr;
|
||||
align-items: baseline;
|
||||
opacity: 0;
|
||||
filter: blur(var(--tool-motion-blur, 2px));
|
||||
transform: translateY(0.06em) scale(0.985);
|
||||
overflow: hidden;
|
||||
transform-origin: left center;
|
||||
transition-property: grid-template-columns, opacity, filter, transform;
|
||||
transition-duration:
|
||||
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms),
|
||||
var(--tool-motion-spring-ms, 480ms);
|
||||
transition-timing-function:
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-item"][data-active="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-empty-inner"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-item-inner"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-prefix"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-start;
|
||||
max-width: 0;
|
||||
margin-right: 0;
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
|
||||
overflow: hidden;
|
||||
transform: translateX(-0.08em);
|
||||
transition-property: opacity, filter, transform;
|
||||
transition-duration:
|
||||
calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75),
|
||||
calc(var(--tool-motion-fade-ms, 220ms) * 0.6);
|
||||
transition-timing-function: ease-out, ease-out, ease-out;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-prefix"][data-active="true"] {
|
||||
max-width: 1ch;
|
||||
margin-right: 0.45ch;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="tool-count-summary"] [data-slot="tool-count-summary-empty"],
|
||||
[data-component="tool-count-summary"] [data-slot="tool-count-summary-item"],
|
||||
[data-component="tool-count-summary"] [data-slot="tool-count-summary-prefix"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
230
packages/ui/src/components/tool-count-summary.stories.tsx
Normal file
230
packages/ui/src/components/tool-count-summary.stories.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { AnimatedCountList, type CountItem } from "./tool-count-summary"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
|
||||
export default {
|
||||
title: "UI/AnimatedCountList",
|
||||
id: "components-animated-count-list",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Animated count list that smoothly transitions items in/out as counts change.
|
||||
|
||||
Uses \`grid-template-columns: 0fr → 1fr\` for width animations and the odometer
|
||||
digit roller for count transitions. Shown here with \`ToolStatusTitle\` exactly
|
||||
as it appears in the context tool group on the session page.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
active: "Exploring",
|
||||
done: "Explored",
|
||||
read: { one: "{{count}} read", other: "{{count}} reads" },
|
||||
search: { one: "{{count}} search", other: "{{count}} searches" },
|
||||
list: { one: "{{count}} list", other: "{{count}} lists" },
|
||||
} as const
|
||||
|
||||
function rand(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-danger-fill, #c33)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const smallBtn = (active?: boolean) =>
|
||||
({
|
||||
padding: "4px 12px",
|
||||
"border-radius": "6px",
|
||||
border: active ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
|
||||
background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "12px",
|
||||
}) as const
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [reads, setReads] = createSignal(0)
|
||||
const [searches, setSearches] = createSignal(0)
|
||||
const [lists, setLists] = createSignal(0)
|
||||
const [active, setActive] = createSignal(false)
|
||||
const [reducedMotion, setReducedMotion] = createSignal(false)
|
||||
|
||||
let timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
const clearAll = () => {
|
||||
for (const t of timeouts) clearTimeout(t)
|
||||
timeouts = []
|
||||
}
|
||||
|
||||
onCleanup(clearAll)
|
||||
|
||||
const startSim = () => {
|
||||
clearAll()
|
||||
setReads(0)
|
||||
setSearches(0)
|
||||
setLists(0)
|
||||
setActive(true)
|
||||
const steps = rand(3, 10)
|
||||
let elapsed = 0
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const delay = rand(300, 800)
|
||||
elapsed += delay
|
||||
const t = setTimeout(() => {
|
||||
const pick = rand(0, 2)
|
||||
if (pick === 0) setReads((n) => n + 1)
|
||||
else if (pick === 1) setSearches((n) => n + 1)
|
||||
else setLists((n) => n + 1)
|
||||
}, elapsed)
|
||||
timeouts.push(t)
|
||||
}
|
||||
|
||||
const end = setTimeout(() => setActive(false), elapsed + 100)
|
||||
timeouts.push(end)
|
||||
}
|
||||
|
||||
const stopSim = () => {
|
||||
clearAll()
|
||||
setActive(false)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
stopSim()
|
||||
setReads(0)
|
||||
setSearches(0)
|
||||
setLists(0)
|
||||
}
|
||||
|
||||
const items = (): CountItem[] => [
|
||||
{ key: "read", count: reads(), one: TEXT.read.one, other: TEXT.read.other },
|
||||
{ key: "search", count: searches(), one: TEXT.search.one, other: TEXT.search.other },
|
||||
{ key: "list", count: lists(), one: TEXT.list.one, other: TEXT.list.other },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "520px" }}>
|
||||
{reducedMotion() && (
|
||||
<style>
|
||||
{`[data-reduced-motion="true"] *,
|
||||
[data-reduced-motion="true"] *::before,
|
||||
[data-reduced-motion="true"] *::after {
|
||||
transition-duration: 0ms !important;
|
||||
}`}
|
||||
</style>
|
||||
)}
|
||||
|
||||
{/* Matches context-tool-group-trigger layout from message-part.tsx */}
|
||||
<span
|
||||
data-reduced-motion={reducedMotion()}
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
"font-size": "14px",
|
||||
"font-weight": "500",
|
||||
color: "var(--text-strong, #eee)",
|
||||
"min-width": "0",
|
||||
}}
|
||||
>
|
||||
<span style={{ "flex-shrink": "0" }}>
|
||||
<ToolStatusTitle active={active()} activeText={TEXT.active} doneText={TEXT.done} split={false} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
"min-width": "0",
|
||||
overflow: "hidden",
|
||||
"text-overflow": "ellipsis",
|
||||
"white-space": "nowrap",
|
||||
"font-weight": "400",
|
||||
color: "var(--text-base, #ccc)",
|
||||
}}
|
||||
>
|
||||
<AnimatedCountList items={items()} fallback="" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={() => (active() ? stopSim() : startSim())} style={btn(active())}>
|
||||
{active() ? "Stop" : "Simulate"}
|
||||
</button>
|
||||
<button onClick={reset} style={btn()}>
|
||||
Reset
|
||||
</button>
|
||||
<button onClick={() => setReducedMotion((v) => !v)} style={smallBtn(reducedMotion())}>
|
||||
{reducedMotion() ? "Motion: reduced" : "Motion: normal"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={() => setReads((n) => n + 1)} style={smallBtn()}>
|
||||
+ read
|
||||
</button>
|
||||
<button onClick={() => setSearches((n) => n + 1)} style={smallBtn()}>
|
||||
+ search
|
||||
</button>
|
||||
<button onClick={() => setLists((n) => n + 1)} style={smallBtn()}>
|
||||
+ list
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
"font-size": "11px",
|
||||
color: "var(--color-text-weak, #888)",
|
||||
"font-family": "monospace",
|
||||
}}
|
||||
>
|
||||
motion: {reducedMotion() ? "reduced" : "normal"} · active: {active() ? "true" : "false"} · reads: {reads()} ·
|
||||
searches: {searches()} · lists: {lists()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty = {
|
||||
render: () => (
|
||||
<span style={{ display: "flex", "align-items": "center", gap: "8px", "font-size": "14px", "font-weight": "500" }}>
|
||||
<ToolStatusTitle active activeText="Exploring" doneText="Explored" split={false} />
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
{ key: "read", count: 0, one: "{{count}} read", other: "{{count}} reads" },
|
||||
{ key: "search", count: 0, one: "{{count}} search", other: "{{count}} searches" },
|
||||
]}
|
||||
fallback=""
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
}
|
||||
|
||||
export const Done = {
|
||||
render: () => (
|
||||
<span style={{ display: "flex", "align-items": "center", gap: "8px", "font-size": "14px", "font-weight": "500" }}>
|
||||
<ToolStatusTitle active={false} activeText="Exploring" doneText="Explored" split={false} />
|
||||
<span style={{ "font-weight": "400", color: "var(--text-base, #ccc)" }}>
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
{ key: "read", count: 5, one: "{{count}} read", other: "{{count}} reads" },
|
||||
{ key: "search", count: 3, one: "{{count}} search", other: "{{count}} searches" },
|
||||
{ key: "list", count: 1, one: "{{count}} list", other: "{{count}} lists" },
|
||||
]}
|
||||
fallback=""
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
}
|
||||
52
packages/ui/src/components/tool-count-summary.tsx
Normal file
52
packages/ui/src/components/tool-count-summary.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Index, createMemo } from "solid-js"
|
||||
import { AnimatedCountLabel } from "./tool-count-label"
|
||||
|
||||
export type CountItem = {
|
||||
key: string
|
||||
count: number
|
||||
one: string
|
||||
other: string
|
||||
}
|
||||
|
||||
export function AnimatedCountList(props: { items: CountItem[]; fallback?: string; class?: string }) {
|
||||
const visible = createMemo(() => props.items.filter((item) => item.count > 0))
|
||||
const fallback = createMemo(() => props.fallback ?? "")
|
||||
const showEmpty = createMemo(() => visible().length === 0 && fallback().length > 0)
|
||||
|
||||
return (
|
||||
<span data-component="tool-count-summary" class={props.class}>
|
||||
<span data-slot="tool-count-summary-empty" data-active={showEmpty() ? "true" : "false"}>
|
||||
<span data-slot="tool-count-summary-empty-inner">{fallback()}</span>
|
||||
</span>
|
||||
|
||||
<Index each={props.items}>
|
||||
{(item, index) => {
|
||||
const active = createMemo(() => item().count > 0)
|
||||
const hasPrev = createMemo(() => {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
if (props.items[i].count > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<span data-slot="tool-count-summary-prefix" data-active={active() && hasPrev() ? "true" : "false"}>
|
||||
,
|
||||
</span>
|
||||
<span data-slot="tool-count-summary-item" data-active={active() ? "true" : "false"}>
|
||||
<span data-slot="tool-count-summary-item-inner">
|
||||
<AnimatedCountLabel
|
||||
one={item().one}
|
||||
other={item().other}
|
||||
count={Math.max(0, Math.round(item().count))}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
89
packages/ui/src/components/tool-status-title.css
Normal file
89
packages/ui/src/components/tool-status-title.css
Normal file
@@ -0,0 +1,89 @@
|
||||
[data-component="tool-status-title"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
|
||||
[data-slot="tool-status-suffix"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="tool-status-prefix"] {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="tool-status-swap"],
|
||||
[data-slot="tool-status-tail"] {
|
||||
display: inline-grid;
|
||||
overflow: hidden;
|
||||
justify-items: start;
|
||||
transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-status-active"],
|
||||
[data-slot="tool-status-done"] {
|
||||
grid-area: 1 / 1;
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
text-align: start;
|
||||
transition-property: opacity, filter, transform;
|
||||
transition-duration:
|
||||
var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
|
||||
calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
|
||||
transition-timing-function: ease-out, ease-out, ease-out;
|
||||
}
|
||||
|
||||
&[data-ready="false"] {
|
||||
[data-slot="tool-status-swap"],
|
||||
[data-slot="tool-status-tail"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-slot="tool-status-active"],
|
||||
[data-slot="tool-status-done"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tool-status-active"] {
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45));
|
||||
transform: translateY(0.03em);
|
||||
}
|
||||
|
||||
[data-slot="tool-status-done"] {
|
||||
color: var(--text-strong);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
[data-slot="tool-status-active"] {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-slot="tool-status-done"] {
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45));
|
||||
transform: translateY(0.03em);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-swap"],
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-tail"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-active"],
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-done"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
138
packages/ui/src/components/tool-status-title.tsx
Normal file
138
packages/ui/src/components/tool-status-title.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
|
||||
function common(active: string, done: string) {
|
||||
const a = Array.from(active)
|
||||
const b = Array.from(done)
|
||||
let i = 0
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
||||
return {
|
||||
prefix: a.slice(0, i).join(""),
|
||||
active: a.slice(i).join(""),
|
||||
done: b.slice(i).join(""),
|
||||
}
|
||||
}
|
||||
|
||||
function contentWidth(el: HTMLSpanElement | undefined) {
|
||||
if (!el) return 0
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
return Math.ceil(range.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
export function ToolStatusTitle(props: {
|
||||
active: boolean
|
||||
activeText: string
|
||||
doneText: string
|
||||
class?: string
|
||||
split?: boolean
|
||||
}) {
|
||||
const split = createMemo(() => common(props.activeText, props.doneText))
|
||||
const suffix = createMemo(
|
||||
() => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
|
||||
)
|
||||
const prefixLen = createMemo(() => Array.from(split().prefix).length)
|
||||
const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
|
||||
const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
|
||||
|
||||
const [width, setWidth] = createSignal("auto")
|
||||
const [ready, setReady] = createSignal(false)
|
||||
let activeRef: HTMLSpanElement | undefined
|
||||
let doneRef: HTMLSpanElement | undefined
|
||||
let frame: number | undefined
|
||||
let readyFrame: number | undefined
|
||||
|
||||
const measure = () => {
|
||||
const target = props.active ? activeRef : doneRef
|
||||
const px = contentWidth(target)
|
||||
if (px > 0) setWidth(`${px}px`)
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
measure()
|
||||
return
|
||||
}
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
measure()
|
||||
})
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
|
||||
readyFrame = requestAnimationFrame(() => {
|
||||
readyFrame = undefined
|
||||
setReady(true)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
[() => props.active, activeTail, doneTail, suffix],
|
||||
() => schedule(),
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
measure()
|
||||
const fonts = typeof document !== "undefined" ? document.fonts : undefined
|
||||
if (!fonts) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
fonts.ready.finally(() => {
|
||||
measure()
|
||||
finish()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
data-component="tool-status-title"
|
||||
data-active={props.active ? "true" : "false"}
|
||||
data-ready={ready() ? "true" : "false"}
|
||||
data-mode={suffix() ? "suffix" : "swap"}
|
||||
class={props.class}
|
||||
aria-label={props.active ? props.activeText : props.doneText}
|
||||
>
|
||||
<Show
|
||||
when={suffix()}
|
||||
fallback={
|
||||
<span data-slot="tool-status-swap" style={{ width: width() }}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={props.active} offset={0} />
|
||||
</span>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={0} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span data-slot="tool-status-suffix">
|
||||
<span data-slot="tool-status-prefix">
|
||||
<TextShimmer text={split().prefix} active={props.active} offset={0} />
|
||||
</span>
|
||||
<span data-slot="tool-status-tail" style={{ width: width() }}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
|
||||
</span>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={prefixLen()} />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@import "katex/dist/katex.min.css" layer(base);
|
||||
|
||||
@import "../components/accordion.css" layer(components);
|
||||
@import "../components/animated-number.css" layer(components);
|
||||
@import "../components/app-icon.css" layer(components);
|
||||
@import "../components/avatar.css" layer(components);
|
||||
@import "../components/basic-tool.css" layer(components);
|
||||
@@ -45,10 +46,16 @@
|
||||
@import "../components/scroll-view.css" layer(components);
|
||||
@import "../components/session-review.css" layer(components);
|
||||
@import "../components/session-turn.css" layer(components);
|
||||
@import "../components/shell-submessage.css" layer(components);
|
||||
@import "../components/sticky-accordion-header.css" layer(components);
|
||||
@import "../components/tabs.css" layer(components);
|
||||
@import "../components/tag.css" layer(components);
|
||||
@import "../components/text-reveal.css" layer(components);
|
||||
@import "../components/text-strikethrough.css" layer(components);
|
||||
@import "../components/text-shimmer.css" layer(components);
|
||||
@import "../components/tool-count-label.css" layer(components);
|
||||
@import "../components/tool-count-summary.css" layer(components);
|
||||
@import "../components/tool-status-title.css" layer(components);
|
||||
@import "../components/toast.css" layer(components);
|
||||
@import "../components/tooltip.css" layer(components);
|
||||
@import "../components/typewriter.css" layer(components);
|
||||
|
||||
Reference in New Issue
Block a user