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 @@
+
+
+
+
+
+
+
+
+
+