diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index 062ef7c..0601df9 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -4,7 +4,7 @@ This inventory identifies the third-party npm packages present in the locked Lumora dependency graph. It is not a replacement for the license text shipped by each package. Release artifacts must retain any license or notice files required by those packages. -Locked package identities: **419** +Locked package identities: **420** | Package | Version | License | Scope | | --- | --- | --- | --- | @@ -315,6 +315,7 @@ Locked package identities: **419** | loupe | 3.2.1 | MIT | development | | lru-cache | 10.4.3 | ISC | development | | lru-cache | 5.1.1 | ISC | development | +| lucide-react | 1.37.0 | ISC | runtime | | lz-string | 1.5.0 | MIT | development | | maath | 0.10.8 | MIT | runtime | | magic-string | 0.30.21 | MIT | development | diff --git a/docs/evidence/tml-563/README.md b/docs/evidence/tml-563/README.md new file mode 100644 index 0000000..eb8e5c0 --- /dev/null +++ b/docs/evidence/tml-563/README.md @@ -0,0 +1,67 @@ +# TML-563 UI/UX Review Evidence + +The `before` images are the original reviewer's `HOLD` matrix. The `after` images are generated from this branch by: + +```sh +npx playwright test e2e/tml-563-screenshots.spec.ts --project=chromium +``` + +Each after image uses a fresh browser context so project persistence and runtime locks cannot leak between captures. + +States 14 and 15 were regenerated after the final review gates. State 14 captures the modal log drawer with its 44px close target and focus boundary; state 15 captures Fit with zero horizontal scroll and fully reachable 44px short-shot targets. Their inert, Escape/focus-restoration, edge-hit, and geometry assertions are enforced by `e2e/ui-ux-regression.spec.ts`. + +| ID | State and viewport | Before | After | +| --- | --- | --- | --- | +| 01 | Editor, selected cube, 1440x900 | [Before](before/01-desktop-editor-1440x900.png) | [After](after/01-desktop-editor-1440x900.png) | +| 01a | Narrow embedded editor, selected cube, 1024x768 | [Before](before/01a-desktop-editor-1024x768.png) | [After](after/01a-desktop-editor-1024x768.png) | +| 02 | Plugin manager, 1440x900 | [Before](before/02-desktop-plugin-manager-1440x900.png) | [After](after/02-desktop-plugin-manager-1440x900.png) | +| 03 | Command palette, 1440x900 | [Before](before/03-desktop-command-palette-1440x900.png) | [After](after/03-desktop-command-palette-1440x900.png) | +| 04 | Storyboard, 1440x900 | [Before](before/04-desktop-storyboard-1440x900.png) | [After](after/04-desktop-storyboard-1440x900.png) | +| 05 | Export workspace, 1440x900 | [Before](before/05-desktop-export-1440x900.png) | [After](after/05-desktop-export-1440x900.png) | +| 06a | Mobile editor before selection, 375x667 | [Before](before/06a-mobile-editor-before-selection-375x667.png) | [After](after/06a-mobile-editor-before-selection-375x667.png) | +| 06 | Mobile editor after selection, 375x667 | [Before](before/06-mobile-editor-375x667.png) | [After](after/06-mobile-editor-375x667.png) | +| 07 | Mobile plugin manager, 375x667 | [Before](before/07-mobile-plugin-manager-375x667.png) | [After](after/07-mobile-plugin-manager-375x667.png) | +| 08 | Mobile storyboard, 375x667 | [Before](before/08-mobile-storyboard-375x667.png) | [After](after/08-mobile-storyboard-375x667.png) | +| 09 | Mobile landscape editor, 667x375 | [Before](before/09-mobile-landscape-editor-667x375.png) | [After](after/09-mobile-landscape-editor-667x375.png) | +| 10 | Mobile landscape export, 667x375 | [Before](before/10-mobile-landscape-export-667x375.png) | [After](after/10-mobile-landscape-export-667x375.png) | +| 11 | Host sibling isolation, plugin modal, 1440x900 | [Before](before/11-portal-host-isolation-1440x900.png) | [After](after/11-portal-host-isolation-1440x900.png) | +| 12 | ShadowRoot opener focus restored, 1440x900 | [Before](before/12-shadow-root-focus-restore-1440x900.png) | [After](after/12-shadow-root-focus-restore-1440x900.png) | +| 13a | 900px Studio boundary (1240x768 host) | [Before](before/13-responsive-boundary-1240x768.png) | [After](after/13-responsive-boundary-1240x768.png) | +| 13b | 901px Studio boundary (1241x768 host) | [Before](before/13-responsive-boundary-1241x768.png) | [After](after/13-responsive-boundary-1241x768.png) | +| 13c | 1100px Studio boundary (1440x768 host) | [Before](before/13-responsive-boundary-1440x768.png) | [After](after/13-responsive-boundary-1440x768.png) | +| 13d | 1101px Studio boundary (1441x768 host) | [Before](before/13-responsive-boundary-1441x768.png) | [After](after/13-responsive-boundary-1441x768.png) | +| 14 | Mobile landscape with host log expanded, 667x375 | [Before](before/14-mobile-landscape-log-open-667x375.png) | [After](after/14-mobile-landscape-log-open-667x375.png) | +| 15 | Mobile fit zoom with 0.1s shot controls, 375x667 | [Before](before/15-mobile-fit-shot-controls-375x667.png) | [After](after/15-mobile-fit-shot-controls-375x667.png) | +| 16 | Mobile keyframe target lanes, 375x667 | [Before](before/16-mobile-keyframe-overlap-375x667.png) | [After](after/16-mobile-keyframe-overlap-375x667.png) | +| 17 | Mobile storyboard close/delete controls, 375x667 | [Before](before/17-mobile-storyboard-controls-375x667.png) | [After](after/17-mobile-storyboard-controls-375x667.png) | +| 18 | Plugin transition focus, 1440x900 | [Before](before/18-plugin-transition-focus-1440x900.png) | [After](after/18-plugin-transition-focus-1440x900.png) | +| 19 | Two-Studio top-modal stack after Escape, 1440x900 | [Before](before/19-multiple-modal-stack-1440x900.png) | [After](after/19-multiple-modal-stack-1440x900.png) | + +The focused regression suite also checks WCAG A/AA violations, document-level host isolation, deep ShadowRoot focus restoration, two-Studio modal ordering, stable plugin-toggle focus, 900/901 and 1100/1101 container geometry, mobile shot/keyframe hitbox geometry, expanded landscape logs, and nonblank desktop/mobile WebGL canvas pixels. + +## Round 3 hard-gate evidence + +The reviewer's annotated `round3-before` images are preserved unchanged. The matching `round3-after` images are generated from the corrected branch by: + +```sh +npx playwright test e2e/tml-563-screenshots.spec.ts --project=chromium +``` + +The capture test asserts the measured failure condition before writing each image: modal count and unique accessible IDs, Fit canvas containment, 44px shot actions, disjoint 60fps keyframe targets with correct center ownership, document-level inert/focus, mixed-modal Escape order, single-row toolbar geometry, and portrait timeline containment. + +| Failure state and viewport | Before | After | +| --- | --- | --- | +| Background Studio dispatches `Ctrl+K`, 1440x900 | [Before](round3-before/hold-dual-ctrlk-scope.png) | [After](round3-after/hold-dual-ctrlk-scope.png) | +| Two command palettes expose duplicate IDs, 1440x900 | [Before](round3-before/hold-dual-palette-ids.png) | [After](round3-after/hold-dual-palette-ids.png) | +| Fit loses the full duration after a 0.1s shot, 375x667 | [Before](round3-before/hold-fit-overview-loss-375x667.png) | [After](round3-after/hold-fit-overview-loss-375x667.png) | +| One zoom-out clips selected-shot actions, 375x667 | [Before](round3-before/hold-shot-actions-clipped-375x667.png) | [After](round3-after/hold-shot-actions-clipped-375x667.png) | +| Adjacent 60fps keyframes overlap, 375x667 | [Before](round3-before/tml-563-keyframes-60fps-overlap-375x667.png) | [After](round3-after/tml-563-keyframes-60fps-overlap-375x667.png) | +| Overwrite confirmation allows host-sibling focus, 1440x900 | [Before](round3-before/hold-overwrite-host-focus.png) | [After](round3-after/hold-overwrite-host-focus.png) | +| Mixed modal Escape closes the lower dialog first, 1440x900 | [Before](round3-before/hold-mixed-modal-escape-order.png) | [After](round3-after/hold-mixed-modal-escape-order.png) | +| Studio 1240 toolbar wraps at host 1580x768 | [Before](round3-before/toolbar-studio-1240-host-1580x768.png) | [After](round3-after/toolbar-studio-1240-host-1580x768.png) | +| Studio 1241 toolbar jumps at host 1581x768 | [Before](round3-before/toolbar-studio-1241-host-1581x768.png) | [After](round3-after/toolbar-studio-1241-host-1581x768.png) | +| Expanded host log clips the timeline, 375x667 | [Before](round3-before/portrait-log-open-375x667.png) | [After](round3-after/portrait-log-open-375x667.png) | + +The related Studio `1080/1081` boundary is captured at [1420x768](round3-after/toolbar-studio-1080-host-1420x768.png) and [1421x768](round3-after/toolbar-studio-1081-host-1421x768.png); both remain in the same compact single-row mode. + +An additional Chromium regression loads 60 consecutive 60fps keyframes and checks both the default zoom and the minimum zoom. Dense groups stay within two 44px rows, keep the track label and shot lane visible, and expose every grouped frame through repeated activation of the cluster target. diff --git a/docs/evidence/tml-563/after/01-desktop-editor-1440x900.png b/docs/evidence/tml-563/after/01-desktop-editor-1440x900.png new file mode 100644 index 0000000..8626fb8 Binary files /dev/null and b/docs/evidence/tml-563/after/01-desktop-editor-1440x900.png differ diff --git a/docs/evidence/tml-563/after/01a-desktop-editor-1024x768.png b/docs/evidence/tml-563/after/01a-desktop-editor-1024x768.png new file mode 100644 index 0000000..657a76b Binary files /dev/null and b/docs/evidence/tml-563/after/01a-desktop-editor-1024x768.png differ diff --git a/docs/evidence/tml-563/after/02-desktop-plugin-manager-1440x900.png b/docs/evidence/tml-563/after/02-desktop-plugin-manager-1440x900.png new file mode 100644 index 0000000..f61145a Binary files /dev/null and b/docs/evidence/tml-563/after/02-desktop-plugin-manager-1440x900.png differ diff --git a/docs/evidence/tml-563/after/03-desktop-command-palette-1440x900.png b/docs/evidence/tml-563/after/03-desktop-command-palette-1440x900.png new file mode 100644 index 0000000..29e41fd Binary files /dev/null and b/docs/evidence/tml-563/after/03-desktop-command-palette-1440x900.png differ diff --git a/docs/evidence/tml-563/after/04-desktop-storyboard-1440x900.png b/docs/evidence/tml-563/after/04-desktop-storyboard-1440x900.png new file mode 100644 index 0000000..65fde84 Binary files /dev/null and b/docs/evidence/tml-563/after/04-desktop-storyboard-1440x900.png differ diff --git a/docs/evidence/tml-563/after/05-desktop-export-1440x900.png b/docs/evidence/tml-563/after/05-desktop-export-1440x900.png new file mode 100644 index 0000000..daae55d Binary files /dev/null and b/docs/evidence/tml-563/after/05-desktop-export-1440x900.png differ diff --git a/docs/evidence/tml-563/after/06-mobile-editor-375x667.png b/docs/evidence/tml-563/after/06-mobile-editor-375x667.png new file mode 100644 index 0000000..5ceeb1e Binary files /dev/null and b/docs/evidence/tml-563/after/06-mobile-editor-375x667.png differ diff --git a/docs/evidence/tml-563/after/06a-mobile-editor-before-selection-375x667.png b/docs/evidence/tml-563/after/06a-mobile-editor-before-selection-375x667.png new file mode 100644 index 0000000..8e65b95 Binary files /dev/null and b/docs/evidence/tml-563/after/06a-mobile-editor-before-selection-375x667.png differ diff --git a/docs/evidence/tml-563/after/07-mobile-plugin-manager-375x667.png b/docs/evidence/tml-563/after/07-mobile-plugin-manager-375x667.png new file mode 100644 index 0000000..260451e Binary files /dev/null and b/docs/evidence/tml-563/after/07-mobile-plugin-manager-375x667.png differ diff --git a/docs/evidence/tml-563/after/08-mobile-storyboard-375x667.png b/docs/evidence/tml-563/after/08-mobile-storyboard-375x667.png new file mode 100644 index 0000000..34f9893 Binary files /dev/null and b/docs/evidence/tml-563/after/08-mobile-storyboard-375x667.png differ diff --git a/docs/evidence/tml-563/after/09-mobile-landscape-editor-667x375.png b/docs/evidence/tml-563/after/09-mobile-landscape-editor-667x375.png new file mode 100644 index 0000000..86ed969 Binary files /dev/null and b/docs/evidence/tml-563/after/09-mobile-landscape-editor-667x375.png differ diff --git a/docs/evidence/tml-563/after/10-mobile-landscape-export-667x375.png b/docs/evidence/tml-563/after/10-mobile-landscape-export-667x375.png new file mode 100644 index 0000000..a2eb9dd Binary files /dev/null and b/docs/evidence/tml-563/after/10-mobile-landscape-export-667x375.png differ diff --git a/docs/evidence/tml-563/after/11-portal-host-isolation-1440x900.png b/docs/evidence/tml-563/after/11-portal-host-isolation-1440x900.png new file mode 100644 index 0000000..8d3b793 Binary files /dev/null and b/docs/evidence/tml-563/after/11-portal-host-isolation-1440x900.png differ diff --git a/docs/evidence/tml-563/after/12-shadow-root-focus-restore-1440x900.png b/docs/evidence/tml-563/after/12-shadow-root-focus-restore-1440x900.png new file mode 100644 index 0000000..9fe8558 Binary files /dev/null and b/docs/evidence/tml-563/after/12-shadow-root-focus-restore-1440x900.png differ diff --git a/docs/evidence/tml-563/after/13-responsive-boundary-1240x768.png b/docs/evidence/tml-563/after/13-responsive-boundary-1240x768.png new file mode 100644 index 0000000..bbd63c4 Binary files /dev/null and b/docs/evidence/tml-563/after/13-responsive-boundary-1240x768.png differ diff --git a/docs/evidence/tml-563/after/13-responsive-boundary-1241x768.png b/docs/evidence/tml-563/after/13-responsive-boundary-1241x768.png new file mode 100644 index 0000000..73ffe13 Binary files /dev/null and b/docs/evidence/tml-563/after/13-responsive-boundary-1241x768.png differ diff --git a/docs/evidence/tml-563/after/13-responsive-boundary-1440x768.png b/docs/evidence/tml-563/after/13-responsive-boundary-1440x768.png new file mode 100644 index 0000000..06ae5de Binary files /dev/null and b/docs/evidence/tml-563/after/13-responsive-boundary-1440x768.png differ diff --git a/docs/evidence/tml-563/after/13-responsive-boundary-1441x768.png b/docs/evidence/tml-563/after/13-responsive-boundary-1441x768.png new file mode 100644 index 0000000..8a5e085 Binary files /dev/null and b/docs/evidence/tml-563/after/13-responsive-boundary-1441x768.png differ diff --git a/docs/evidence/tml-563/after/14-mobile-landscape-log-open-667x375.png b/docs/evidence/tml-563/after/14-mobile-landscape-log-open-667x375.png new file mode 100644 index 0000000..abc6f37 Binary files /dev/null and b/docs/evidence/tml-563/after/14-mobile-landscape-log-open-667x375.png differ diff --git a/docs/evidence/tml-563/after/15-mobile-fit-shot-controls-375x667.png b/docs/evidence/tml-563/after/15-mobile-fit-shot-controls-375x667.png new file mode 100644 index 0000000..0a85f54 Binary files /dev/null and b/docs/evidence/tml-563/after/15-mobile-fit-shot-controls-375x667.png differ diff --git a/docs/evidence/tml-563/after/16-mobile-keyframe-overlap-375x667.png b/docs/evidence/tml-563/after/16-mobile-keyframe-overlap-375x667.png new file mode 100644 index 0000000..c3268ac Binary files /dev/null and b/docs/evidence/tml-563/after/16-mobile-keyframe-overlap-375x667.png differ diff --git a/docs/evidence/tml-563/after/17-mobile-storyboard-controls-375x667.png b/docs/evidence/tml-563/after/17-mobile-storyboard-controls-375x667.png new file mode 100644 index 0000000..0a71406 Binary files /dev/null and b/docs/evidence/tml-563/after/17-mobile-storyboard-controls-375x667.png differ diff --git a/docs/evidence/tml-563/after/18-plugin-transition-focus-1440x900.png b/docs/evidence/tml-563/after/18-plugin-transition-focus-1440x900.png new file mode 100644 index 0000000..57bce05 Binary files /dev/null and b/docs/evidence/tml-563/after/18-plugin-transition-focus-1440x900.png differ diff --git a/docs/evidence/tml-563/after/19-multiple-modal-stack-1440x900.png b/docs/evidence/tml-563/after/19-multiple-modal-stack-1440x900.png new file mode 100644 index 0000000..4f08ac3 Binary files /dev/null and b/docs/evidence/tml-563/after/19-multiple-modal-stack-1440x900.png differ diff --git a/docs/evidence/tml-563/before/01-desktop-editor-1440x900.png b/docs/evidence/tml-563/before/01-desktop-editor-1440x900.png new file mode 100644 index 0000000..b0380db Binary files /dev/null and b/docs/evidence/tml-563/before/01-desktop-editor-1440x900.png differ diff --git a/docs/evidence/tml-563/before/01a-desktop-editor-1024x768.png b/docs/evidence/tml-563/before/01a-desktop-editor-1024x768.png new file mode 100644 index 0000000..30b8d3d Binary files /dev/null and b/docs/evidence/tml-563/before/01a-desktop-editor-1024x768.png differ diff --git a/docs/evidence/tml-563/before/02-desktop-plugin-manager-1440x900.png b/docs/evidence/tml-563/before/02-desktop-plugin-manager-1440x900.png new file mode 100644 index 0000000..fc0e320 Binary files /dev/null and b/docs/evidence/tml-563/before/02-desktop-plugin-manager-1440x900.png differ diff --git a/docs/evidence/tml-563/before/03-desktop-command-palette-1440x900.png b/docs/evidence/tml-563/before/03-desktop-command-palette-1440x900.png new file mode 100644 index 0000000..28b2e0a Binary files /dev/null and b/docs/evidence/tml-563/before/03-desktop-command-palette-1440x900.png differ diff --git a/docs/evidence/tml-563/before/04-desktop-storyboard-1440x900.png b/docs/evidence/tml-563/before/04-desktop-storyboard-1440x900.png new file mode 100644 index 0000000..7d71ed2 Binary files /dev/null and b/docs/evidence/tml-563/before/04-desktop-storyboard-1440x900.png differ diff --git a/docs/evidence/tml-563/before/05-desktop-export-1440x900.png b/docs/evidence/tml-563/before/05-desktop-export-1440x900.png new file mode 100644 index 0000000..aa889ff Binary files /dev/null and b/docs/evidence/tml-563/before/05-desktop-export-1440x900.png differ diff --git a/docs/evidence/tml-563/before/06-mobile-editor-375x667.png b/docs/evidence/tml-563/before/06-mobile-editor-375x667.png new file mode 100644 index 0000000..24fb46e Binary files /dev/null and b/docs/evidence/tml-563/before/06-mobile-editor-375x667.png differ diff --git a/docs/evidence/tml-563/before/06a-mobile-editor-before-selection-375x667.png b/docs/evidence/tml-563/before/06a-mobile-editor-before-selection-375x667.png new file mode 100644 index 0000000..de46a2c Binary files /dev/null and b/docs/evidence/tml-563/before/06a-mobile-editor-before-selection-375x667.png differ diff --git a/docs/evidence/tml-563/before/07-mobile-plugin-manager-375x667.png b/docs/evidence/tml-563/before/07-mobile-plugin-manager-375x667.png new file mode 100644 index 0000000..a75b86d Binary files /dev/null and b/docs/evidence/tml-563/before/07-mobile-plugin-manager-375x667.png differ diff --git a/docs/evidence/tml-563/before/08-mobile-storyboard-375x667.png b/docs/evidence/tml-563/before/08-mobile-storyboard-375x667.png new file mode 100644 index 0000000..4bcb8fc Binary files /dev/null and b/docs/evidence/tml-563/before/08-mobile-storyboard-375x667.png differ diff --git a/docs/evidence/tml-563/before/09-mobile-landscape-editor-667x375.png b/docs/evidence/tml-563/before/09-mobile-landscape-editor-667x375.png new file mode 100644 index 0000000..5a2e79c Binary files /dev/null and b/docs/evidence/tml-563/before/09-mobile-landscape-editor-667x375.png differ diff --git a/docs/evidence/tml-563/before/10-mobile-landscape-export-667x375.png b/docs/evidence/tml-563/before/10-mobile-landscape-export-667x375.png new file mode 100644 index 0000000..d7b0a00 Binary files /dev/null and b/docs/evidence/tml-563/before/10-mobile-landscape-export-667x375.png differ diff --git a/docs/evidence/tml-563/before/11-portal-host-isolation-1440x900.png b/docs/evidence/tml-563/before/11-portal-host-isolation-1440x900.png new file mode 100644 index 0000000..1eadf45 Binary files /dev/null and b/docs/evidence/tml-563/before/11-portal-host-isolation-1440x900.png differ diff --git a/docs/evidence/tml-563/before/12-shadow-root-focus-restore-1440x900.png b/docs/evidence/tml-563/before/12-shadow-root-focus-restore-1440x900.png new file mode 100644 index 0000000..bf840f6 Binary files /dev/null and b/docs/evidence/tml-563/before/12-shadow-root-focus-restore-1440x900.png differ diff --git a/docs/evidence/tml-563/before/13-responsive-boundary-1240x768.png b/docs/evidence/tml-563/before/13-responsive-boundary-1240x768.png new file mode 100644 index 0000000..4059dc3 Binary files /dev/null and b/docs/evidence/tml-563/before/13-responsive-boundary-1240x768.png differ diff --git a/docs/evidence/tml-563/before/13-responsive-boundary-1241x768.png b/docs/evidence/tml-563/before/13-responsive-boundary-1241x768.png new file mode 100644 index 0000000..5be3aa2 Binary files /dev/null and b/docs/evidence/tml-563/before/13-responsive-boundary-1241x768.png differ diff --git a/docs/evidence/tml-563/before/13-responsive-boundary-1440x768.png b/docs/evidence/tml-563/before/13-responsive-boundary-1440x768.png new file mode 100644 index 0000000..bf3bb6a Binary files /dev/null and b/docs/evidence/tml-563/before/13-responsive-boundary-1440x768.png differ diff --git a/docs/evidence/tml-563/before/13-responsive-boundary-1441x768.png b/docs/evidence/tml-563/before/13-responsive-boundary-1441x768.png new file mode 100644 index 0000000..8e216d2 Binary files /dev/null and b/docs/evidence/tml-563/before/13-responsive-boundary-1441x768.png differ diff --git a/docs/evidence/tml-563/before/14-mobile-landscape-log-open-667x375.png b/docs/evidence/tml-563/before/14-mobile-landscape-log-open-667x375.png new file mode 100644 index 0000000..52f73f4 Binary files /dev/null and b/docs/evidence/tml-563/before/14-mobile-landscape-log-open-667x375.png differ diff --git a/docs/evidence/tml-563/before/15-mobile-fit-shot-controls-375x667.png b/docs/evidence/tml-563/before/15-mobile-fit-shot-controls-375x667.png new file mode 100644 index 0000000..9430725 Binary files /dev/null and b/docs/evidence/tml-563/before/15-mobile-fit-shot-controls-375x667.png differ diff --git a/docs/evidence/tml-563/before/16-mobile-keyframe-overlap-375x667.png b/docs/evidence/tml-563/before/16-mobile-keyframe-overlap-375x667.png new file mode 100644 index 0000000..9430725 Binary files /dev/null and b/docs/evidence/tml-563/before/16-mobile-keyframe-overlap-375x667.png differ diff --git a/docs/evidence/tml-563/before/17-mobile-storyboard-controls-375x667.png b/docs/evidence/tml-563/before/17-mobile-storyboard-controls-375x667.png new file mode 100644 index 0000000..c4333e5 Binary files /dev/null and b/docs/evidence/tml-563/before/17-mobile-storyboard-controls-375x667.png differ diff --git a/docs/evidence/tml-563/before/18-plugin-transition-focus-1440x900.png b/docs/evidence/tml-563/before/18-plugin-transition-focus-1440x900.png new file mode 100644 index 0000000..206dce1 Binary files /dev/null and b/docs/evidence/tml-563/before/18-plugin-transition-focus-1440x900.png differ diff --git a/docs/evidence/tml-563/before/19-multiple-modal-stack-1440x900.png b/docs/evidence/tml-563/before/19-multiple-modal-stack-1440x900.png new file mode 100644 index 0000000..1868641 Binary files /dev/null and b/docs/evidence/tml-563/before/19-multiple-modal-stack-1440x900.png differ diff --git a/docs/evidence/tml-563/round3-after/hold-dual-ctrlk-scope.png b/docs/evidence/tml-563/round3-after/hold-dual-ctrlk-scope.png new file mode 100644 index 0000000..aec85fb Binary files /dev/null and b/docs/evidence/tml-563/round3-after/hold-dual-ctrlk-scope.png differ diff --git a/docs/evidence/tml-563/round3-after/hold-dual-palette-ids.png b/docs/evidence/tml-563/round3-after/hold-dual-palette-ids.png new file mode 100644 index 0000000..c82dc97 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/hold-dual-palette-ids.png differ diff --git a/docs/evidence/tml-563/round3-after/hold-fit-overview-loss-375x667.png b/docs/evidence/tml-563/round3-after/hold-fit-overview-loss-375x667.png new file mode 100644 index 0000000..faca722 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/hold-fit-overview-loss-375x667.png differ diff --git a/docs/evidence/tml-563/round3-after/hold-mixed-modal-escape-order.png b/docs/evidence/tml-563/round3-after/hold-mixed-modal-escape-order.png new file mode 100644 index 0000000..3a5bf3c Binary files /dev/null and b/docs/evidence/tml-563/round3-after/hold-mixed-modal-escape-order.png differ diff --git a/docs/evidence/tml-563/round3-after/hold-overwrite-host-focus.png b/docs/evidence/tml-563/round3-after/hold-overwrite-host-focus.png new file mode 100644 index 0000000..0b903b6 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/hold-overwrite-host-focus.png differ diff --git a/docs/evidence/tml-563/round3-after/hold-shot-actions-clipped-375x667.png b/docs/evidence/tml-563/round3-after/hold-shot-actions-clipped-375x667.png new file mode 100644 index 0000000..e3a8ec1 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/hold-shot-actions-clipped-375x667.png differ diff --git a/docs/evidence/tml-563/round3-after/portrait-log-open-375x667.png b/docs/evidence/tml-563/round3-after/portrait-log-open-375x667.png new file mode 100644 index 0000000..e3f4738 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/portrait-log-open-375x667.png differ diff --git a/docs/evidence/tml-563/round3-after/tml-563-keyframes-60fps-overlap-375x667.png b/docs/evidence/tml-563/round3-after/tml-563-keyframes-60fps-overlap-375x667.png new file mode 100644 index 0000000..18f50e4 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/tml-563-keyframes-60fps-overlap-375x667.png differ diff --git a/docs/evidence/tml-563/round3-after/toolbar-studio-1080-host-1420x768.png b/docs/evidence/tml-563/round3-after/toolbar-studio-1080-host-1420x768.png new file mode 100644 index 0000000..942ccd0 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/toolbar-studio-1080-host-1420x768.png differ diff --git a/docs/evidence/tml-563/round3-after/toolbar-studio-1081-host-1421x768.png b/docs/evidence/tml-563/round3-after/toolbar-studio-1081-host-1421x768.png new file mode 100644 index 0000000..891ed67 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/toolbar-studio-1081-host-1421x768.png differ diff --git a/docs/evidence/tml-563/round3-after/toolbar-studio-1240-host-1580x768.png b/docs/evidence/tml-563/round3-after/toolbar-studio-1240-host-1580x768.png new file mode 100644 index 0000000..9714af5 Binary files /dev/null and b/docs/evidence/tml-563/round3-after/toolbar-studio-1240-host-1580x768.png differ diff --git a/docs/evidence/tml-563/round3-after/toolbar-studio-1241-host-1581x768.png b/docs/evidence/tml-563/round3-after/toolbar-studio-1241-host-1581x768.png new file mode 100644 index 0000000..c3d8e1f Binary files /dev/null and b/docs/evidence/tml-563/round3-after/toolbar-studio-1241-host-1581x768.png differ diff --git a/docs/evidence/tml-563/round3-before/hold-dual-ctrlk-scope.png b/docs/evidence/tml-563/round3-before/hold-dual-ctrlk-scope.png new file mode 100644 index 0000000..f6e8aaf Binary files /dev/null and b/docs/evidence/tml-563/round3-before/hold-dual-ctrlk-scope.png differ diff --git a/docs/evidence/tml-563/round3-before/hold-dual-palette-ids.png b/docs/evidence/tml-563/round3-before/hold-dual-palette-ids.png new file mode 100644 index 0000000..8287f7a Binary files /dev/null and b/docs/evidence/tml-563/round3-before/hold-dual-palette-ids.png differ diff --git a/docs/evidence/tml-563/round3-before/hold-fit-overview-loss-375x667.png b/docs/evidence/tml-563/round3-before/hold-fit-overview-loss-375x667.png new file mode 100644 index 0000000..2823c41 Binary files /dev/null and b/docs/evidence/tml-563/round3-before/hold-fit-overview-loss-375x667.png differ diff --git a/docs/evidence/tml-563/round3-before/hold-mixed-modal-escape-order.png b/docs/evidence/tml-563/round3-before/hold-mixed-modal-escape-order.png new file mode 100644 index 0000000..bf6e500 Binary files /dev/null and b/docs/evidence/tml-563/round3-before/hold-mixed-modal-escape-order.png differ diff --git a/docs/evidence/tml-563/round3-before/hold-overwrite-host-focus.png b/docs/evidence/tml-563/round3-before/hold-overwrite-host-focus.png new file mode 100644 index 0000000..684f765 Binary files /dev/null and b/docs/evidence/tml-563/round3-before/hold-overwrite-host-focus.png differ diff --git a/docs/evidence/tml-563/round3-before/hold-shot-actions-clipped-375x667.png b/docs/evidence/tml-563/round3-before/hold-shot-actions-clipped-375x667.png new file mode 100644 index 0000000..e8e736e Binary files /dev/null and b/docs/evidence/tml-563/round3-before/hold-shot-actions-clipped-375x667.png differ diff --git a/docs/evidence/tml-563/round3-before/portrait-log-open-375x667.png b/docs/evidence/tml-563/round3-before/portrait-log-open-375x667.png new file mode 100644 index 0000000..271171a Binary files /dev/null and b/docs/evidence/tml-563/round3-before/portrait-log-open-375x667.png differ diff --git a/docs/evidence/tml-563/round3-before/tml-563-keyframes-60fps-overlap-375x667.png b/docs/evidence/tml-563/round3-before/tml-563-keyframes-60fps-overlap-375x667.png new file mode 100644 index 0000000..f25b97f Binary files /dev/null and b/docs/evidence/tml-563/round3-before/tml-563-keyframes-60fps-overlap-375x667.png differ diff --git a/docs/evidence/tml-563/round3-before/toolbar-studio-1240-host-1580x768.png b/docs/evidence/tml-563/round3-before/toolbar-studio-1240-host-1580x768.png new file mode 100644 index 0000000..925b1db Binary files /dev/null and b/docs/evidence/tml-563/round3-before/toolbar-studio-1240-host-1580x768.png differ diff --git a/docs/evidence/tml-563/round3-before/toolbar-studio-1241-host-1581x768.png b/docs/evidence/tml-563/round3-before/toolbar-studio-1241-host-1581x768.png new file mode 100644 index 0000000..a1c11ed Binary files /dev/null and b/docs/evidence/tml-563/round3-before/toolbar-studio-1241-host-1581x768.png differ diff --git a/docs/plans/2026-08-31-tml-563-ui-ux-hold-remediation.md b/docs/plans/2026-08-31-tml-563-ui-ux-hold-remediation.md new file mode 100644 index 0000000..459b330 --- /dev/null +++ b/docs/plans/2026-08-31-tml-563-ui-ux-hold-remediation.md @@ -0,0 +1,101 @@ +# TML-563 UI/UX HOLD Remediation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. + +**Goal:** Resolve UI/UX review findings 1-9 from `main@51863fb`, add regression coverage, and provide the reviewer's desktop/mobile screenshot matrix for re-review. + +**Architecture:** Introduce one portal-based modal primitive that owns focus trapping, Escape handling, opener restoration, and application isolation. Make the Studio root the responsive container, switch narrow embeds to explicit scene/object/properties tabs, keep primary actions in one row with an accessible overflow menu, and collapse the demo host log on small screens. Preserve editing behavior while replacing inaccessible timeline/tree interactions with named native controls, consistent Lucide icons, semantic disabled colors, and shared plugin visual tokens. + +**Tech Stack:** React 19, TypeScript, CSS container queries, Lucide React, Vitest/Testing Library, Playwright, axe-core. + +--- + +### Task 1: Modal focus boundary + +**Files:** +- Create: `packages/studio/src/components/ModalDialog.tsx` +- Create: `packages/studio/test/modal-dialog.test.tsx` +- Modify: `packages/studio/src/components/PluginManager.tsx` +- Modify: `packages/studio/src/components/CommandPalette.tsx` +- Modify: `packages/studio/src/components/LumoraStudio.tsx` +- Test: `e2e/ui-ux-regression.spec.ts` + +1. Write tests for `aria-modal`, portal placement, initial focus, Tab/Shift+Tab wrapping, Escape, outside-key isolation, and opener restoration. +2. Run the focused tests and confirm they fail on the existing dialogs. +3. Implement `ModalDialog`, render plugin/command dialogs through it, and set the Studio application root inert while either dialog is open. +4. Add a persistent command-search label and rerun the focused tests until green. + +### Task 2: Container-responsive editor and compact host + +**Files:** +- Modify: `packages/studio/src/components/LumoraStudio.tsx` +- Modify: `packages/studio/src/components/Toolbar.tsx` +- Modify: `packages/studio/src/lumora.css` +- Modify: `examples/embedded-host/src/App.tsx` +- Modify: `examples/embedded-host/src/app.css` +- Test: `e2e/ui-ux-regression.spec.ts` + +1. Add E2E assertions for 900/760/600/560/480px Studio widths, stable scene height, no horizontal overflow, one-row primary actions, panel tab switching, and collapsed mobile host log. +2. Run the E2E file and confirm the current viewport-based layout fails. +3. Make `.lumora-studio` the inline-size container, replace editor `@media` layout rules with `@container`, and add scene/object/properties tabs below 900px. +4. Move secondary toolbar actions into an accessible narrow-container overflow menu and make the host log a user-controlled drawer below 900px. +5. Use `100dvh` and stable tracks so 375x667 and 667x375 preserve a usable scene viewport. + +### Task 3: Accessible names and keyboard timeline + +**Files:** +- Modify: `packages/studio/test/editor-components.test.tsx` +- Modify: `packages/studio/test/timeline-panel.test.tsx` +- Modify: `packages/studio/src/components/editor/ObjectTree.tsx` +- Modify: `packages/studio/src/components/editor/EditorViewport.tsx` +- Modify: `packages/studio/src/components/editor/PropertiesPanel.tsx` +- Modify: `packages/studio/src/components/editor/TimelinePanel.tsx` +- Test: `e2e/accessibility.spec.ts` + +1. Add failing accessible-name assertions for the scene selector, view selector, object-name input, and command search. +2. Add failing keyboard tests for ruler Arrow/Page/Home/End seeking, track selection, and shot seeking. +3. Add labels, expose the ruler as a slider, and use separate native buttons for track/shot actions without nested interactive controls. +4. Expand editor-state axe scans so the main editor and dialogs are checked instead of only the inert export workspace. + +### Task 4: Targets, contrast, and icon language + +**Files:** +- Modify: `packages/studio/package.json` +- Modify: `package-lock.json` +- Modify: `packages/studio/src/components/editor/ObjectTree.tsx` +- Modify: `packages/studio/src/components/editor/TimelinePanel.tsx` +- Modify: `packages/studio/src/components/storyboard/StoryboardWorkspace.tsx` +- Modify: `packages/studio/src/components/PluginManager.tsx` +- Modify: `packages/studio/src/components/Toolbar.tsx` +- Modify: `packages/studio/src/lumora.css` +- Test: `packages/studio/test/editor-components.test.tsx` +- Test: `packages/studio/test/timeline-panel.test.tsx` + +1. Add failing assertions for dynamic action names, pressed states, and icon-only control labels. +2. Add `lucide-react`, replace Unicode glyphs and single-character action abbreviations, and hide icons from the accessibility tree. +3. Keep visual keyframe dots at 8px while expanding hit boxes to 24px desktop and 44px touch; give tree and shot actions equivalent stable targets. +4. Remove whole-row disabled opacity, use readable state tokens, and keep tree actions visible without hover. + +### Task 5: Mock plugin visual baseline + +**Files:** +- Create: `examples/mock-plugin/src/style.css` +- Modify: `examples/mock-plugin/src/index.tsx` +- Modify: `examples/mock-plugin/src/panels/MockConsolePanel.tsx` +- Modify: `examples/mock-plugin/src/panels/MockAiChatPanel.tsx` +- Test: `e2e/ui-ux-regression.spec.ts` + +1. Add a failing browser assertion that Mock controls inherit the Studio surface, text, border, focus, and disabled tokens. +2. Add plugin-owned semantic styles for headings, actions, inputs, results, empty states, and log rows. +3. Rerun the visual and accessibility scenarios until green. + +### Task 6: Verification and evidence + +**Files:** +- Modify: `e2e/ui-ux-regression.spec.ts` +- Generate: `test-results/tml-563-after/*.png` + +1. Capture the editor at 1440x900 and 1024x768; plugin manager, command palette, storyboard, and export at 1440x900; editor/plugin/storyboard at 375x667; and editor/export at 667x375. +2. Run focused Vitest and Chromium E2E, then `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, and `git diff --check`. +3. Compare screenshots against the review attachments and inspect the critical desktop/mobile images directly. +4. Commit, push an independent `TML-563` branch, create a linked PR without merging, and report the PR, tests, screenshots, and residual limits to the project manager. diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 60f5efd..ad31ed1 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -2,17 +2,26 @@ import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; +async function clickToolbarItem(page: Page, testId: string) { + const item = page.getByTestId(testId); + if (!(await item.isVisible())) { + await page.getByTestId('toolbar-more').click(); + await expect(item).toBeVisible(); + } + await item.click(); +} + async function openSampleExport(page: Page) { await page.goto('/'); - await page.getByTestId('open-sample-project').click(); - await expect(page.getByTestId('tree-row-sample-cube')).toBeVisible(); + await clickToolbarItem(page, 'open-sample-project'); + await expect(page.getByTestId('open-export-workspace')).toBeEnabled(); await page.getByTestId('open-export-workspace').click(); await expect(page.getByTestId('export-workspace')).toBeVisible(); } test('host event log preserves native PageDown and Space scrolling without toggling Studio playback', async ({ page }) => { await page.goto('/'); - await page.getByTestId('open-sample-project').click(); + await clickToolbarItem(page, 'open-sample-project'); await expect(page.getByTestId('tree-row-sample-cube')).toBeVisible(); const log = page.getByTestId('host-event-log'); diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 11dbbb0..d8439cd 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -41,7 +41,15 @@ async function countGizmoPixels(page: Page): Promise { test.beforeEach(async ({ page }) => { await page.goto('/'); - await page.getByTestId('open-sample-project').click(); + const openSample = page.getByTestId('open-sample-project'); + if (!(await openSample.isVisible())) { + await page.getByTestId('toolbar-more').click(); + await expect(openSample).toBeVisible(); + } + await openSample.click(); + if (!(await page.getByTestId('tree-row-sample-group').isVisible())) { + await page.getByTestId('editor-panel-objects').click(); + } await expect(page.getByTestId('tree-row-sample-group')).toBeVisible(); }); @@ -342,13 +350,13 @@ test('M3 树行键盘动作:V/L 切换可见/锁定,Delete 全局快捷键 await row('sample-cube').click(); await row('sample-cube').focus(); - // L:锁定(按钮文本翻转为「锁」);V:隐藏(「隐」)——行内按钮等效快捷键 + // L/V 切换后,图标按钮的可访问名称同步表达下一步动作。 await page.keyboard.press('l'); - await expect(page.getByTestId('tree-lock-sample-cube')).toHaveText('锁'); + await expect(page.getByTestId('tree-lock-sample-cube')).toHaveAccessibleName('解锁 立方体'); await page.keyboard.press('v'); - await expect(page.getByTestId('tree-visible-sample-cube')).toHaveText('隐'); + await expect(page.getByTestId('tree-visible-sample-cube')).toHaveAccessibleName('显示 立方体'); await page.keyboard.press('l'); - await expect(page.getByTestId('tree-lock-sample-cube')).toHaveText('开'); + await expect(page.getByTestId('tree-lock-sample-cube')).toHaveAccessibleName('锁定 立方体'); // Delete:宿主全局快捷键(选中行上直接删除,撤销可恢复) await page.keyboard.press('Delete'); await expect(row('sample-cube')).not.toBeVisible(); @@ -360,7 +368,7 @@ test('M3 按钮双击隔离:双击不绕过删除确认、不触发行重命 // 双击删除按钮:第一次点击只进入确认态,双击的第二次点击被隔离 → 对象仍在 await page.getByTestId('tree-delete-sample-light').dblclick(); await expect(page.getByTestId('tree-row-sample-light')).toBeVisible(); - await expect(page.getByTestId('tree-delete-sample-light')).toHaveText('确认?'); + await expect(page.getByTestId('tree-delete-sample-light')).toHaveAccessibleName('确认删除 主光'); // 双击按钮不触发行级 dblclick(不进入重命名) await expect(page.getByTestId('tree-rename-sample-light')).toHaveCount(0); // 单击确认 → 删除 @@ -369,7 +377,7 @@ test('M3 按钮双击隔离:双击不绕过删除确认、不触发行重命 // 双击可见性按钮:只切换一次(一次双击只产生一步「隐藏」,不因第二次点击回弹) await page.getByTestId('tree-visible-sample-cube').dblclick(); - await expect(page.getByTestId('tree-visible-sample-cube')).toHaveText('隐'); + await expect(page.getByTestId('tree-visible-sample-cube')).toHaveAccessibleName('显示 立方体'); await expect(page.getByTestId('tree-rename-sample-cube')).toHaveCount(0); }); @@ -543,8 +551,10 @@ test.describe('G-10 验收:窄屏布局', () => { expect(scrollWidth).toBeLessThanOrEqual(innerWidth); // 窄屏下列布局仍可编辑:选中对象并修改名称提交 + await page.getByTestId('editor-panel-properties').click(); await page.getByTestId('inspector-name').fill('窄屏示例组'); await page.getByTestId('inspector-name').press('Enter'); + await page.getByTestId('editor-panel-objects').click(); await expect(page.getByTestId('tree-row-sample-group')).toContainText('窄屏示例组'); }); }); diff --git a/e2e/export.spec.ts b/e2e/export.spec.ts index a341ba7..aa8a0b5 100644 --- a/e2e/export.spec.ts +++ b/e2e/export.spec.ts @@ -1634,12 +1634,12 @@ test('completes the new-project release flow without unhandled browser errors', await page.getByTitle('摄像机', { exact: true }).click(); await page.getByTestId('timeline-record').click(); - await expect(page.getByTestId('timeline-record')).toHaveText('■'); + await expect(page.getByTestId('timeline-record')).toHaveAccessibleName('停止录制'); await page.keyboard.down('w'); await page.waitForTimeout(250); await page.keyboard.up('w'); await page.getByTestId('timeline-record').click(); - await expect(page.getByTestId('timeline-record')).toHaveText('●'); + await expect(page.getByTestId('timeline-record')).toHaveAccessibleName('开始录制机位运动'); await expect(page.locator('[data-testid^="track-lane-"]')).not.toHaveCount(0); await page.getByTestId('open-storyboard-workspace').click(); diff --git a/e2e/recording-shortcut.spec.ts b/e2e/recording-shortcut.spec.ts index 9408276..6c1dc69 100644 --- a/e2e/recording-shortcut.spec.ts +++ b/e2e/recording-shortcut.spec.ts @@ -79,10 +79,9 @@ function renderedProjection(fingerprint: ProjectFingerprint): RenderedTrackFinge } async function readRenderedTimelineFingerprint(page: Page): Promise { - return page.locator('[data-testid^="track-lane-"]').evaluateAll((nodes) => + return page.locator('[data-track-id]').evaluateAll((nodes) => nodes.map((node) => { - const testId = node.getAttribute('data-testid') ?? ''; - const id = testId.slice('track-lane-'.length); + const id = node.getAttribute('data-track-id') ?? ''; return { id, targetPath: node.getAttribute('data-track-target-path') ?? '', @@ -154,7 +153,7 @@ test('accepting beforeunload discards active uncommitted recording samples', asy await page.keyboard.press('Shift+R'); await page.getByText('覆盖录制').click(); const record = page.getByTestId('timeline-record'); - await expect(record).toHaveText('■'); + await expect(record).toHaveAccessibleName('停止录制'); await page.keyboard.down('w'); await page.waitForTimeout(300); await page.keyboard.up('w'); @@ -182,12 +181,12 @@ test('saved recordings restore the same track and keyframe fingerprint after reo await page.keyboard.press('Shift+R'); await page.getByText('覆盖录制').click(); const record = page.getByTestId('timeline-record'); - await expect(record).toHaveText('■'); + await expect(record).toHaveAccessibleName('停止录制'); await page.keyboard.down('w'); await page.waitForTimeout(300); await page.keyboard.up('w'); await page.keyboard.press('Shift+R'); - await expect(record).toHaveText('●'); + await expect(record).toHaveAccessibleName('开始录制机位运动'); await expect(page.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 }); const storedBeforeClose = await readStoredRecording(page); expect(storedBeforeClose).not.toBeNull(); diff --git a/e2e/storyboard.spec.ts b/e2e/storyboard.spec.ts index e3e26ff..d7b45b6 100644 --- a/e2e/storyboard.spec.ts +++ b/e2e/storyboard.spec.ts @@ -642,6 +642,7 @@ test('maps an OpenAI-compatible authentication failure without retrying or chang test('keeps OpenAI-compatible settings usable without horizontal overflow on mobile', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); + await page.getByTestId('editor-panel-objects').click(); await page.getByTestId('panel-tab-com.lumora.openai.compatible.settings').click(); const settings = page.getByTestId('openai-compatible-settings'); await expect(settings).toBeVisible(); diff --git a/e2e/studio.spec.ts b/e2e/studio.spec.ts index ad64877..f3cfca3 100644 --- a/e2e/studio.spec.ts +++ b/e2e/studio.spec.ts @@ -55,6 +55,17 @@ test('命令面板(Ctrl+K)可过滤并执行 Mock 插件命令', async ({ pa await expect(page.getByTestId('command-palette')).not.toBeVisible(); }); +test('命令面板输入框按 Enter 执行首个匹配命令', async ({ page }) => { + await expect(page.getByTestId('panel-tab-com.lumora.mock.panel.console')).toBeVisible(); + await page.keyboard.press('Control+k'); + const input = page.getByTestId('command-palette-input'); + await input.fill('exportScene'); + await expect(page.getByTestId('palette-command-com.lumora.mock.exportScene')).toBeVisible(); + await input.press('Enter'); + await expect(page.getByTestId('command-palette')).not.toBeVisible(); + await expect(page.getByTestId('event-log')).toContainText('command:executed'); +}); + test('Mock AI 面板经 services.ai 流式对话', async ({ page }) => { await page.getByTestId('panel-tab-com.lumora.mock.panel.ai').click(); await expect(page.getByTestId('mock-ai-input')).toBeVisible(); diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 9df1a7b..9cd8c4a 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -27,7 +27,7 @@ async function startRecording(page: Page): Promise { await page.getByTestId('timeline-record').click(); await expect(page.getByTestId('overwrite-confirm')).toBeVisible(); await page.getByText('覆盖录制').click(); - await expect(page.getByTestId('timeline-record')).toHaveText('■'); + await expect(page.getByTestId('timeline-record')).toHaveAccessibleName('停止录制'); const viewport = page.getByTestId('lumora-viewport'); await viewport.click({ position: { x: 2, y: 2 }, modifiers: ['Control'] }); await expect(viewport).toBeFocused(); @@ -155,14 +155,14 @@ async function expectShotLeft(page: Page, shotId: string, expectedPx: number): P test('overwrite confirmation portal retains resolved Studio theme styles', async ({ page }) => { await page.getByTestId('tree-row-sample-camera').click(); await page.getByTestId('timeline-record').click(); - const overlay = page.getByTestId('overwrite-confirm'); - await expect(overlay).toBeVisible(); - - const styles = await overlay.evaluate((element) => { - const overlayStyle = getComputedStyle(element); - const modal = element.querySelector('.lumora-timeline__modal')!; - const button = modal.querySelector('.lumora-button')!; - const modalStyle = getComputedStyle(modal); + const modal = page.getByTestId('overwrite-confirm'); + await expect(modal).toBeVisible(); + + const styles = await modal.evaluate((element) => { + const overlay = element.closest('.lumora-modal-backdrop')!; + const overlayStyle = getComputedStyle(overlay); + const button = element.querySelector('.lumora-button')!; + const modalStyle = getComputedStyle(element); const buttonStyle = getComputedStyle(button); return { surfaceVariable: overlayStyle.getPropertyValue('--lumora-surface-2').trim(), @@ -485,7 +485,7 @@ test('recording control: keyboard-mouse mode records deterministic tap, smooth h await page.getByTestId('timeline-record').click(); await expect(page.getByTestId('timeline-time')).toHaveText('00:00.00'); - const positionLane = page.getByTestId('track-lane-sample-track-camera-dolly'); + const positionLane = page.locator('[data-track-id="sample-track-camera-dolly"]'); const rotationLane = page.locator('.lumora-timeline__lane').filter({ hasText: '录制主摄像机·旋转' }); await expect(positionLane).toHaveCount(1); await expect(rotationLane).toHaveCount(1); @@ -759,7 +759,7 @@ test('AC2 浏览器级:按住驾驶键时页面失焦 → 相机 transform 冻 // 冻结在失焦瞬间(与 moving 帧之间隔了 300ms 的持续驾驶,位移必定可辨) await page.waitForTimeout(300); await page.evaluate(() => window.dispatchEvent(new Event('blur'))); - await expect(page.getByTestId('timeline-record')).toHaveText('▶'); // 进入暂停态 + await expect(page.getByTestId('timeline-record')).toHaveAccessibleName('继续录制'); await page.waitForTimeout(600); const frozen1 = await canvasShot(page); await page.waitForTimeout(400); @@ -770,7 +770,7 @@ test('AC2 浏览器级:按住驾驶键时页面失焦 → 相机 transform 冻 // 松开按键 → 恢复录制 → 停止 await page.keyboard.up('s'); await page.getByTestId('timeline-record').click(); // ▶ = 恢复 - await expect(page.getByTestId('timeline-record')).toHaveText('■'); + await expect(page.getByTestId('timeline-record')).toHaveAccessibleName('停止录制'); await page.waitForTimeout(200); await page.getByTestId('timeline-record').click(); // 停止 }); diff --git a/e2e/tml-563-screenshots.spec.ts b/e2e/tml-563-screenshots.spec.ts new file mode 100644 index 0000000..8e2a69a --- /dev/null +++ b/e2e/tml-563-screenshots.spec.ts @@ -0,0 +1,326 @@ +import { mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import type { Browser, Page } from '@playwright/test'; + +const OUTPUT_DIR = resolve('docs/evidence/tml-563/round3-after'); +const CURRENT_OUTPUT_DIR = resolve('docs/evidence/tml-563/after'); + +async function clickToolbarItem(page: Page, testId: string) { + const item = page.getByTestId(testId); + if (!(await item.isVisible())) { + await page.getByTestId('toolbar-more').click(); + await expect(item).toBeVisible(); + } + await item.click(); +} + +async function openSampleProject(page: Page) { + await clickToolbarItem(page, 'open-sample-project'); + await expect(page.getByTestId('open-export-workspace')).toBeEnabled(); +} + +async function prepareShortShot(page: Page) { + await openSampleProject(page); + await page.getByTestId('open-storyboard-workspace').click(); + await page.getByTestId('storyboard-tab-adopted').click(); + const shortestShot = page.getByTestId('storyboard-adopted-shot').first(); + await shortestShot.locator('input[type="number"]').fill('0.1'); + await shortestShot.locator('input[type="number"]').press('Tab'); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('storyboard-workspace')).toBeHidden(); + await page.getByTitle('适配时长').click(); +} + +async function prepareAdjacentShortShots(page: Page) { + await openSampleProject(page); + await page.getByTestId('open-storyboard-workspace').click(); + await page.getByTestId('storyboard-tab-adopted').click(); + const shots = page.getByTestId('storyboard-adopted-shot'); + for (let index = 0; index < 2; index += 1) { + const duration = shots.nth(index).locator('input[type="number"]'); + await duration.fill('0.1'); + await duration.press('Tab'); + } + await page.keyboard.press('Escape'); + const targets = page.locator('[data-testid^="shot-block-"]'); + await targets.nth(1).click(); + await page.locator('[data-testid^="shot-move-left-"]:not([disabled])').click(); + await page.getByTitle('适配时长').click(); +} + +async function prepareOverwrite(page: Page) { + await openSampleProject(page); + await page.getByTestId('editor-panel-objects').click(); + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('editor-panel-scene').click(); + await page.getByTestId('timeline-record').click(); + await expect(page.getByTestId('overwrite-confirm')).toBeVisible(); +} + +async function capture(page: Page, filename: string, outputDir = OUTPUT_DIR) { + await page.screenshot({ + path: resolve(outputDir, filename), + animations: 'disabled', + }); +} + +async function captureScenario( + browser: Browser, + viewport: { width: number; height: number }, + filename: string, + arrange: (page: Page) => Promise, + url = '/', + outputDir = OUTPUT_DIR, +) { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + try { + await page.goto(url); + await arrange(page); + await page.waitForTimeout(300); + await capture(page, filename, outputDir); + } finally { + await context.close(); + } +} + +async function expectSingleRowToolbar(page: Page) { + const geometry = await page.getByTestId('lumora-toolbar').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const visibleButtons = Array.from(element.querySelectorAll('button')).filter( + (button) => button.getClientRects().length > 0, + ); + return { + height: rect.height, + contained: visibleButtons.every((button) => { + const buttonRect = button.getBoundingClientRect(); + return buttonRect.top >= rect.top && buttonRect.bottom <= rect.bottom && buttonRect.right <= rect.right; + }), + singleLine: visibleButtons.every((button) => getComputedStyle(button).whiteSpace === 'nowrap'), + }; + }); + expect(geometry.height).toBeLessThanOrEqual(52); + expect(geometry.contained).toBe(true); + expect(geometry.singleLine).toBe(true); +} + +test('captures the TML-563 round 3 before/after matrix', async ({ browser }) => { + test.setTimeout(180_000); + mkdirSync(OUTPUT_DIR, { recursive: true }); + + await captureScenario( + browser, + { width: 1440, height: 900 }, + 'hold-dual-ctrlk-scope.png', + async (page) => { + const studios = page.locator('#root [data-testid="lumora-studio"]'); + await expect(studios).toHaveCount(2); + await studios.first().dispatchEvent('keydown', { + key: 'k', + code: 'KeyK', + ctrlKey: true, + bubbles: true, + composed: true, + }); + await expect(page.getByTestId('command-palette')).toHaveCount(1); + await studios.nth(1).dispatchEvent('keydown', { + key: 'k', + code: 'KeyK', + ctrlKey: true, + bubbles: true, + composed: true, + }); + await expect(page.getByTestId('command-palette')).toHaveCount(1); + }, + '/?fixture=dual-studio', + ); + + await captureScenario( + browser, + { width: 1440, height: 900 }, + 'hold-dual-palette-ids.png', + async (page) => { + const openers = page.locator('#root [data-testid="open-command-palette"]'); + await expect(openers).toHaveCount(2); + await openers.first().evaluate((button: HTMLButtonElement) => button.click()); + await openers.nth(1).evaluate((button: HTMLButtonElement) => button.click()); + const inputs = page.getByTestId('command-palette-input'); + await expect(inputs).toHaveCount(2); + const labels = await inputs.evaluateAll((elements: HTMLInputElement[]) => elements.map((input) => ({ + id: input.id, + labelCount: input.labels?.length ?? 0, + accessibleLabel: input.labels?.[0]?.textContent?.trim(), + }))); + expect(new Set(labels.map(({ id }) => id)).size).toBe(2); + expect(labels.every(({ labelCount, accessibleLabel }) => labelCount === 1 && accessibleLabel === '搜索命令')).toBe(true); + }, + '/?fixture=dual-studio', + ); + + await captureScenario( + browser, + { width: 375, height: 667 }, + 'hold-fit-overview-loss-375x667.png', + async (page) => { + await prepareShortShot(page); + const geometry = await page.getByTestId('timeline-body').evaluate((body) => ({ + viewportWidth: body.clientWidth, + canvasWidth: body.querySelector('.lumora-timeline__canvas')!.getBoundingClientRect().width, + })); + expect(geometry.canvasWidth).toBeLessThanOrEqual(geometry.viewportWidth + 1); + await page.getByTestId('timeline-body').evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + }, + ); + + await captureScenario( + browser, + { width: 375, height: 667 }, + 'hold-shot-actions-clipped-375x667.png', + async (page) => { + await prepareShortShot(page); + await page.getByTitle('缩小').click(); + await page.locator('[data-testid^="shot-block-"]').first().click(); + const actions = page.getByTestId('selected-shot-actions'); + await expect(actions).toBeVisible(); + const hitTargets = await actions.locator('button').evaluateAll((buttons) => buttons.map((button) => { + const rect = button.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + })); + expect(hitTargets).toHaveLength(3); + expect(hitTargets.every(({ width, height }) => width >= 44 && height >= 44)).toBe(true); + }, + ); + + await captureScenario( + browser, + { width: 375, height: 667 }, + 'tml-563-keyframes-60fps-overlap-375x667.png', + async (page) => { + const lane = page.locator('[data-track-id="review-60fps"]'); + await expect(lane).toBeVisible(); + await page.getByTitle('适配时长').click(); + const keys = lane.locator('.lumora-timeline__keyframe'); + await expect(keys).toHaveCount(2); + const geometry = await keys.evaluateAll((elements) => elements.map((element) => { + const rect = element.getBoundingClientRect(); + const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + ownsCenter: document.elementFromPoint(center.x, center.y)?.closest('.lumora-timeline__keyframe') === element, + }; + })); + const [first, second] = geometry; + const overlapWidth = Math.max(0, Math.min(first!.right, second!.right) - Math.max(first!.left, second!.left)); + const overlapHeight = Math.max(0, Math.min(first!.bottom, second!.bottom) - Math.max(first!.top, second!.top)); + expect(overlapWidth * overlapHeight).toBe(0); + expect(geometry.every(({ ownsCenter }) => ownsCenter)).toBe(true); + await keys.first().focus(); + }, + '/?fixture=tml-563-round3', + ); + + await captureScenario( + browser, + { width: 1440, height: 900 }, + 'hold-overwrite-host-focus.png', + async (page) => { + await prepareOverwrite(page); + const focusState = await page.evaluate(() => { + const hostButton = document.querySelector('[data-testid="studio-mount-toggle"]')!; + const dialog = document.querySelector('[data-testid="overwrite-confirm"]')!; + hostButton.focus(); + return { + rootInert: document.querySelector('#root')!.inert, + hostFocused: document.activeElement === hostButton, + dialogFocused: dialog.contains(document.activeElement), + }; + }); + expect(focusState).toEqual({ rootInert: true, hostFocused: false, dialogFocused: true }); + }, + ); + + await captureScenario( + browser, + { width: 1440, height: 900 }, + 'hold-mixed-modal-escape-order.png', + async (page) => { + await prepareOverwrite(page); + await page.getByTestId('open-command-palette').evaluate((button: HTMLButtonElement) => button.click()); + await expect(page.getByTestId('command-palette')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('command-palette')).toHaveCount(0); + await expect(page.getByTestId('overwrite-confirm')).toBeVisible(); + }, + ); + + for (const { width, filename } of [ + { width: 1420, filename: 'toolbar-studio-1080-host-1420x768.png' }, + { width: 1421, filename: 'toolbar-studio-1081-host-1421x768.png' }, + { width: 1580, filename: 'toolbar-studio-1240-host-1580x768.png' }, + { width: 1581, filename: 'toolbar-studio-1241-host-1581x768.png' }, + ]) { + await captureScenario(browser, { width, height: 768 }, filename, async (page) => { + await openSampleProject(page); + await expectSingleRowToolbar(page); + }); + } + + await captureScenario( + browser, + { width: 375, height: 667 }, + 'portrait-log-open-375x667.png', + async (page) => { + await openSampleProject(page); + await page.getByTestId('host-log-toggle').click(); + await expect(page.getByTestId('host-event-log')).toBeVisible(); + await page.getByTestId('timeline-body').evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + const contained = await page.evaluate(() => { + const studio = document.querySelector('[data-testid="lumora-studio"]')!.getBoundingClientRect(); + const timeline = document.querySelector('[data-testid="lumora-timeline"]')!.getBoundingClientRect(); + const shots = document.querySelector('[data-testid="timeline-shots"]')!.getBoundingClientRect(); + return timeline.bottom <= studio.bottom + 1 && shots.bottom <= timeline.bottom + 1; + }); + expect(contained).toBe(true); + }, + ); +}); + +test('captures refreshed TML-563 states 14 and 15', async ({ browser }) => { + mkdirSync(CURRENT_OUTPUT_DIR, { recursive: true }); + + await captureScenario( + browser, + { width: 667, height: 375 }, + '14-mobile-landscape-log-open-667x375.png', + async (page) => { + await openSampleProject(page); + await page.getByTestId('host-log-toggle').click(); + await expect(page.getByTestId('host-log-close')).toBeFocused(); + }, + '/', + CURRENT_OUTPUT_DIR, + ); + + await captureScenario( + browser, + { width: 375, height: 667 }, + '15-mobile-fit-shot-controls-375x667.png', + async (page) => { + await prepareAdjacentShortShots(page); + await expect.poll(() => page.getByTestId('timeline-body').evaluate((body) => body.scrollLeft)).toBe(0); + await page.getByTestId('timeline-body').evaluate((body) => { + body.scrollTop = body.scrollHeight; + }); + }, + '/', + CURRENT_OUTPUT_DIR, + ); +}); diff --git a/e2e/ui-ux-regression.spec.ts b/e2e/ui-ux-regression.spec.ts new file mode 100644 index 0000000..90c88c3 --- /dev/null +++ b/e2e/ui-ux-regression.spec.ts @@ -0,0 +1,845 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; +import { decodePng, pngPixel } from './helpers/png'; + +async function sampledColorCount(page: import('@playwright/test').Page) { + const image = decodePng(await page.locator('.lumora-viewport canvas').screenshot()); + const colors = new Set(); + const step = Math.max(1, Math.floor(Math.min(image.width, image.height) / 80)); + for (let y = 0; y < image.height; y += step) { + for (let x = 0; x < image.width; x += step) { + colors.add(pngPixel(image, x, y).slice(0, 3).join(',')); + } + } + return { colors: colors.size, width: image.width, height: image.height }; +} + +async function clickToolbarItem( + page: import('@playwright/test').Page, + testId: string, + scope: import('@playwright/test').Page | import('@playwright/test').Locator = page, +) { + const item = scope.getByTestId(testId); + if (!(await item.isVisible())) await scope.getByTestId('toolbar-more').click(); + await item.click(); +} + +async function expectFirstTwoShotTargetsToBeDisjoint(page: import('@playwright/test').Page) { + const shots = page.locator('[data-testid^="shot-block-"]'); + const durations = page.locator('[data-testid^="shot-duration-"]'); + await expect(shots).toHaveCount(3); + await expect(durations).toHaveCount(3); + await shots.first().scrollIntoViewIfNeeded(); + const geometry = await shots.evaluateAll((elements) => elements.slice(0, 2).map((element) => { + const rect = element.getBoundingClientRect(); + const body = document.querySelector('[data-testid="timeline-body"]')!.getBoundingClientRect(); + const label = document.querySelector('.lumora-timeline__label--shots')!.getBoundingClientRect(); + const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + const points = [ + { x: rect.left + 1, y: center.y }, + center, + { x: rect.right - 1, y: center.y }, + ]; + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + center, + points, + fullyVisible: rect.left >= label.right - 0.5 && rect.right <= body.right + 0.5, + ownsPoints: points.every((point) => + document.elementFromPoint(point.x, point.y)?.closest('[data-testid^="shot-block-"]') === element, + ), + }; + })); + expect(geometry).toHaveLength(2); + expect(geometry.every(({ width, height }) => width >= 44 && height >= 44)).toBe(true); + const [first, second] = geometry; + const overlapWidth = Math.max(0, Math.min(first!.right, second!.right) - Math.max(first!.left, second!.left)); + const overlapHeight = Math.max(0, Math.min(first!.bottom, second!.bottom) - Math.max(first!.top, second!.top)); + expect(overlapWidth * overlapHeight).toBe(0); + expect(geometry.every(({ fullyVisible, ownsPoints }) => fullyVisible && ownsPoints)).toBe(true); + + const durationGeometry = await durations.evaluateAll((elements) => elements.slice(0, 2).map((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width }; + })); + expect(durationGeometry.every(({ width }) => width >= 3)).toBe(true); + expect(Math.abs(durationGeometry[0]!.right - durationGeometry[1]!.left)).toBeLessThanOrEqual(1); + + for (const [index, { points }] of geometry.entries()) { + for (const point of points) { + await page.mouse.click(point.x, point.y); + await expect(shots.nth(index)).toHaveAttribute('aria-pressed', 'true'); + } + } +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await clickToolbarItem(page, 'open-sample-project'); + await expect(page.getByTestId('open-export-workspace')).toBeEnabled(); +}); + +test('1024 desktop uses the Studio container width and preserves the scene workspace', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 768 }); + await page.getByTestId('editor-panel-objects').click(); + await page.getByTestId('tree-row-sample-cube').click(); + await page.getByTestId('editor-panel-scene').click(); + const studio = page.getByTestId('lumora-studio'); + const measurements = await page.evaluate(() => { + const studioRect = document.querySelector('[data-testid="lumora-studio"]')!.getBoundingClientRect(); + const viewportRect = document.querySelector('[data-testid="lumora-viewport"]')!.getBoundingClientRect(); + return { studioWidth: studioRect.width, viewportWidth: viewportRect.width, viewportHeight: viewportRect.height }; + }); + expect(measurements.studioWidth).toBeLessThan(760); + expect(measurements.viewportWidth).toBeGreaterThanOrEqual(480); + expect(measurements.viewportHeight).toBeGreaterThanOrEqual(280); + await expect(studio.getByRole('tab', { name: '场景' })).toHaveAttribute('aria-selected', 'true'); +}); + +test('Studio 1080/1081 boundary keeps the toolbar on one contained row', async ({ page }) => { + const heights: number[] = []; + for (const width of [1420, 1421]) { + await page.setViewportSize({ width, height: 768 }); + const measurements = await page.evaluate(() => { + const studio = document.querySelector('[data-testid="lumora-studio"]')!; + const toolbar = document.querySelector('[data-testid="lumora-toolbar"]')!; + const more = document.querySelector('[data-testid="toolbar-more"]')!; + const toolbarRect = toolbar.getBoundingClientRect(); + const visibleButtons = Array.from(toolbar.querySelectorAll('button')).filter( + (button) => button.getClientRects().length > 0, + ); + return { + studio: studio.getBoundingClientRect().width, + height: toolbarRect.height, + moreVisible: getComputedStyle(more).display !== 'none', + contained: visibleButtons.every((button) => { + const rect = button.getBoundingClientRect(); + return rect.top >= toolbarRect.top && rect.bottom <= toolbarRect.bottom && rect.right <= toolbarRect.right; + }), + }; + }); + expect(measurements.studio).toBeCloseTo(width - 340, 0); + expect(measurements.moreVisible).toBe(true); + expect(measurements.contained).toBe(true); + expect(measurements.height).toBeLessThanOrEqual(52); + heights.push(measurements.height); + } + expect(Math.abs(heights[1]! - heights[0]!)).toBeLessThan(1); +}); + +test('Studio 1240/1241 boundary remains a compact single row', async ({ page }) => { + const heights: number[] = []; + for (const width of [1580, 1581]) { + await page.setViewportSize({ width, height: 768 }); + const geometry = await page.getByTestId('lumora-toolbar').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const visibleButtons = Array.from(element.querySelectorAll('button')).filter( + (button) => button.getClientRects().length > 0, + ); + return { + height: rect.height, + contained: visibleButtons.every((button) => { + const buttonRect = button.getBoundingClientRect(); + return buttonRect.top >= rect.top && buttonRect.bottom <= rect.bottom && buttonRect.right <= rect.right; + }), + }; + }); + expect(geometry.height).toBeLessThanOrEqual(52); + expect(geometry.contained).toBe(true); + heights.push(geometry.height); + } + expect(Math.abs(heights[1]! - heights[0]!)).toBeLessThan(1); +}); + +test('mobile defaults to a usable scene and keeps the host log collapsed', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await expect(page.getByTestId('host-event-log')).toBeHidden(); + await expect(page.getByTestId('host-log-toggle')).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByTestId('lumora-toolbar')).toHaveCSS('flex-wrap', 'nowrap'); + + const viewport = page.getByTestId('lumora-viewport'); + const box = await viewport.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(220); + await page.getByTestId('editor-panel-objects').click(); + await expect(page.getByTestId('tree-row-sample-cube')).toBeVisible(); + await page.getByTestId('editor-panel-scene').click(); + await expect(viewport).toBeVisible(); +}); + +test('375 fit zoom shows the whole duration and keeps selected-shot actions in the sticky label', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.getByTestId('open-storyboard-workspace').click(); + await page.getByTestId('storyboard-tab-adopted').click(); + const shortestValidShot = page.getByTestId('storyboard-adopted-shot').first(); + await shortestValidShot.locator('input[type="number"]').fill('0.1'); + await shortestValidShot.locator('input[type="number"]').press('Tab'); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('storyboard-workspace')).toBeHidden(); + await page.getByRole('button', { name: '适配' }).click(); + + const shots = page.locator('[data-testid^="shot-block-"]'); + await expect(shots).toHaveCount(3); + const fitGeometry = await page.getByTestId('timeline-body').evaluate((body) => ({ + clientWidth: body.clientWidth, + canvasWidth: body.querySelector('.lumora-timeline__canvas')!.getBoundingClientRect().width, + })); + expect(fitGeometry.canvasWidth).toBeLessThanOrEqual(fitGeometry.clientWidth + 1); + + await shots.first().click(); + const actions = page.getByTestId('selected-shot-actions'); + await expect(actions).toBeVisible(); + const actionGeometry = await actions.locator('button').evaluateAll((buttons) => + buttons.map((button) => { + const rect = button.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }), + ); + expect(actionGeometry).toHaveLength(3); + expect(actionGeometry.every(({ width, height }) => width >= 44 && height >= 44)).toBe(true); + expect(await actions.evaluate((element) => getComputedStyle(element.parentElement!).position)).toBe('sticky'); +}); + +test('375 adjacent 0.1s shots keep disjoint 44px targets at fit, zoom-out, and minimum zoom', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.getByTestId('open-storyboard-workspace').click(); + await page.getByTestId('storyboard-tab-adopted').click(); + const adoptedShots = page.getByTestId('storyboard-adopted-shot'); + await expect(adoptedShots).toHaveCount(3); + for (let index = 0; index < 2; index += 1) { + const duration = adoptedShots.nth(index).locator('input[type="number"]'); + await duration.fill('0.1'); + await duration.press('Tab'); + } + await page.keyboard.press('Escape'); + await expect(page.getByTestId('storyboard-workspace')).toBeHidden(); + + const shots = page.locator('[data-testid^="shot-block-"]'); + await shots.nth(1).click(); + await page.locator('[data-testid^="shot-move-left-"]:not([disabled])').click(); + await page.getByRole('button', { name: '适配' }).click(); + await expect.poll(() => page.getByTestId('timeline-body').evaluate((body) => body.scrollLeft)).toBe(0); + await expectFirstTwoShotTargetsToBeDisjoint(page); + + await page.getByTitle('缩小').click(); + await expectFirstTwoShotTargetsToBeDisjoint(page); + + for (let index = 0; index < 12; index += 1) await page.getByTitle('缩小').click(); + await expectFirstTwoShotTargetsToBeDisjoint(page); +}); + +test('out-of-order imported shots use chronological action boundaries and reorder order', async ({ page }) => { + await page.goto('/?fixture=tml-563-timeline-edges'); + const shots = page.locator('[data-testid^="shot-block-review-"]'); + await expect(shots).toHaveCount(3); + await expect.poll(() => shots.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-testid')))) + .toEqual([ + 'shot-block-review-short', + 'shot-block-review-long', + 'shot-block-review-late', + ]); + + await page.getByTestId('shot-block-review-short').click(); + await expect(page.getByTestId('shot-move-left-review-short')).toBeDisabled(); + await expect(page.getByTestId('shot-move-right-review-short')).toBeEnabled(); + + await page.getByTestId('shot-block-review-late').click(); + await expect(page.getByTestId('shot-move-left-review-late')).toBeEnabled(); + await expect(page.getByTestId('shot-move-right-review-late')).toBeDisabled(); + + await page.getByTestId('shot-block-review-long').click(); + await page.getByTestId('shot-move-left-review-long').click(); + await expect.poll(() => shots.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-testid')))) + .toEqual([ + 'shot-block-review-long', + 'shot-block-review-short', + 'shot-block-review-late', + ]); + await expect(page.getByTestId('shot-duration-review-long')).toHaveCSS('left', '0px'); + await expect(page.getByTestId('shot-move-left-review-long')).toBeDisabled(); +}); + +test('selected overlapping shot duration remains visibly accented', async ({ page }) => { + await page.goto('/?fixture=tml-563-timeline-edges'); + await page.getByTestId('shot-block-review-short').click(); + await page.getByTestId('timeline-body').evaluate((body) => { + body.scrollLeft = 0; + body.scrollTop = body.scrollHeight; + }); + const selected = page.getByTestId('shot-duration-review-short'); + const covering = page.getByTestId('shot-duration-review-long'); + const metrics = await selected.evaluate((element, coveringElement) => { + const rect = element.getBoundingClientRect(); + const coveringRect = (coveringElement as HTMLElement).getBoundingClientRect(); + const labelRect = document.querySelector('.lumora-timeline__label--shots')!.getBoundingClientRect(); + const sampleX = Math.min(rect.right - 2, Math.max(rect.left + 2, labelRect.right + 2)); + const color = getComputedStyle(element).backgroundColor.match(/\d+/g)!.slice(0, 3).map(Number); + return { + x: sampleX, + y: rect.top + rect.height / 2, + color, + overlap: Math.max(0, Math.min(rect.right, coveringRect.right) - Math.max(rect.left, coveringRect.left)), + sampleInsideSelected: sampleX >= rect.left && sampleX < rect.right, + }; + }, await covering.elementHandle()); + expect(metrics.overlap).toBeGreaterThan(0); + expect(metrics.sampleInsideSelected).toBe(true); + + const image = decodePng(await page.screenshot({ animations: 'disabled' })); + expect(pngPixel(image, Math.floor(metrics.x), Math.floor(metrics.y)).slice(0, 3)).toEqual(metrics.color); +}); + +test('375 exact 60fps adjacent keyframes have disjoint hitboxes and own both visible centers', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/?fixture=tml-563-round3'); + const lane = page.locator('[data-track-id="review-60fps"]'); + await expect(lane).toBeVisible(); + await page.getByRole('button', { name: '适配' }).click(); + const keys = lane.locator('.lumora-timeline__keyframe'); + await expect(keys).toHaveCount(2); + + const geometry = await keys.evaluateAll((elements) => elements.map((element) => { + const rect = element.getBoundingClientRect(); + const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + ownsCenter: document.elementFromPoint(center.x, center.y)?.closest('.lumora-timeline__keyframe') === element, + }; + })); + const [first, second] = geometry; + const overlapWidth = Math.max(0, Math.min(first!.right, second!.right) - Math.max(first!.left, second!.left)); + const overlapHeight = Math.max(0, Math.min(first!.bottom, second!.bottom) - Math.max(first!.top, second!.top)); + expect(overlapWidth * overlapHeight).toBe(0); + expect(geometry.every(({ ownsCenter }) => ownsCenter)).toBe(true); +}); + +test('375 dense 60fps track stays bounded at default and minimum zoom', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/?fixture=tml-563-round3-dense'); + const lane = page.locator('[data-track-id="review-60fps"]'); + await expect(lane).toBeVisible(); + + const measure = () => page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); + const body = rect('[data-testid="timeline-body"]'); + const row = rect('[data-track-id="review-60fps"]'); + const label = rect('[data-testid="track-lane-review-60fps"]'); + const shots = rect('[data-testid="timeline-shots"]'); + const clusters = Array.from(document.querySelectorAll('[data-testid^="keyframe-cluster-review-60fps-"]')); + return { + rowHeight: row.height, + labelVisible: label.top >= body.top && label.bottom <= body.bottom, + shotsVisible: shots.top >= body.top && shots.bottom <= body.bottom, + clusterCount: clusters.length, + clusteredFrames: clusters.reduce((sum, cluster) => sum + Number(cluster.dataset.keyframeCount), 0), + }; + }); + + let geometry = await measure(); + expect(geometry.rowHeight).toBeLessThanOrEqual(88); + expect(geometry.labelVisible).toBe(true); + expect(geometry.shotsVisible).toBe(true); + expect(geometry.clusterCount).toBeGreaterThan(1); + expect(geometry.clusteredFrames).toBe(60); + + for (let index = 0; index < 6; index += 1) await page.getByTitle('缩小').click(); + + geometry = await measure(); + expect(geometry.rowHeight).toBeLessThanOrEqual(88); + expect(geometry.labelVisible).toBe(true); + expect(geometry.shotsVisible).toBe(true); + expect(geometry.clusterCount).toBe(1); + expect(geometry.clusteredFrames).toBe(60); +}); + +test('375 storyboard close and delete controls use 44px icon targets', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.getByTestId('open-storyboard-workspace').click(); + await page.getByTestId('storyboard-tab-adopted').click(); + + const controls = page.getByRole('button', { name: /关闭 AI 分镜工作台|删除分镜/ }); + expect(await controls.count()).toBeGreaterThan(1); + for (let index = 0; index < await controls.count(); index += 1) { + const control = controls.nth(index); + const box = await control.boundingBox(); + expect(box?.width ?? 0).toBeGreaterThanOrEqual(44); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + const deletes = page.getByRole('button', { name: /删除分镜/ }); + await expect(deletes.first().locator('svg.lucide-trash-2')).toBeVisible(); +}); + +test('667x375 expanded host log keeps a complete keyframe target and shot row inside Timeline', async ({ page }) => { + await page.setViewportSize({ width: 667, height: 375 }); + const toggle = page.getByTestId('host-log-toggle'); + await toggle.focus(); + await toggle.click(); + const log = page.getByTestId('host-event-log'); + const close = page.getByTestId('host-log-close'); + await expect(log).toBeVisible(); + await expect(log).toHaveAttribute('role', 'dialog'); + await expect(log).toHaveAttribute('aria-modal', 'true'); + await expect(page.getByTestId('host-inertable-root')).toHaveAttribute('inert', ''); + await expect(close).toBeFocused(); + const closeBox = await close.boundingBox(); + expect(closeBox?.width ?? 0).toBeGreaterThanOrEqual(44); + expect(closeBox?.height ?? 0).toBeGreaterThanOrEqual(44); + + for (let index = 0; index < 4; index += 1) { + await page.keyboard.press(index % 2 === 0 ? 'Tab' : 'Shift+Tab'); + expect(await log.evaluate((element) => element.contains(document.activeElement))).toBe(true); + } + + const obscuredControls = await page.evaluate(() => { + const drawer = document.querySelector('[data-testid="host-event-log"]')!.getBoundingClientRect(); + const controls = Array.from(document.querySelectorAll( + '[data-testid="host-studio-region"] button, [data-testid="host-studio-region"] input, [data-testid="host-studio-region"] [tabindex]', + )).filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && rect.left < drawer.right && rect.right > drawer.left + && rect.top < drawer.bottom && rect.bottom > drawer.top; + }); + return { + count: controls.length, + allInert: controls.every((element) => element.closest('[inert]') !== null), + allCovered: controls.every((element) => { + const rect = element.getBoundingClientRect(); + const x = Math.max(drawer.left + 1, Math.min(drawer.right - 1, rect.left + rect.width / 2)); + const y = Math.max(drawer.top + 1, Math.min(drawer.bottom - 1, rect.top + rect.height / 2)); + const hit = document.elementFromPoint(x, y); + return hit?.closest('[data-testid="host-event-log"]') !== null; + }), + }; + }); + expect(obscuredControls.count).toBeGreaterThan(0); + expect(obscuredControls.allInert).toBe(true); + expect(obscuredControls.allCovered).toBe(true); + + await page.getByTestId('timeline-body').evaluate((element) => { + element.scrollTop = 0; + }); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); + const studio = rect('[data-testid="lumora-studio"]'); + const scene = rect('[data-testid="lumora-viewport"]'); + const timeline = rect('[data-testid="lumora-timeline"]'); + const body = rect('[data-testid="timeline-body"]'); + const ruler = rect('[data-testid="timeline-ruler"]'); + const keyframeElement = document.querySelector('.lumora-timeline__keyframe')!; + const keyframe = keyframeElement.getBoundingClientRect(); + const keyframeCenter = { x: keyframe.left + keyframe.width / 2, y: keyframe.top + keyframe.height / 2 }; + return { + studio: { top: studio.top, bottom: studio.bottom }, + scene: { top: scene.top, bottom: scene.bottom, height: scene.height }, + timeline: { top: timeline.top, bottom: timeline.bottom, height: timeline.height }, + body: { top: body.top, bottom: body.bottom, height: body.height }, + ruler: { bottom: ruler.bottom }, + keyframe: { + top: keyframe.top, + bottom: keyframe.bottom, + width: keyframe.width, + height: keyframe.height, + isInert: keyframeElement.closest('[inert]') !== null, + ownsCenter: document.elementFromPoint(keyframeCenter.x, keyframeCenter.y)?.closest('.lumora-timeline__keyframe') === keyframeElement, + }, + }; + }); + expect(geometry.scene.height).toBeGreaterThanOrEqual(100); + expect(geometry.timeline.height).toBeGreaterThanOrEqual(118); + expect(geometry.scene.top).toBeGreaterThanOrEqual(geometry.studio.top); + expect(geometry.timeline.bottom).toBeLessThanOrEqual(geometry.studio.bottom + 1); + expect(geometry.keyframe.width).toBeGreaterThanOrEqual(44); + expect(geometry.keyframe.height).toBeGreaterThanOrEqual(44); + expect(geometry.keyframe.top).toBeGreaterThanOrEqual(geometry.ruler.bottom - 1); + expect(geometry.keyframe.bottom).toBeLessThanOrEqual(geometry.body.bottom + 1); + expect(geometry.keyframe.isInert).toBe(true); + expect(geometry.keyframe.ownsCenter).toBe(false); + + await page.getByTestId('timeline-body').evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + const bottomGeometry = await page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); + const body = rect('[data-testid="timeline-body"]'); + const ruler = rect('[data-testid="timeline-ruler"]'); + const shots = rect('[data-testid="timeline-shots"]'); + const shotTargetElement = document.querySelector('[data-testid^="shot-block-"]')!; + const shotTarget = shotTargetElement.getBoundingClientRect(); + const shotCenter = { x: shotTarget.left + shotTarget.width / 2, y: shotTarget.top + shotTarget.height / 2 }; + return { + bodyBottom: body.bottom, + rulerBottom: ruler.bottom, + shots: { top: shots.top, bottom: shots.bottom, height: shots.height }, + shotTarget: { + top: shotTarget.top, + bottom: shotTarget.bottom, + height: shotTarget.height, + isInert: shotTargetElement.closest('[inert]') !== null, + ownsCenter: document.elementFromPoint(shotCenter.x, shotCenter.y)?.closest('[data-testid^="shot-block-"]') === shotTargetElement, + }, + }; + }); + expect(bottomGeometry.shots.height).toBeGreaterThanOrEqual(58); + expect(bottomGeometry.shots.top).toBeGreaterThanOrEqual(bottomGeometry.rulerBottom - 1); + expect(bottomGeometry.shots.bottom).toBeLessThanOrEqual(bottomGeometry.bodyBottom + 1); + expect(bottomGeometry.shotTarget.height).toBeGreaterThanOrEqual(44); + expect(bottomGeometry.shotTarget.top).toBeGreaterThanOrEqual(bottomGeometry.rulerBottom - 1); + expect(bottomGeometry.shotTarget.bottom).toBeLessThanOrEqual(bottomGeometry.bodyBottom + 1); + expect(bottomGeometry.shotTarget.isInert).toBe(true); + expect(bottomGeometry.shotTarget.ownsCenter).toBe(false); + + await page.keyboard.press('Escape'); + await expect(log).toBeHidden(); + await expect(page.getByTestId('host-inertable-root')).not.toHaveAttribute('inert', ''); + await expect(toggle).toBeFocused(); + expect(await page.evaluate(() => { + const shot = document.querySelector('[data-testid^="shot-block-"]')!; + const rect = shot.getBoundingClientRect(); + return document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) + ?.closest('[data-testid^="shot-block-"]') === shot; + })).toBe(true); +}); + +test('667x375 host log modal contains all host focus and keeps global Escape available', async ({ page }) => { + await page.setViewportSize({ width: 667, height: 375 }); + const toggle = page.getByTestId('host-log-toggle'); + const log = page.getByTestId('host-event-log'); + const close = page.getByTestId('host-log-close'); + const background = page.getByTestId('host-inertable-root'); + const reopen = page.getByTestId('reopen-last-export'); + await toggle.click(); + await expect(close).toBeFocused(); + await expect(background).toHaveAttribute('inert', ''); + + const eventCount = await page.getByTestId('event-log').locator('li').count(); + const reopenBox = await reopen.boundingBox(); + await page.mouse.click( + reopenBox!.x + reopenBox!.width / 2, + reopenBox!.y + reopenBox!.height / 2, + ); + await expect(close).toBeFocused(); + await expect(page.getByTestId('event-log').locator('li')).toHaveCount(eventCount); + + await page.locator('body').evaluate((body) => { + body.tabIndex = -1; + body.focus(); + }); + await expect(close).toBeFocused(); + + const viewportBox = await page.getByTestId('lumora-viewport').boundingBox(); + await page.mouse.click(viewportBox!.x + 20, viewportBox!.y + 20); + await page.keyboard.press('Escape'); + await expect(log).toBeHidden(); + await expect(background).not.toHaveAttribute('inert', ''); + await expect(toggle).toBeFocused(); +}); + +test('375x667 expanded host log keeps the complete timeline inside Studio', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.getByTestId('host-log-toggle').click(); + await expect(page.getByTestId('host-event-log')).toBeVisible(); + await page.getByTestId('timeline-body').evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); + const studio = rect('[data-testid="lumora-studio"]'); + const timeline = rect('[data-testid="lumora-timeline"]'); + const timelineBody = rect('[data-testid="timeline-body"]'); + const shots = rect('[data-testid="timeline-shots"]'); + return { + studioBottom: studio.bottom, + timelineBottom: timeline.bottom, + timelineBodyBottom: timelineBody.bottom, + shotsBottom: shots.bottom, + }; + }); + expect(geometry.timelineBottom).toBeLessThanOrEqual(geometry.studioBottom + 1); + expect(geometry.timelineBodyBottom).toBeLessThanOrEqual(geometry.studioBottom + 1); + expect(geometry.shotsBottom).toBeLessThanOrEqual(geometry.timelineBodyBottom + 1); +}); + +test('mobile editor tabs support roving keyboard navigation', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + const sceneTab = page.getByTestId('editor-panel-scene'); + const objectsTab = page.getByTestId('editor-panel-objects'); + const propertiesTab = page.getByTestId('editor-panel-properties'); + + await sceneTab.focus(); + await page.keyboard.press('ArrowRight'); + await expect(objectsTab).toBeFocused(); + await expect(objectsTab).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('End'); + await expect(propertiesTab).toBeFocused(); + await expect(propertiesTab).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('Home'); + await expect(sceneTab).toBeFocused(); + await expect(sceneTab).toHaveAttribute('aria-selected', 'true'); +}); + +test('mobile overflow menu supports menu keys and visible modal focus restoration', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + const moreButton = page.getByTestId('toolbar-more'); + await moreButton.click(); + const firstItem = page.getByTestId('open-sample-project'); + const secondItem = page.getByTestId('close-project'); + await expect(firstItem).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(secondItem).toBeFocused(); + await page.keyboard.press('End'); + await expect(page.getByTestId('open-plugin-manager')).toBeFocused(); + await page.keyboard.press('Home'); + await expect(firstItem).toBeFocused(); + + const pluginOpener = page.getByTestId('open-plugin-manager'); + await pluginOpener.click(); + await page.getByTestId('close-plugin-manager').click(); + await expect(pluginOpener).toBeVisible(); + await expect(pluginOpener).toBeFocused(); + + const paletteOpener = page.getByTestId('open-command-palette'); + await paletteOpener.click(); + await page.locator('.lumora-modal-backdrop').click({ position: { x: 1, y: 1 } }); + await expect(paletteOpener).toBeVisible(); + await expect(paletteOpener).toBeFocused(); +}); + +test('mobile Ctrl+K restores the visible pre-shortcut focus target', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + const moreButton = page.getByTestId('toolbar-more'); + await moreButton.focus(); + await page.keyboard.press('Control+k'); + await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(moreButton).toBeFocused(); +}); + +test('desktop and mobile canvases render nonblank scene pixels at stable dimensions', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 768 }); + let pixels = await sampledColorCount(page); + expect(pixels.width).toBeGreaterThanOrEqual(480); + expect(pixels.height).toBeGreaterThanOrEqual(280); + expect(pixels.colors).toBeGreaterThan(20); + + await page.setViewportSize({ width: 375, height: 667 }); + pixels = await sampledColorCount(page); + expect(pixels.width).toBeGreaterThanOrEqual(350); + expect(pixels.height).toBeGreaterThanOrEqual(220); + expect(pixels.colors).toBeGreaterThan(20); +}); + +test('plugin manager and command palette contain focus and restore their openers', async ({ page }) => { + const pluginOpener = page.getByTestId('open-plugin-manager'); + await clickToolbarItem(page, 'open-plugin-manager'); + const pluginDialog = page.getByRole('dialog', { name: '插件管理' }); + await expect(pluginDialog).toHaveAttribute('aria-modal', 'true'); + await page.keyboard.press('Shift+Tab'); + await expect(pluginDialog).toContainText('插件管理'); + expect(await pluginDialog.evaluate((element) => element.contains(document.activeElement))).toBe(true); + await page.keyboard.press('Escape'); + await expect(pluginOpener).toBeFocused(); + + const paletteOpener = page.getByTestId('open-command-palette'); + await clickToolbarItem(page, 'open-command-palette'); + const palette = page.getByRole('dialog', { name: '命令面板' }); + await expect(page.getByTestId('command-palette-input')).toHaveAccessibleName('搜索命令'); + await page.keyboard.press('Shift+Tab'); + expect(await palette.evaluate((element) => element.contains(document.activeElement))).toBe(true); + await page.keyboard.press('Escape'); + await expect(paletteOpener).toBeFocused(); +}); + +test('portal modal inerts the host root and restores a deep ShadowRoot opener', async ({ page }) => { + await clickToolbarItem(page, 'open-plugin-manager'); + const pluginDialog = page.getByRole('dialog', { name: '插件管理' }); + await expect(page.locator('#root')).toHaveAttribute('inert', ''); + const escaped = await page.evaluate(() => { + const target = document.querySelector('[data-testid="studio-mount-toggle"]')!; + target.focus(); + return document.activeElement === target; + }); + expect(escaped).toBe(false); + expect(await pluginDialog.evaluate((dialog) => dialog.contains(document.activeElement))).toBe(true); + await page.keyboard.press('Escape'); + + await page.evaluate(() => { + const host = document.createElement('div'); + host.id = 'shadow-focus-host'; + const shadow = host.attachShadow({ mode: 'open' }); + const button = document.createElement('button'); + button.id = 'shadow-focus-opener'; + button.textContent = 'Shadow opener'; + shadow.append(button); + document.body.append(host); + button.focus(); + }); + await page.evaluate(() => { + document.querySelector('[data-testid="open-command-palette"]')!.click(); + }); + await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible(); + await page.keyboard.press('Escape'); + expect(await page.evaluate(() => + document.querySelector('#shadow-focus-host')?.shadowRoot?.activeElement?.id, + )).toBe('shadow-focus-opener'); +}); + +test('plugin toggles keep browser focus through active, disabled, and failed transitions', async ({ page }) => { + await clickToolbarItem(page, 'open-plugin-manager'); + const activeToggle = page.getByTestId('plugin-toggle-com.lumora.mock'); + await activeToggle.click(); + await expect(page.getByTestId('plugin-state-com.lumora.mock')).toHaveText('已禁用'); + await expect(activeToggle).toBeFocused(); + await activeToggle.click(); + await expect(page.getByTestId('plugin-state-com.lumora.mock')).toHaveText('运行中'); + await expect(activeToggle).toBeFocused(); + + const failedToggle = page.getByTestId('plugin-toggle-com.example.brokenmanifest'); + await failedToggle.click(); + await expect(page.getByTestId('plugin-state-com.example.brokenmanifest')).toHaveText('已停用'); + await expect(failedToggle).toBeFocused(); +}); + +test('two Studio instances route Escape to the top portal only', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/?fixture=dual-studio'); + const studios = page.getByTestId('lumora-studio'); + await expect(studios).toHaveCount(2); + const openers = page.getByTestId('open-plugin-manager'); + await expect(openers).toHaveCount(2); + await clickToolbarItem(page, 'open-plugin-manager', studios.first()); + const lowerDialog = page.getByRole('dialog', { name: '插件管理' }).first(); + await openers.nth(1).evaluate((button) => button.click()); + await expect(page.getByRole('dialog', { name: '插件管理' })).toHaveCount(2); + + await page.keyboard.press('Escape'); + + await expect(page.getByRole('dialog', { name: '插件管理' })).toHaveCount(1); + await expect(lowerDialog).toBeVisible(); +}); + +test('host log modal removes both Studio instances from interaction and the AX tree', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'CDP accessibility assertions require Chromium'); + await page.setViewportSize({ width: 667, height: 375 }); + await page.goto('/?fixture=dual-studio'); + const studios = page.getByTestId('lumora-studio'); + await expect(studios).toHaveCount(2); + await page.getByTestId('host-log-toggle').click(); + + const dialog = page.getByTestId('host-event-log'); + const close = page.getByTestId('host-log-close'); + const secondProjectButton = studios.nth(1).getByTestId('project-menu'); + await expect(dialog).toHaveAttribute('aria-modal', 'true'); + await expect(close).toBeFocused(); + await expect(page.getByTestId('host-inertable-root')).toHaveAttribute('inert', ''); + expect.soft(await secondProjectButton.evaluate((button) => button.closest('[inert]') !== null)).toBe(true); + + const cdp = await page.context().newCDPSession(page); + const { root } = await cdp.send('DOM.getDocument'); + const { nodeId } = await cdp.send('DOM.querySelector', { + nodeId: root.nodeId, + selector: '[data-testid="dual-studio-fixture"] [data-testid="project-menu"]', + }); + const { nodes } = await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false }); + expect.soft(nodes[0]?.ignored).toBe(true); + expect.soft(nodes[0]?.ignoredReasons?.some((reason) => reason.name === 'inertElement')).toBe(true); + + await secondProjectButton.evaluate((button: HTMLButtonElement) => button.click()); + await expect.soft(studios.nth(1).getByTestId('project-menu-dropdown')).toBeHidden(); + await expect(dialog).toBeVisible(); + await expect(close).toBeFocused(); +}); + +test('overwrite confirmation participates in the shared modal stack below a later command palette', async ({ page }) => { + await page.getByTestId('editor-panel-objects').click(); + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('editor-panel-scene').click(); + await page.getByTestId('timeline-record').click(); + await expect(page.getByTestId('overwrite-confirm')).toBeVisible(); + await page.getByTestId('open-command-palette').evaluate((button: HTMLButtonElement) => button.click()); + await expect(page.getByTestId('command-palette')).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(page.getByTestId('overwrite-confirm')).toBeVisible(); + await expect(page.getByTestId('command-palette')).toHaveCount(0); +}); + +test('main editor and dialogs have no WCAG A/AA axe violations', async ({ page }) => { + await page.getByTestId('editor-panel-objects').click(); + await page.getByTestId('tree-row-sample-cube').click(); + await page.getByTestId('editor-panel-scene').click(); + let results = await new AxeBuilder({ page }) + .include('[data-testid="lumora-studio"]') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze(); + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]); + + await clickToolbarItem(page, 'open-plugin-manager'); + results = await new AxeBuilder({ page }) + .include('[data-testid="plugin-manager"]') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze(); + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]); +}); + +test('Mock plugin controls inherit Studio surface, border, text, focus, and disabled tokens', async ({ page }) => { + await page.getByTestId('editor-panel-objects').click(); + await page.getByTestId('panel-tab-com.lumora.mock.panel.ai').click(); + const panel = page.getByTestId('mock-ai-panel'); + const input = page.getByTestId('mock-ai-input'); + const send = page.getByTestId('mock-ai-send'); + await input.focus(); + + const styles = await panel.evaluate((element) => { + const studio = element.closest('.lumora-studio')!; + const inputElement = element.querySelector('[data-testid="mock-ai-input"]')!; + const resolveColor = (variable: string) => { + const probe = document.createElement('span'); + probe.style.color = `var(${variable})`; + studio.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }; + return { + panelBackground: getComputedStyle(element).backgroundColor, + surface: resolveColor('--lumora-surface'), + inputBackground: getComputedStyle(inputElement).backgroundColor, + background: resolveColor('--lumora-bg'), + inputBorder: getComputedStyle(inputElement).borderColor, + border: resolveColor('--lumora-border'), + inputText: getComputedStyle(inputElement).color, + text: resolveColor('--lumora-text'), + inputOutline: getComputedStyle(inputElement).outlineColor, + accent: resolveColor('--lumora-accent'), + }; + }); + expect(styles.panelBackground).toBe(styles.surface); + expect(styles.inputBackground).toBe(styles.background); + expect(styles.inputBorder).toBe(styles.border); + expect(styles.inputText).toBe(styles.text); + expect(styles.inputOutline).toBe(styles.accent); + + await input.fill('token check'); + await send.click(); + await expect(send).toBeDisabled(); + const disabledColor = await send.evaluate((button) => getComputedStyle(button).color); + const disabledToken = await panel.evaluate((element) => { + const probe = document.createElement('span'); + probe.style.color = 'var(--lumora-text-disabled)'; + element.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + expect(disabledColor).toBe(disabledToken); +}); diff --git a/examples/embedded-host/src/App.tsx b/examples/embedded-host/src/App.tsx index 8aad16b..cafe230 100644 --- a/examples/embedded-host/src/App.tsx +++ b/examples/embedded-host/src/App.tsx @@ -1,5 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; -import type { Manifest, PluginDescriptor } from '@lumora/core'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { X } from 'lucide-react'; +import { createSampleProject } from '@lumora/core'; +import type { Manifest, PluginDescriptor, Project } from '@lumora/core'; import { LumoraStudio } from '@lumora/studio'; import type { LumoraStudioHandle } from '@lumora/studio'; import mockManifest from '@lumora/mock-plugin/lumora.plugin.json'; @@ -172,6 +175,50 @@ const DEBUG_FULL = new URLSearchParams(window.location.search).get('debug') === /** 本地存储后端选择:?storage=opfs 使用 OPFS,缺省 IndexedDB(持久化门面可切换,TML-53 范围项) */ const STORAGE = new URLSearchParams(window.location.search).get('storage') === 'opfs' ? 'opfs' : 'indexeddb'; +const DUAL_STUDIO_FIXTURE = new URLSearchParams(window.location.search).get('fixture') === 'dual-studio'; +const REVIEW_FIXTURE = new URLSearchParams(window.location.search).get('fixture'); + +function createRound3ReviewProject(dense = false): Project { + const project = createSampleProject('lumora://tml-563-round3', 'TML-563 60fps 回归'); + const sourceTrack = project.tracks[0]!; + return { + ...project, + settings: { ...project.settings, fps: 60 }, + tracks: [{ + ...sourceTrack, + id: 'review-60fps', + name: '60fps 相邻关键帧', + keyframes: dense + ? Array.from({ length: 60 }, (_, index) => ({ + time: 1 + index / 60, + value: [index, 0, 0] as [number, number, number], + })) + : [ + { time: 1, value: [0, 0, 0] }, + { time: 1 + 1 / 60, value: [1, 0, 0] }, + ], + }], + }; +} + +function createTimelineEdgeReviewProject(): Project { + const project = createSampleProject('lumora://tml-563-timeline-edges', 'TML-563 timeline edge cases'); + const [short, long, late] = project.shots; + return { + ...project, + shots: [ + { ...late!, id: 'review-late', name: 'Late shot', startTime: 2, endTime: 3 }, + { ...short!, id: 'review-short', name: 'Short overlap', startTime: 0, endTime: 1 }, + { ...long!, id: 'review-long', name: 'Long overlap', startTime: 0, endTime: 2 }, + ], + }; +} + +const INITIAL_REVIEW_PROJECT = REVIEW_FIXTURE === 'tml-563-timeline-edges' + ? createTimelineEdgeReviewProject() + : REVIEW_FIXTURE?.startsWith('tml-563-round3') + ? createRound3ReviewProject(REVIEW_FIXTURE === 'tml-563-round3-dense') + : undefined; export default function App() { const [mounted, setMounted] = useState(true); @@ -179,11 +226,88 @@ export default function App() { // 双击/连点不再并发进入卸载流程;close() 本身是 single-flight(重复调用 // 共享同一 in-flight 裁决),此处是 UI 层对同一问题的第一道防线 const [closing, setClosing] = useState(false); + const [logOpen, setLogOpen] = useState(false); const [log, setLog] = useState([]); const handleRef = useRef(null); + const logToggleRef = useRef(null); + const logDialogRef = useRef(null); const appendLog = (line: string) => setLog((lines) => [...lines.slice(-49), line]); + const closeLog = useCallback(() => { + setLogOpen(false); + requestAnimationFrame(() => logToggleRef.current?.focus()); + }, []); + + useEffect(() => { + if (!logOpen) return; + const focusDialog = () => { + const dialog = logDialogRef.current; + if (!dialog || dialog.contains(document.activeElement)) return; + dialog.querySelector('[data-testid="host-log-close"]')?.focus(); + }; + const frame = requestAnimationFrame(focusDialog); + const handleDocumentKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeLog(); + } else if (event.key === 'Tab' && !logDialogRef.current?.contains(event.target as Node)) { + event.preventDefault(); + focusDialog(); + } + }; + const handleDocumentFocusIn = (event: FocusEvent) => { + if (!logDialogRef.current?.contains(event.target as Node)) focusDialog(); + }; + const handleDocumentPointerDown = (event: PointerEvent) => { + if (logDialogRef.current?.contains(event.target as Node)) return; + event.preventDefault(); + event.stopPropagation(); + focusDialog(); + }; + const handleDocumentClick = (event: MouseEvent) => { + if (logDialogRef.current?.contains(event.target as Node)) return; + event.preventDefault(); + event.stopPropagation(); + focusDialog(); + }; + document.addEventListener('keydown', handleDocumentKeyDown, true); + document.addEventListener('focusin', handleDocumentFocusIn, true); + document.addEventListener('pointerdown', handleDocumentPointerDown, true); + document.addEventListener('click', handleDocumentClick, true); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('keydown', handleDocumentKeyDown, true); + document.removeEventListener('focusin', handleDocumentFocusIn, true); + document.removeEventListener('pointerdown', handleDocumentPointerDown, true); + document.removeEventListener('click', handleDocumentClick, true); + }; + }, [closeLog, logOpen]); + + const handleLogKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeLog(); + return; + } + if (event.key !== 'Tab') return; + const focusable = Array.from( + event.currentTarget.querySelectorAll('button:not(:disabled), [href], input:not(:disabled), [tabindex="0"]'), + ); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if ((event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } + }; + useEffect(() => { if (!mounted) return; const runtime = handleRef.current?.runtime; @@ -230,63 +354,107 @@ export default function App() { return (
-
-

Lumora 嵌入宿主示例

-
- - +
+
+

Lumora 嵌入宿主示例

+
+ + + +
+
+
+
+ {mounted ? ( + + ) : ( +
+ Studio 已卸载 —— WebGL 场景、插件贡献项与事件订阅均已释放 +
+ )} +
-
-
- {mounted ? ( - - ) : ( -
- Studio 已卸载 —— WebGL 场景、插件贡献项与事件订阅均已释放 + {DUAL_STUDIO_FIXTURE && ( +
+
)} -
+
); } diff --git a/examples/embedded-host/src/app.css b/examples/embedded-host/src/app.css index 5adaac7..8b60abf 100644 --- a/examples/embedded-host/src/app.css +++ b/examples/embedded-host/src/app.css @@ -20,12 +20,19 @@ body { } .host { - display: flex; - flex-direction: column; - height: 100vh; + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + grid-template-rows: auto minmax(0, 1fr); + height: 100dvh; +} + +.host__inertable-root { + display: contents; } .host__bar { + grid-column: 1 / -1; + grid-row: 1; display: flex; align-items: center; justify-content: space-between; @@ -50,19 +57,39 @@ body { cursor: pointer; } +.host__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.host__log-toggle { + display: none; +} + .host__actions button:hover { background: #2b3140; } .host__layout { + grid-column: 1; + grid-row: 2; display: flex; flex: 1; min-height: 0; } +.host__studio-region { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; +} + .host__studio { flex: 1; min-width: 0; + min-height: 0; } .host__placeholder { @@ -75,6 +102,8 @@ body { } .host__log { + grid-column: 2; + grid-row: 2; width: 340px; flex-shrink: 0; overflow: auto; @@ -113,14 +142,149 @@ body { /* 窄屏:日志面板移到 Studio 下方,Studio 获得完整宽度(Studio 自身 有 ≤900px 的三栏纵向堆叠规则,容器过窄时其内部布局无法成立) */ @media (max-width: 900px) { + .host { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; + } + + .host__bar { + min-height: 44px; + padding: 5px 8px; + } + + .host__bar h1 { + font-size: 13px; + white-space: nowrap; + } + + .host__actions { + min-width: 0; + } + + .host__actions button { + min-width: 0; + padding: 5px 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .host__log-toggle { + display: inline-flex; + } + .host__layout { flex-direction: column; } .host__log { + grid-column: 1; + grid-row: 3; + display: none; width: 100%; - height: 150px; + height: min(180px, 32dvh); border-left: none; border-top: 1px solid #2a2e3a; } + + .host__log[data-open] { + display: block; + } +} + +.host__log-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.host__log-header h2 { + margin: 0; +} + +.host__log-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 44px; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid #3b4152; + border-radius: 6px; + background: #232834; + color: #e8eaf0; + cursor: pointer; +} + +.host__log-close:hover { + background: #2b3140; +} + +.host__log-close:focus-visible { + outline: 2px solid #4dabf7; + outline-offset: 2px; +} + +.host__log-close svg { + width: 20px; + height: 20px; +} + +.host__fixture-studio { + position: fixed; + left: -10000px; + top: 0; + width: 800px; + height: 600px; + overflow: hidden; +} + +@media (max-width: 900px) and (max-height: 500px) { + .host { + position: relative; + } + + .host__log { + position: fixed; + z-index: 80; + top: 44px; + bottom: 0; + right: 0; + width: min(260px, 40vw); + height: auto; + padding-block: 6px; + border-top: none; + border-left: 1px solid #2a2e3a; + box-shadow: -12px 0 24px rgba(0, 0, 0, 0.35); + } + + .host__log h2 { + margin-bottom: 4px; + } +} + +@media (max-width: 600px) { + .host__bar h1 { + display: none; + } + + .host__actions { + width: 100%; + } + + .host__actions button { + flex: 1; + } + + .host__log { + height: 64px; + padding-block: 8px; + } + + .host__log h2 { + margin-bottom: 6px; + } } diff --git a/examples/mock-plugin/src/index.tsx b/examples/mock-plugin/src/index.tsx index 834d264..1312ce0 100644 --- a/examples/mock-plugin/src/index.tsx +++ b/examples/mock-plugin/src/index.tsx @@ -11,6 +11,7 @@ import type { StoryboardDraftPayload, StoryboardGenerateRequest, } from '@lumora/plugin-sdk'; +import './style.css'; import { MockAiChatPanel } from './panels/MockAiChatPanel'; import { MockConsolePanel } from './panels/MockConsolePanel'; diff --git a/examples/mock-plugin/src/style.css b/examples/mock-plugin/src/style.css new file mode 100644 index 0000000..a87bb2a --- /dev/null +++ b/examples/mock-plugin/src/style.css @@ -0,0 +1,108 @@ +.lumora-mock-console, +.lumora-mock-ai { + display: flex; + min-height: 100%; + flex-direction: column; + gap: 12px; + padding: 16px; + color: var(--lumora-text); + background: var(--lumora-surface); +} + +.lumora-mock-console h4, +.lumora-mock-ai h4, +.lumora-mock-console p, +.lumora-mock-ai p { + margin: 0; +} + +.lumora-mock-console h4, +.lumora-mock-ai h4 { + font-size: 14px; + font-weight: 650; +} + +.lumora-mock-console > p, +.lumora-mock-result { + color: var(--lumora-text-dim); +} + +.lumora-mock-actions, +.lumora-mock-chat { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.lumora-mock-actions button, +.lumora-mock-chat button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid var(--lumora-border); + border-radius: 6px; + color: var(--lumora-text); + background: var(--lumora-surface-2); + font: inherit; + cursor: pointer; +} + +.lumora-mock-actions button:hover:not(:disabled), +.lumora-mock-chat button:hover:not(:disabled) { + border-color: var(--lumora-accent); +} + +.lumora-mock-chat input { + min-width: 0; + min-height: 36px; + flex: 1 1 220px; + padding: 6px 10px; + border: 1px solid var(--lumora-border); + border-radius: 6px; + color: var(--lumora-text); + background: var(--lumora-bg); + font: inherit; +} + +.lumora-mock-actions button:focus-visible, +.lumora-mock-chat button:focus-visible, +.lumora-mock-chat input:focus-visible { + outline: 2px solid var(--lumora-accent); + outline-offset: 2px; +} + +.lumora-mock-actions button:disabled, +.lumora-mock-chat button:disabled, +.lumora-mock-chat input:disabled { + color: var(--lumora-text-disabled); + cursor: not-allowed; +} + +.lumora-mock-result { + min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--lumora-border); + border-radius: 6px; + background: var(--lumora-surface-2); + overflow-wrap: anywhere; +} + +.lumora-mock-log { + min-height: 72px; + margin: 0; + padding: 10px 12px 10px 28px; + border: 1px solid var(--lumora-border); + border-radius: 6px; + color: var(--lumora-text-dim); + background: var(--lumora-bg); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + overflow: auto; +} + +@container (max-width: 600px) { + .lumora-mock-actions button, + .lumora-mock-chat button, + .lumora-mock-chat input { + min-height: 44px; + } +} diff --git a/package-lock.json b/package-lock.json index 08962da..2775dd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4302,6 +4302,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.37.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.37.0.tgz", + "integrity": "sha512-LPsB4rD1TD6wZu1djKOf9vUnS1jTNaHbolXebXDgiTdb6jeA1agIJhJsIybCmjKmQClcOaal1o1OaiYahEftyQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", @@ -6388,6 +6397,7 @@ "@lumora/core": "0.1.0", "@react-three/drei": "^10.0.0", "@react-three/fiber": "^9.0.0", + "lucide-react": "^1.37.0", "three": "^0.170.0", "webm-muxer": "^5.1.4" }, diff --git a/packages/core/src/timeline/timeline-controller.ts b/packages/core/src/timeline/timeline-controller.ts index d2b68b9..0bf32ec 100644 --- a/packages/core/src/timeline/timeline-controller.ts +++ b/packages/core/src/timeline/timeline-controller.ts @@ -37,7 +37,7 @@ export interface TimelineControllerOptions { /** 默认缩放:24fps 每帧 ≈ 10px(240px/s) */ export const DEFAULT_TIMELINE_ZOOM = 240; export const MIN_TIMELINE_ZOOM = 30; -export const MAX_TIMELINE_ZOOM = 1200; +export const MAX_TIMELINE_ZOOM = 1600; /** 时间吸附到帧边界(fps 无效/非正时原样返回) */ export function snapTimeToFrame(time: number, fps: number): number { diff --git a/packages/studio/package.json b/packages/studio/package.json index 86a49c6..74994b0 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -27,6 +27,7 @@ "@lumora/core": "0.1.0", "@react-three/drei": "^10.0.0", "@react-three/fiber": "^9.0.0", + "lucide-react": "^1.37.0", "three": "^0.170.0", "webm-muxer": "^5.1.4" }, diff --git a/packages/studio/src/components/CommandPalette.tsx b/packages/studio/src/components/CommandPalette.tsx index 2777766..0168b63 100644 --- a/packages/studio/src/components/CommandPalette.tsx +++ b/packages/studio/src/components/CommandPalette.tsx @@ -1,14 +1,19 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; +import type { RefObject } from 'react'; import type { Command } from '@lumora/core'; import type { StudioRuntime } from '../runtime/studio-runtime'; import { useEventRefresh } from '../hooks/use-event-refresh'; +import { ModalDialog } from './ModalDialog'; interface CommandPaletteProps { runtime: StudioRuntime; onClose: () => void; + returnFocusRef?: RefObject; } -export function CommandPalette({ runtime, onClose }: CommandPaletteProps) { +export function CommandPalette({ runtime, onClose, returnFocusRef }: CommandPaletteProps) { + const titleId = useId(); + const inputId = useId(); useEventRefresh(runtime.events, ['command:changed']); const [query, setQuery] = useState(''); const inputRef = useRef(null); @@ -37,21 +42,26 @@ export function CommandPalette({ runtime, onClose }: CommandPaletteProps) { }; return ( -
-
event.stopPropagation()} - onKeyDownCapture={(event) => { - if (event.key !== 'Escape') return; + { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); - event.stopPropagation(); onClose(); - }} - > + } + }} + > +
+

命令面板

+ +
setQuery(event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter' && commands[0]) run(commands[0]); + if (event.key === 'Enter' && commands[0]) { + event.preventDefault(); + run(commands[0]); + } }} />
    @@ -78,7 +91,6 @@ export function CommandPalette({ runtime, onClose }: CommandPaletteProps) { ))}
-
-
+ ); } diff --git a/packages/studio/src/components/LumoraStudio.tsx b/packages/studio/src/components/LumoraStudio.tsx index 01eaf1c..6be1575 100644 --- a/packages/studio/src/components/LumoraStudio.tsx +++ b/packages/studio/src/components/LumoraStudio.tsx @@ -1,5 +1,4 @@ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import type { ReactNode } from 'react'; import { findObject } from '@lumora/core'; import type { PluginDescriptor, Project } from '@lumora/core'; @@ -14,6 +13,7 @@ import { PanelHost } from './panels/PanelHost'; import { Toolbar } from './Toolbar'; import { CommandPalette } from './CommandPalette'; import { PluginManager } from './PluginManager'; +import { ModalDialog } from './ModalDialog'; import { EditorViewport } from './editor/EditorViewport'; import { TimelinePanel } from './editor/TimelinePanel'; import { ObjectTree } from './editor/ObjectTree'; @@ -166,6 +166,7 @@ export const LumoraStudio = forwardRef(fu return true; }, []); const [saveState, setSaveState] = useState({ status: 'idle' }); + const overwriteTitleId = useId(); useEffect(() => { const sub = runtime.persistence.events.on('save-state', ({ state }) => setSaveState(state)); @@ -189,102 +190,6 @@ export const LumoraStudio = forwardRef(fu window.addEventListener('beforeunload', preventDataLoss); return () => window.removeEventListener('beforeunload', preventDataLoss); }, [protectBeforeUnload]); - // 覆盖确认模态(复审阻断 4):提升到壳层根级,打开时整壳 inert(工具栏/对象树/ - // 视口/时间线整体不可达),模态经 portal 挂到 body 脱离 inert 子树 - const overwriteModalRef = useRef(null); - const overwriteTriggerRef = useRef(null); - useEffect(() => { - if (!session.state.overwritePending) return; - const dialog = overwriteModalRef.current; - if (!dialog) return; - overwriteTriggerRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - const focusables = () => - Array.from( - dialog.querySelectorAll( - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', - ), - ); - focusables()[0]?.focus(); - // 捕获阶段处理先于全局冒泡处理器,stopImmediatePropagation 使其不可达。 - // 应用快捷键统一小写匹配:Ctrl+Shift+K 的 key 为大写 K,未小写化时泄漏到 - // 命令面板开关(复审阻断 4) - const isModalEditingShortcut = (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - return ( - key === 'delete' || - key === 'backspace' || - key === '1' || - key === '2' || - key === '3' || - ((event.ctrlKey || event.metaKey) && - (key === 'k' || key === 'z' || key === 'y' || key === 'd')) || - matchesShortcut(event, recordingShortcutRef.current) || - (DRIVE_KEY_CODES.has(event.code) && !event.ctrlKey && !event.metaKey && !event.altKey) - ); - }; - const onKeyDownCapture = (event: KeyboardEvent) => { - // Escape 无条件取消,先于「模态外」分支判定:焦点逃逸到对话框外后 - // Escape 不得被 outside 分支吞掉(复审阻断 4) - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - session.cancelOverwrite(); - return; - } - const inside = event.target instanceof Node && dialog.contains(event.target); - if (!inside) { - // 模态外(含 window/document 上的按键):一律拦截,不再穿透到全局处理器; - // 焦点若已逃逸到对话框外(程序性 blur 等),Tab 把它拉回首项(焦点陷阱闭环) - if (event.key === 'Tab') { - focusables()[0]?.focus(); - } - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - if (event.key === 'Tab') { - const list = focusables(); - if (list.length === 0) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - const first = list[0]!; - const last = list[list.length - 1]!; - if (!dialog.contains(document.activeElement)) { - event.preventDefault(); - event.stopImmediatePropagation(); - first.focus(); - } else if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - event.stopImmediatePropagation(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - event.stopImmediatePropagation(); - first.focus(); - } - return; - } - if (isModalEditingShortcut(event)) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - if (event.key === ' ') { - // 对话框按钮的空格激活走原生行为,只阻断冒泡到全局播放切换 - event.stopImmediatePropagation(); - return; - } - }; - window.addEventListener('keydown', onKeyDownCapture, true); - return () => { - window.removeEventListener('keydown', onKeyDownCapture, true); - const trigger = overwriteTriggerRef.current; - if (trigger && trigger.isConnected) trigger.focus(); - }; - }, [session.state.overwritePending, session]); // 分镜缩略图截图通道:EditorViewport 的 FrameCaptureBridge 挂载后可用 const captureRef = useRef<((cameraObjectId?: string | null) => string | null) | null>(null); const exportFrameRef = useRef(null); @@ -307,8 +212,17 @@ export const LumoraStudio = forwardRef(fu const [paletteOpen, setPaletteOpen] = useState(false); const [storyboardOpen, setStoryboardOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); + const [editorPanel, setEditorPanel] = useState<'scene' | 'objects' | 'properties'>('scene'); const exportButtonRef = useRef(null); + const paletteButtonRef = useRef(null); + const pluginButtonRef = useRef(null); + const paletteReturnFocusRef = useRef(null); const rootRef = useRef(null); + const rememberPaletteReturnFocus = useCallback(() => { + const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; + paletteReturnFocusRef.current = + activeElement && activeElement !== document.body ? activeElement : paletteButtonRef.current; + }, []); useEffect(() => { const closeWorkspace = () => { @@ -502,7 +416,10 @@ export const LumoraStudio = forwardRef(fu if ((event.ctrlKey || event.metaKey) && key === 'k') { event.preventDefault(); setStoryboardOpen(false); - setPaletteOpen((open) => !open); + setPaletteOpen((open) => { + if (!open) rememberPaletteReturnFocus(); + return !open; + }); return; } if (preservesNativeKeyboardSemantics(event)) return; @@ -565,7 +482,7 @@ export const LumoraStudio = forwardRef(fu window.removeEventListener('keydown', onKey); unregisterRoot(); }; - }, [runtime, exportOpen]); + }, [runtime, exportOpen, rememberPaletteReturnFocus]); return ( <> @@ -574,9 +491,6 @@ export const LumoraStudio = forwardRef(fu className={`lumora-studio${className ? ` ${className}` : ''}`} data-testid="lumora-studio" data-workspace={storyboardOpen ? 'storyboard' : exportOpen ? 'export' : undefined} - // 覆盖确认模态打开时整壳 inert:工具栏/对象树/视口/时间线整体不可达 - // (复审阻断 4:仅时间线内容 inert 时其余应用仍可交互) - inert={session.state.overwritePending || undefined} > (fu storyboardOpen={storyboardOpen} exportOpen={exportOpen} exportButtonRef={exportButtonRef} + paletteButtonRef={paletteButtonRef} + pluginButtonRef={pluginButtonRef} onToggleStoryboard={() => { setPluginManagerOpen(false); setPaletteOpen(false); @@ -606,12 +522,73 @@ export const LumoraStudio = forwardRef(fu onTogglePalette={() => { setStoryboardOpen(false); setExportOpen(false); - setPaletteOpen((open) => !open); + setPaletteOpen((open) => { + if (!open) rememberPaletteReturnFocus(); + return !open; + }); }} />
-
-
+
+ {([ + ['scene', '场景'], + ['objects', '对象'], + ['properties', '属性'], + ] as const).map(([panel, label]) => ( + + ))} +
+
+
(fu onDisablePlugin={(pluginId) => void runtime.host.disable(pluginId)} />
-
+
{scene ? ( scene(project) @@ -665,12 +647,19 @@ export const LumoraStudio = forwardRef(fu /> )}
- +
+ +
{storyboardOpen && project && ( (fu )}
- {pluginManagerOpen && setPluginManagerOpen(false)} />} - {paletteOpen && setPaletteOpen(false)} />} -
- {session.state.overwritePending && - createPortal( -
-
-

该机位已有录制轨道,覆盖现有关键帧?

-
- - -
-
-
, - document.body, + {pluginManagerOpen && ( + setPluginManagerOpen(false)} + /> )} + {paletteOpen && ( + setPaletteOpen(false)} + /> + )} +
+ {session.state.overwritePending && ( + +

该机位已有录制轨道,覆盖现有关键帧?

+
+ + +
+
+ )} ); }); diff --git a/packages/studio/src/components/ModalDialog.tsx b/packages/studio/src/components/ModalDialog.tsx new file mode 100644 index 0000000..9fcd28f --- /dev/null +++ b/packages/studio/src/components/ModalDialog.tsx @@ -0,0 +1,311 @@ +import { useLayoutEffect, useRef } from 'react'; +import type { KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from 'react'; +import { createPortal } from 'react-dom'; + +const FOCUSABLE_SELECTOR = [ + 'button:not([disabled])', + '[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +function deepActiveElement(root: Document | ShadowRoot): HTMLElement | null { + let active = root.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; +} + +function composedParent(element: HTMLElement): HTMLElement | null { + if (element.parentElement) return element.parentElement; + const root = element.getRootNode(); + return root instanceof ShadowRoot && root.host instanceof HTMLElement ? root.host : null; +} + +function composedContains(container: HTMLElement, target: HTMLElement | null): boolean { + let current = target; + while (current) { + if (container.contains(current)) return true; + current = composedParent(current); + } + return false; +} + +function canRestoreFocus(target: HTMLElement): boolean { + if (!target.isConnected || target.matches(':disabled')) return false; + let current: HTMLElement | null = target; + while (current) { + const style = current.ownerDocument.defaultView?.getComputedStyle(current); + if ( + current.hidden || + current.hasAttribute('inert') || + style?.display === 'none' || + style?.visibility === 'hidden' + ) return false; + current = composedParent(current); + } + return true; +} + +interface InertSnapshot { + hadAttribute: boolean; + propertyValue: boolean; +} + +interface ModalEntry { + dialog: HTMLDivElement; + portalRoot: HTMLDivElement; + returnFocusCandidates: HTMLElement[]; + close: () => void; + initialFocus: HTMLElement | null; +} + +class DocumentModalManager { + private readonly stack: ModalEntry[] = []; + private readonly inertSnapshots = new Map(); + private observer: MutationObserver | null = null; + + constructor(private readonly document: Document) {} + + register(entry: ModalEntry): () => void { + this.stack.push(entry); + if (this.stack.length === 1) { + this.document.addEventListener('keydown', this.onKeyDown, true); + this.document.addEventListener('focusin', this.onFocusIn, true); + this.observer = new MutationObserver(() => this.reconcileInert()); + this.observer.observe(this.document.body, { childList: true }); + } + this.reconcileInert(); + this.focusEntry(entry, false, entry.initialFocus); + return () => this.unregister(entry); + } + + private unregister(entry: ModalEntry) { + const index = this.stack.indexOf(entry); + if (index < 0) return; + const wasTop = index === this.stack.length - 1; + this.stack.splice(index, 1); + this.reconcileInert(); + + if (this.stack.length === 0) { + this.document.removeEventListener('keydown', this.onKeyDown, true); + this.document.removeEventListener('focusin', this.onFocusIn, true); + this.observer?.disconnect(); + this.observer = null; + } + if (!wasTop) return; + + const next = this.top(); + const candidate = entry.returnFocusCandidates.find((target) => + canRestoreFocus(target) && (!next || composedContains(next.dialog, target)), + ); + if (candidate) candidate.focus(); + else if (next) this.focusEntry(next); + } + + private top(): ModalEntry | undefined { + return this.stack[this.stack.length - 1]; + } + + private focusables(entry: ModalEntry): HTMLElement[] { + return Array.from(entry.dialog.querySelectorAll(FOCUSABLE_SELECTOR)).filter(canRestoreFocus); + } + + private focusEntry(entry: ModalEntry, preferLast = false, preferred: HTMLElement | null = null) { + const items = this.focusables(entry); + const target = preferred && canRestoreFocus(preferred) + ? preferred + : preferLast + ? items[items.length - 1] + : items[0]; + (target ?? entry.dialog).focus(); + } + + private readonly onFocusIn = (event: FocusEvent) => { + const top = this.top(); + if (!top || composedContains(top.dialog, deepActiveElement(this.document))) return; + event.stopImmediatePropagation(); + this.focusEntry(top); + }; + + private readonly onKeyDown = (event: KeyboardEvent) => { + const top = this.top(); + if (!top) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + top.close(); + return; + } + + const active = deepActiveElement(this.document); + const activeInside = composedContains(top.dialog, active); + const targetInside = event.composedPath().includes(top.dialog) || ( + event.target instanceof HTMLElement && composedContains(top.dialog, event.target) + ); + if (event.key !== 'Tab') { + if (!activeInside || !targetInside) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + return; + } + + const items = this.focusables(top); + if (items.length === 0) { + event.preventDefault(); + event.stopImmediatePropagation(); + top.dialog.focus(); + return; + } + const first = items[0]!; + const last = items[items.length - 1]!; + if (!activeInside || !targetInside) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.focusEntry(top, event.shiftKey); + } else if (event.shiftKey && active === first) { + event.preventDefault(); + event.stopImmediatePropagation(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + event.stopImmediatePropagation(); + first.focus(); + } + }; + + private restoreInert(element: HTMLElement) { + const snapshot = this.inertSnapshots.get(element); + if (!snapshot) return; + element.toggleAttribute('inert', snapshot.hadAttribute); + element.inert = snapshot.propertyValue; + this.inertSnapshots.delete(element); + } + + private reconcileInert() { + const top = this.top(); + if (!top) { + for (const element of Array.from(this.inertSnapshots.keys())) this.restoreInert(element); + return; + } + + const bodyChildren = Array.from(this.document.body.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement, + ); + for (const child of bodyChildren) { + if (child === top.portalRoot) { + this.restoreInert(child); + continue; + } + if (!this.inertSnapshots.has(child)) { + this.inertSnapshots.set(child, { + hadAttribute: child.hasAttribute('inert'), + propertyValue: child.inert, + }); + } + child.setAttribute('inert', ''); + child.inert = true; + } + } +} + +const modalManagers = new WeakMap(); + +function modalManagerFor(document: Document): DocumentModalManager { + let manager = modalManagers.get(document); + if (!manager) { + manager = new DocumentModalManager(document); + modalManagers.set(document, manager); + } + return manager; +} + +interface ModalDialogProps { + children: ReactNode; + onClose: () => void; + ariaLabel?: string; + ariaLabelledBy?: string; + backdropClassName?: string; + dialogClassName: string; + backdropTestId?: string; + dialogTestId?: string; + initialFocusRef?: RefObject; + returnFocusRef?: RefObject; + closeOnBackdrop?: boolean; + onDialogKeyDown?: (event: ReactKeyboardEvent) => void; +} + +/** Shared, document-scoped modal boundary for Studio overlays and embedded hosts. */ +export function ModalDialog({ + children, + onClose, + ariaLabel, + ariaLabelledBy, + backdropClassName, + dialogClassName, + backdropTestId, + dialogTestId, + initialFocusRef, + returnFocusRef, + closeOnBackdrop = true, + onDialogKeyDown, +}: ModalDialogProps) { + const backdropRef = useRef(null); + const dialogRef = useRef(null); + const closeRef = useRef(onClose); + closeRef.current = onClose; + + useLayoutEffect(() => { + const dialog = dialogRef.current; + const portalRoot = backdropRef.current; + if (!dialog || !portalRoot) return; + const activeElement = deepActiveElement(dialog.ownerDocument); + const candidates = activeElement && activeElement !== dialog.ownerDocument.body + ? [activeElement, returnFocusRef?.current] + : [returnFocusRef?.current, activeElement]; + const returnFocusCandidates = candidates.filter( + (candidate, index, list): candidate is HTMLElement => + candidate instanceof HTMLElement && list.indexOf(candidate) === index, + ); + return modalManagerFor(dialog.ownerDocument).register({ + dialog, + portalRoot, + returnFocusCandidates, + close: () => closeRef.current(), + initialFocus: initialFocusRef?.current ?? null, + }); + }, [initialFocusRef, returnFocusRef]); + + return createPortal( +
{ + if (closeOnBackdrop && event.target === event.currentTarget) closeRef.current(); + }} + > +
{ + onDialogKeyDown?.(event); + event.stopPropagation(); + }} + > + {children} +
+
, + document.body, + ); +} diff --git a/packages/studio/src/components/PluginManager.tsx b/packages/studio/src/components/PluginManager.tsx index 128881e..b768171 100644 --- a/packages/studio/src/components/PluginManager.tsx +++ b/packages/studio/src/components/PluginManager.tsx @@ -1,10 +1,15 @@ import type { PluginInfo } from '@lumora/core'; +import { useId } from 'react'; +import type { RefObject } from 'react'; import type { StudioRuntime } from '../runtime/studio-runtime'; +import { X } from 'lucide-react'; import { useEventRefresh } from '../hooks/use-event-refresh'; +import { ModalDialog } from './ModalDialog'; interface PluginManagerProps { runtime: StudioRuntime; onClose: () => void; + returnFocusRef?: RefObject; } const STATE_LABELS: Record = { @@ -18,33 +23,48 @@ const STATE_LABELS: Record = { failed: '失败', }; -export function PluginManager({ runtime, onClose }: PluginManagerProps) { +export function PluginManager({ runtime, onClose, returnFocusRef }: PluginManagerProps) { + const titleId = useId(); useEventRefresh(runtime.events, ['plugin:state-changed', 'contribution:changed']); const plugins = runtime.host.listPlugins(); return ( -
-
event.stopPropagation()} - > +
-

插件管理

+

插件管理

插件代码在独立的错误隔离中运行;失败原因与停用入口见下表。

    {plugins.length === 0 &&
  • 尚未注册任何插件
  • } - {plugins.map((plugin) => ( + {plugins.map((plugin) => { + const busy = ['registered', 'loading', 'activating', 'deactivating'].includes(plugin.state); + const disables = plugin.state === 'active' || plugin.state === 'failed' || plugin.state === 'deactivating'; + const actionLabel = plugin.state === 'deactivating' + ? '停用中…' + : plugin.state === 'loading' || plugin.state === 'activating' + ? '启用中…' + : disables + ? '禁用' + : plugin.reason + ? '重新启用' + : '启用'; + return ( // 记录键/操作一律使用稳定唯一的 instanceId;manifest id 仅展示
  • )}
    - {plugin.state === 'active' && ( - - )} - {(plugin.state === 'inactive' || plugin.state === 'disabled') && ( - - )} - {plugin.state === 'failed' && ( - - )} +
  • - ))} + ); + })}
-
-
+ ); } diff --git a/packages/studio/src/components/Toolbar.tsx b/packages/studio/src/components/Toolbar.tsx index 6e4e410..5e875e2 100644 --- a/packages/studio/src/components/Toolbar.tsx +++ b/packages/studio/src/components/Toolbar.tsx @@ -1,5 +1,18 @@ -import { useRef } from 'react'; -import type { RefObject } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from 'react'; +import { + Clapperboard, + Command, + Download, + FileInput, + FolderInput, + FolderOpen, + MoreHorizontal, + Puzzle, + Redo2, + Undo2, + X, +} from 'lucide-react'; import { createSampleProject } from '@lumora/core'; import type { Project } from '@lumora/core'; import type { StudioRuntime } from '../runtime/studio-runtime'; @@ -19,6 +32,8 @@ interface ToolbarProps { storyboardOpen: boolean; exportOpen: boolean; exportButtonRef: RefObject; + paletteButtonRef: RefObject; + pluginButtonRef: RefObject; onToggleStoryboard: () => void; onToggleExport: () => void; onTogglePlugins: () => void; @@ -33,6 +48,8 @@ export function Toolbar({ storyboardOpen, exportOpen, exportButtonRef, + paletteButtonRef, + pluginButtonRef, onToggleStoryboard, onToggleExport, onTogglePlugins, @@ -42,10 +59,36 @@ export function Toolbar({ const toolbars = runtime.host.contributions.getToolbars(); const fileInputRef = useRef(null); const dirInputRef = useRef(null); + const toolbarRef = useRef(null); + const moreButtonRef = useRef(null); + const moreMenuRef = useRef(null); + const [moreOpen, setMoreOpen] = useState(false); const editor = runtime.editor; const { canUndo, canRedo, undoLabel, redoLabel } = editorState; - // 全量文件列表交给导入流程:.gltf 与外部 .bin/纹理一起选中时组成多文件导入 + useEffect(() => { + if (!moreOpen) return; + moreMenuRef.current?.querySelector('[role="menuitem"]:not(:disabled)')?.focus(); + const closeOutside = (event: PointerEvent) => { + if (!(event.target instanceof Node) || toolbarRef.current?.contains(event.target)) return; + const targetElement = event.target instanceof Element ? event.target : event.target.parentElement; + if (targetElement?.closest('.lumora-modal-backdrop')) return; + setMoreOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + setMoreOpen(false); + moreButtonRef.current?.focus(); + }; + document.addEventListener('pointerdown', closeOutside); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('pointerdown', closeOutside); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [moreOpen]); + const handleImportFile = async (files: FileList | null) => { if (!files || files.length === 0) return; const result = await importModelFile(editor, cache, Array.from(files)); @@ -57,154 +100,222 @@ export function Toolbar({ if (fileInputRef.current) fileInputRef.current.value = ''; }; + const closeMoreThen = (action: () => void) => { + setMoreOpen(false); + action(); + }; + const menuRole = moreOpen ? 'menuitem' : undefined; + const handleMoreMenuKeyDown = (event: ReactKeyboardEvent) => { + if (!moreOpen || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const items = Array.from( + moreMenuRef.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? [], + ); + if (items.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + const current = items.indexOf(document.activeElement as HTMLButtonElement); + let next = 0; + if (event.key === 'End') next = items.length - 1; + else if (event.key === 'ArrowUp') next = current <= 0 ? items.length - 1 : current - 1; + else if (event.key === 'ArrowDown') next = current < 0 || current === items.length - 1 ? 0 : current + 1; + items[next]?.focus(); + }; + return ( -
- Lumora Studio +
+ Lumora
- - - - - - void handleImportFile(e.target.files)} - /> - )} - accept=".glb,.gltf,.bin,model/gltf-binary,model/gltf+json,application/octet-stream,image/png,image/jpeg,image/webp,image/gif" - style={{ display: 'none' }} - data-testid="toolbar-model-dir-input" - onChange={(e) => void handleImportFile(e.target.files)} - /> - {toolbars.map((item) => { - const command = runtime.host.commands.get(item.commandId); - return ( - - ); - })} - + + + + + + void handleImportFile(event.target.files)} + /> + )} + accept=".glb,.gltf,.bin,model/gltf-binary,model/gltf+json,application/octet-stream,image/png,image/jpeg,image/webp,image/gif" + hidden + data-testid="toolbar-model-dir-input" + onChange={(event) => void handleImportFile(event.target.files)} + /> + {toolbars.map((item) => { + const command = runtime.host.commands.get(item.commandId); + return ( + + ); + })} + + +
v{runtime.host.hostVersion} diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index 5c527ea..5ec5b35 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -1118,6 +1118,7 @@ function ViewportToolbar({ { const result = editor.setActiveScene(e.target.value); @@ -263,7 +276,8 @@ export function ObjectTree({ editor, project, selection, cache }: ObjectTreeProp setAddMenuOpen((open) => !open); }} > - + 添加 +