diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index d3e7fc495ca..8a2270113a9 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -610,7 +610,7 @@ const routes: Routes = [ data: { hideCardWrapper: true, hideIcon: true, - maxWidth: "3xl", + maxWidth: "4xl", } satisfies AnonLayoutWrapperData, children: [ { diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html index 3764f7d828f..cd091e11940 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html @@ -46,10 +46,10 @@ The first video is relatively positioned to force the layout and spacing of the videos. -->
-
+
-
+ +
-
+
{ HTMLMediaElement.prototype.play = play; beforeEach(async () => { - window.matchMedia = jest.fn().mockReturnValue(false); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation(() => ({ + matches: false, + addListener() {}, + removeListener() {}, + })), + }); play.mockClear(); await TestBed.configureTestingModule({ @@ -126,45 +133,34 @@ describe("AddExtensionVideosComponent", () => { thirdVideo = component["videoElements"].get(2)!.nativeElement; }); - it("starts the video sequence when all videos are loaded", fakeAsync(() => { - tick(); - + it("starts the video sequence when all videos are loaded", () => { expect(firstVideo.play).toHaveBeenCalled(); - })); - - it("plays videos in sequence", fakeAsync(() => { - tick(); // let first video play + }); + it("plays videos in sequence", () => { play.mockClear(); firstVideo.onended!(new Event("ended")); // trigger next video - tick(); - expect(secondVideo.play).toHaveBeenCalledTimes(1); play.mockClear(); secondVideo.onended!(new Event("ended")); // trigger next video - tick(); - expect(thirdVideo.play).toHaveBeenCalledTimes(1); - })); + }); - it("doesn't play videos again when the user prefers no motion", fakeAsync(() => { + it("doesn't play videos again when the user prefers no motion", () => { component["prefersReducedMotion"] = true; - tick(); firstVideo.onended!(new Event("ended")); - tick(); + secondVideo.onended!(new Event("ended")); - tick(); play.mockClear(); thirdVideo.onended!(new Event("ended")); // trigger first video again - tick(); expect(play).toHaveBeenCalledTimes(0); - })); + }); }); }); diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts index 2420414fc88..d053e05c36b 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts @@ -17,6 +17,11 @@ export class AddExtensionVideosComponent { private document = inject(DOCUMENT); + /** CSS variable name tied to the video overlay */ + private cssOverlayVariable = "--overlay-opacity"; + /** CSS variable name tied to the video border */ + private cssBorderVariable = "--border-opacity"; + /** Current viewport size */ protected variant: "mobile" | "desktop" = "desktop"; @@ -26,6 +31,15 @@ export class AddExtensionVideosComponent { /** True when the user prefers reduced motion */ protected prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + /** CSS classes for the video container, pulled into the class only for readability. */ + protected videoContainerClass = [ + "tw-absolute tw-left-0 tw-top-0 tw-w-[15rem] tw-opacity-0 md:tw-opacity-100 md:tw-relative lg:tw-w-[17rem] tw-max-w-full tw-aspect-[0.807]", + `[${this.cssOverlayVariable}:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`, + `[${this.cssBorderVariable}:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`, + "after:tw-transition-opacity after:tw-duration-400 after:tw-ease-linear", + "before:tw-transition-opacity before:tw-duration-400 before:tw-ease-linear", + ].join(" "); + /** Returns true when all videos are loaded */ get allVideosLoaded(): boolean { return this.numberOfLoadedVideos >= 3; @@ -97,12 +111,14 @@ export class AddExtensionVideosComponent { const video = this.videoElements.toArray()[index].nativeElement; video.onended = () => { void this.startVideoSequence(index + 1); + void this.addPausedStyles(video); }; this.mobileTransitionIn(index); - // Set muted via JavaScript, browsers are respecting autoplay consistently over just the HTML attribute + // Browsers are not respecting autoplay consistently with just the HTML attribute, set via JavaScript as well. video.muted = true; + this.addPlayingStyles(video); await video.play(); } @@ -143,4 +159,36 @@ export class AddExtensionVideosComponent { element.style.transition = transition ? "opacity 0.5s linear" : ""; element.style.opacity = "1"; } + + /** + * Add styles to the video that is moving to the paused/completed state. + * Fade in the overlay and fade out the border. + */ + private addPausedStyles(video: HTMLVideoElement): void { + const parentElement = video.parentElement; + if (!parentElement) { + return; + } + + // The border opacity transitions from 1 to 0 based on the percent complete. + parentElement.style.setProperty(this.cssBorderVariable, "0"); + // The opacity transitions from 0 to 0.7 based on the percent complete. + parentElement.style.setProperty(this.cssOverlayVariable, "0.7"); + } + + /** + * Add styles to the video that is moving to the playing state. + * Fade out the overlay and fade in the border. + */ + private addPlayingStyles(video: HTMLVideoElement): void { + const parentElement = video.parentElement; + if (!parentElement) { + return; + } + + // The border opacity transitions from 0 to 1 based on the percent complete. + parentElement.style.setProperty(this.cssBorderVariable, "1"); + // The opacity transitions from 0.7 to 0 based on the percent complete. + parentElement.style.setProperty(this.cssOverlayVariable, "0"); + } } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index bb749c2d0b1..355f3aef6eb 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -23,7 +23,7 @@ import { AnonLayoutBitwardenShield } from "../icon/logos"; import { SharedModule } from "../shared"; import { TypographyModule } from "../typography"; -export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl"; +export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; @Component({ selector: "auth-anon-layout", @@ -74,6 +74,8 @@ export class AnonLayoutComponent implements OnInit, OnChanges { return "tw-max-w-2xl"; case "3xl": return "tw-max-w-3xl"; + case "4xl": + return "tw-max-w-4xl"; } }