diff --git a/eslint.config.mjs b/eslint.config.mjs index 30db5aa6ae9..cc183a4e115 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,4 +37,14 @@ export default [ files: ['kolibri/**/static/**'], rules: CJS_RULES, }, + + // SVGator-generated animation files embed minified ES2022 player code (class + // fields, ??= operator) that can't be rewritten. Overriding the parser version + // for these files only + { + files: ['**/animations/**/*.vue'], + languageOptions: { + ecmaVersion: 2022, + }, + }, ]; diff --git a/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/PicturePasswordGrid.vue b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/PicturePasswordGrid.vue index cc2a70e91e4..d1483fdbd18 100644 --- a/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/PicturePasswordGrid.vue +++ b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/PicturePasswordGrid.vue @@ -56,34 +56,40 @@ - - + + @@ -100,6 +106,7 @@ import { picturePasswordStrings } from 'kolibri-common/strings/picturePasswords'; import useKResponsiveElement from 'kolibri-design-system/lib/composables/useKResponsiveElement'; import PicturePasswordOption from './PicturePasswordOption'; + import SubmitBurstAnimation from './animations/SubmitBurstAnimation'; // Pre-compute once at module scope — PICTURE_PASSWORD_SET is static JSON so // there is no benefit to re-deriving this array on every component mount. @@ -111,7 +118,7 @@ export default { name: 'PicturePasswordGrid', - components: { PicturePasswordOption }, + components: { PicturePasswordOption, SubmitBurstAnimation }, setup(props, { emit }) { const $themeTokens = themeTokens(); @@ -286,6 +293,7 @@ ); const arrowBouncing = ref(false); + const burstVisible = ref(false); /** * @public @@ -294,26 +302,43 @@ */ const playSuccessAnimation = () => { const STAGGER = 150; - const DURATION = 380; + const ICON_BOUNCE_DURATION = 380; + const BURST_DURATION = 1100; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const stagger = reduce ? 0 : STAGGER; - const dur = reduce ? 0 : DURATION; + const iconDuration = reduce ? 0 : ICON_BOUNCE_DURATION; + const burstDuration = reduce ? 0 : BURST_DURATION; + const iconCount = sequence.value.length; + + if (!iconCount) { + return Promise.resolve(); + } return new Promise(resolve => { - for (let i = 0; i < sequence.value.length; i++) { + for (let i = 0; i < iconCount; i++) { const id = sequence.value[i]; - const isLast = i === sequence.value.length - 1; + const isLast = i === iconCount - 1; window.setTimeout(() => { bouncingId.value = id; - if (isLast) arrowBouncing.value = true; + if (isLast) { + arrowBouncing.value = true; + if (!reduce) { + burstVisible.value = true; + window.setTimeout(() => { + burstVisible.value = false; + }, burstDuration); + } + } window.setTimeout(() => { if (bouncingId.value === id) bouncingId.value = null; if (isLast) arrowBouncing.value = false; - }, dur); + }, iconDuration); }, i * stagger); } - window.setTimeout(() => resolve(), (sequence.value.length - 1) * stagger + dur); + const lastStart = (iconCount - 1) * stagger; + const animationDuration = Math.max(lastStart + iconDuration, lastStart + burstDuration); + window.setTimeout(() => resolve(), animationDuration); }); }; @@ -356,6 +381,7 @@ submitPulsing, bouncingId, arrowBouncing, + burstVisible, handleSelect, handleDisabledSelect, handleSubmit, @@ -463,16 +489,30 @@ border-radius: 16px; } - .submit-button { + .submit-container { + position: relative; display: flex; align-items: center; justify-content: center; + } + + .submit-button { + width: 100%; + height: 100%; padding: 0; border: 0; border-radius: 8px; transition: $core-time; } + .submit-burst { + position: absolute; + top: 50%; + left: 50%; + z-index: 100; + transform: translate(-50%, -50%); + } + .submit-icon { top: 0; width: 40px; diff --git a/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/__tests__/PicturePasswordGrid.spec.js b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/__tests__/PicturePasswordGrid.spec.js index 22e8d1f2315..747f15e7fbe 100644 --- a/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/__tests__/PicturePasswordGrid.spec.js +++ b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/__tests__/PicturePasswordGrid.spec.js @@ -301,7 +301,7 @@ describe('PicturePasswordGrid', () => { iconStyle: 'colorful', showIconText: true, }, - stubs: ['PicturePasswordOption', 'KIcon'], + stubs: ['PicturePasswordOption', 'KIcon', 'SubmitBurstAnimation'], }); } @@ -355,12 +355,20 @@ describe('PicturePasswordGrid', () => { await nextTick(); expect(wrapper.vm.bouncingId).toBe(3); expect(wrapper.vm.arrowBouncing).toBe(true); + expect(wrapper.vm.burstVisible).toBe(true); - // After the final 380ms, bouncing clears and Promise resolves + // After 380ms, icon/arrow bounce is done but burst is still running jest.advanceTimersByTime(380); await nextTick(); expect(wrapper.vm.bouncingId).toBeNull(); expect(wrapper.vm.arrowBouncing).toBe(false); + expect(wrapper.vm.burstVisible).toBe(true); + expect(resolved).toBe(false); + + // Burst duration gates completion (1100ms from last icon start) + jest.advanceTimersByTime(720); + await nextTick(); + expect(wrapper.vm.burstVisible).toBe(false); expect(resolved).toBe(true); }); @@ -389,6 +397,7 @@ describe('PicturePasswordGrid', () => { await nextTick(); expect(resolved).toBe(true); expect(wrapper.vm.arrowBouncing).toBe(false); + expect(wrapper.vm.burstVisible).toBe(false); }); }); diff --git a/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/__tests__/SubmitBurstAnimation.spec.js b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/__tests__/SubmitBurstAnimation.spec.js new file mode 100644 index 00000000000..b6a32fbd1c2 --- /dev/null +++ b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/__tests__/SubmitBurstAnimation.spec.js @@ -0,0 +1,46 @@ +import { mount } from '@vue/test-utils'; +import SubmitBurstAnimation from '../animations/SubmitBurstAnimation.vue'; + +describe('SubmitBurstAnimation', () => { + let getElementByIdSpy; + + afterEach(() => { + if (getElementByIdSpy) { + getElementByIdSpy.mockRestore(); + getElementByIdSpy = null; + } + }); + + it('renders the burst SVG', () => { + const mockPlayer = { + ready: jest.fn(callback => callback({ play: jest.fn() })), + destruct: jest.fn(), + }; + getElementByIdSpy = jest.spyOn(document, 'getElementById').mockReturnValue({ + svgatorPlayer: mockPlayer, + }); + + const wrapper = mount(SubmitBurstAnimation); + + expect(wrapper.find('svg').exists()).toBe(true); + + wrapper.destroy(); + }); + + it('calls player.destruct on unmount when available', () => { + const destruct = jest.fn(); + const mockPlayer = { + ready: jest.fn(callback => callback({ play: jest.fn() })), + destruct, + }; + getElementByIdSpy = jest.spyOn(document, 'getElementById').mockReturnValue({ + svgatorPlayer: mockPlayer, + }); + + const wrapper = mount(SubmitBurstAnimation); + + wrapper.destroy(); + + expect(destruct).toHaveBeenCalled(); + }); +}); diff --git a/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/animations/SubmitBurstAnimation.vue b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/animations/SubmitBurstAnimation.vue new file mode 100644 index 00000000000..60cfe8d82b2 --- /dev/null +++ b/kolibri/plugins/user_auth/frontend/views/SignInPage/PictureSignIn/animations/SubmitBurstAnimation.vue @@ -0,0 +1,374 @@ + + + + +