);
}
diff --git a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss
index dd3ffbb9..9168c492 100644
--- a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss
+++ b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss
@@ -9,6 +9,9 @@
// whole player growing past a fixed-height host.
height: 100%;
min-height: 0;
+ // Positioning context for .navBtn in compact mode (see below) — harmless
+ // otherwise since nothing else in here is positioned.
+ position: relative;
}
// Shared centered column so the nav bar and question card line up 1:1.
@@ -32,10 +35,31 @@
@include m.mobile {
padding: v.$space-5 v.$space-4;
}
+
+ @include m.short {
+ padding: v.$space-3 v.$space-4;
+
+ // On a short viewport most questions don't need the full height, so
+ // stretching the card to fill it just inflates the card's own blank
+ // interior. Let the card size to its content instead — any leftover
+ // space shows as the shell's background, not dead space inside a card.
+ > * {
+ flex: 0 1 auto;
+ }
+ }
+
+ // Compact (mobile-app) layout floats Prev/Next beside the card instead of in
+ // a top bar (see .navBtn below) — widen the inline gutter so they land
+ // beside the card, not over it.
+ @include m.compact {
+ padding-inline: 2.75rem;
+ }
}
// Question navigation, now at the TOP of the player (was a bottom footer).
// Same max-width + gutters as .content so its edges align with the card.
+// Desktop/tablet/portal/editor: a normal top bar. Mobile-app only (m.compact,
+// below) — visually floats Prev/Next to the sides instead.
.navBar {
display: flex;
align-items: center;
@@ -49,6 +73,10 @@
@include m.mobile {
padding: v.$space-3 v.$space-4;
}
+
+ @include m.short {
+ padding: v.$space-2 v.$space-4;
+ }
}
.counter {
@@ -58,6 +86,12 @@
color: v.$g600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
+
+ // Mobile-app only: replaced by QuestionCard's progress badge next to the
+ // question-type tag (see QuestionCard.module.scss .progress).
+ @include m.compact {
+ display: none;
+ }
}
.navBtn {
@@ -87,12 +121,51 @@
min-width: 2.75rem;
min-height: 2.75rem;
}
+
+ // Mobile-app only: float beside the card, vertically centered on the
+ // section's full height (`.sectionPlayer`, not `.navBar`), icon-only circle.
+ // Desktop/tablet/portal/editor are untouched — this rule only fires under
+ // m.compact.
+ @include m.compact {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 5;
+ min-width: 0;
+ min-height: 0;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border-radius: v.$r-pill;
+
+ // The base hover lift (translateY(-1px)) would fight the centering
+ // transform above — keep the shadow/color feedback, drop the lift.
+ &:hover:not(:disabled) {
+ transform: translateY(-50%);
+ }
+ }
+}
+
+.navPrev {
+ @include m.compact {
+ left: v.$space-1;
+ }
+}
+
+.navNext {
+ @include m.compact {
+ right: v.$space-1;
+ }
}
.navLabel {
@include m.mobile {
display: none;
}
+
+ @include m.compact {
+ display: none;
+ }
}
.submit {
diff --git a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx
index 8a8f7885..f756260b 100644
--- a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx
+++ b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx
@@ -201,7 +201,7 @@ export function SectionPlayer({ section, onSectionEnd, isLastSection = true }: S
{t(language, 'PREVIOUS')}
+ {/* Desktop/tablet only (hidden in compact mode via QuestionCard's
+ progress badge) — kept here so the counter stays centered. */}
{t(language, 'QUESTION')} {currentSlide + 1} {t(language, 'OF')} {questions.length}
@@ -222,7 +224,7 @@ export function SectionPlayer({ section, onSectionEnd, isLastSection = true }: S
// non-interactive (aria-hidden, not focusable, disabled).
-
+ {
expect(screen.queryByText(/progress is saved/i)).not.toBeInTheDocument();
});
- it('omits the minutes tile when there is no time limit', () => {
- render();
- expect(screen.queryByText(/minutes/i)).not.toBeInTheDocument();
+ it('shows "No Limit" for the minutes tile when there is no time limit', () => {
+ render();
+ expect(screen.getByText(/minutes/i)).toBeInTheDocument();
+ expect(screen.getByText('No Limit')).toBeInTheDocument();
+ });
+
+ it('shows "No Limit" attempts when the backend does not send a cap', () => {
+ render();
+ expect(screen.getAllByText('No Limit').length).toBeGreaterThanOrEqual(1);
});
it('emits onStart when the CTA is clicked', () => {
diff --git a/projects/sunbird-quml-player-react/src/components/StartPage/StartPage.tsx b/projects/sunbird-quml-player-react/src/components/StartPage/StartPage.tsx
index 8c93808a..77caac3d 100644
--- a/projects/sunbird-quml-player-react/src/components/StartPage/StartPage.tsx
+++ b/projects/sunbird-quml-player-react/src/components/StartPage/StartPage.tsx
@@ -1,3 +1,4 @@
+import { useRef, useState } from 'react';
import { t, readI18n } from '../../i18n/translations';
import {
ClipboardIcon,
@@ -6,17 +7,25 @@ import {
AttemptsIcon,
ChevronRightIcon,
ShieldIcon,
+ PreviousIcon,
} from '../icons';
import type { Section } from '../../types';
+import { useIsCompactViewport } from './useIsCompactViewport';
import styles from './StartPage.module.scss';
/**
* StartPage — Assessment Overview / details screen (spec §6.1 + §6.2).
*
- * Pure presentational: a centered column with the assessment identity, a stats
- * card, an "assessment sections" grid, and a Start CTA. All data is derived by
- * MainPlayer from Context and passed as props; this component owns no runtime
- * state and never mutates Context.
+ * A centered column with the assessment identity, a stats card, an
+ * "assessment sections" grid, and a Start CTA. All data is derived by
+ * MainPlayer from Context and passed as props; this component never mutates
+ * Context.
+ *
+ * Desktop/tablet/portal/editor: everything renders on one scrollable page
+ * (unchanged). Mobile-app only (`useIsCompactViewport`): paged into two
+ * screens — stats first, then sections + the Start/Resume CTA — so neither
+ * screen needs to scroll on a short/narrow viewport. This is the one bit of
+ * local UI state in this component; it never reaches Context or the parent.
*/
/** Letter-badge palette for the first sections (later sections render without a badge). */
@@ -27,9 +36,14 @@ export interface StartPageProps {
sections: Section[];
totalQuestions: number;
totalSections: number;
- /** Seconds; omit/0 hides the minutes value. */
+ /** Seconds; omit/0 shows "No Limit" instead of a duration. */
timeLimit?: number;
- attemptsLeft?: number;
+ /** Omit/null → "No Limit" (the backend didn't send a cap), distinct from an explicit low number. */
+ attemptsLeft?: number | null;
+ /** True once the assessment has already been entered this attempt (e.g. via
+ * the header brand button) — the clock keeps running behind Overview, so the
+ * CTA and footer note should read "Resume", not "Start". */
+ hasStarted?: boolean;
onStart: () => void;
/** Optional jump-to-section from a section card (defaults to Start). */
onSectionSelect?: (index: number) => void;
@@ -49,95 +63,144 @@ export function StartPage({
totalQuestions,
totalSections,
timeLimit = 0,
- attemptsLeft = 3,
+ attemptsLeft = null,
+ hasStarted = false,
onStart,
onSectionSelect,
language = 'en',
}: StartPageProps) {
+ const containerRef = useRef(null);
+ const isCompact = useIsCompactViewport(containerRef);
+ const [step, setStep] = useState<'info' | 'sections'>('info');
+ // Non-compact always shows both; compact gates on `step`.
+ const showInfo = !isCompact || step === 'info';
+ const showSections = !isCompact || step === 'sections';
+
const questionLabel = (n: number) =>
`${n} ${n === 1 ? t(language, 'QUESTION').toLowerCase() : t(language, 'QUESTIONS').toLowerCase()}`;
+ // Not sent by the backend → "No Limit" rather than hiding the tile or
+ // showing a fabricated number.
+ const attemptsDisplay = attemptsLeft == null ? t(language, 'NO_LIMIT') : String(attemptsLeft);
+
const stats = [
{ key: 'q', icon: , label: t(language, 'QUESTIONS'), value: String(totalQuestions) },
- // No time limit → omit the tile entirely (nothing meaningful to show).
- ...(timeLimit > 0
- ? [{ key: 'm', icon: , label: t(language, 'MINUTES_LABEL'), value: formatMinutes(timeLimit) }]
- : []),
+ {
+ key: 'm',
+ icon: ,
+ label: t(language, 'MINUTES_LABEL'),
+ value: timeLimit > 0 ? formatMinutes(timeLimit) : t(language, 'NO_LIMIT'),
+ },
{ key: 's', icon: , label: t(language, 'SECTIONS'), value: String(totalSections) },
- { key: 'a', icon: , label: t(language, 'ATTEMPTS_LEFT'), value: String(attemptsLeft) },
+ { key: 'a', icon: , label: t(language, 'ATTEMPTS_LEFT'), value: attemptsDisplay },
];
return (
-
+
-
-
-
{title}
-
-
-
-
-
-
-
-
-
- {stats.map(({ key, icon, label, value }) => (
-
- {icon}
-
-
{label}
-
{value}
+ {showInfo && (
+ // Mobile-app only: this whole block is a flex column filling the
+ // screen — .statsCard is flex:1, so the card itself fills the
+ // remaining height instead of leaving blank page below it.
+
+ {showSections && (
+ // Mobile-app only: also a flex column, but the SECTION GRID is the
+ // flex:1/scrollable piece (not the whole screen) — heading/note stay
+ // pinned at top and the Start/Resume CTA + footer note stay pinned
+ // at the bottom, so the CTA is always reachable without scrolling
+ // even with many sections; only the grid between them scrolls.
+
+ )}
+
+ {/* Mobile-app only — a small floating "Next" over the info screen's
+ bottom-right corner (screen 1 → screen 2, see `step` above). */}
+ {isCompact && showInfo && (
+ setStep('sections')}>
+ {t(language, 'NEXT')} →
+
+ )}
);
}
diff --git a/projects/sunbird-quml-player-react/src/components/StartPage/useIsCompactViewport.ts b/projects/sunbird-quml-player-react/src/components/StartPage/useIsCompactViewport.ts
new file mode 100644
index 00000000..f9496806
--- /dev/null
+++ b/projects/sunbird-quml-player-react/src/components/StartPage/useIsCompactViewport.ts
@@ -0,0 +1,48 @@
+import { useEffect, useState } from 'react';
+import type { RefObject } from 'react';
+
+// Mirrors the SCSS `m.compact` mixin (styles/mixins.scss) — narrow width OR
+// short height. Kept in sync manually: SCSS can't export tokens to JS, and
+// this is the one piece of "mobile-app-only" behavior that can't be done in
+// pure CSS (it changes what's IN the DOM — paging the overview into two
+// screens — not just how existing DOM looks).
+const MOBILE_MAX_WIDTH = 768;
+const SHORT_MAX_HEIGHT = 720;
+
+/**
+ * True when the player is effectively rendering as the mobile app — narrow
+ * width (container-based, so it also matches the editor's mobile preview) OR
+ * a short viewport (a real device in landscape). False on portal/desktop/
+ * editor-desktop-preview, where nothing here should change behavior.
+ */
+export function useIsCompactViewport(containerRef: RefObject): boolean {
+ const [isCompact, setIsCompact] = useState(false);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ // Both are unavailable under jsdom (no test env implements viewport
+ // layout anyway) — fall back to the default `false` (today's single-page
+ // layout), which is what every existing test already renders against.
+ if (!el || typeof ResizeObserver === 'undefined' || typeof window.matchMedia !== 'function') {
+ return;
+ }
+
+ const heightQuery = window.matchMedia(`(max-height: ${SHORT_MAX_HEIGHT}px)`);
+ const update = () => {
+ const width = el.getBoundingClientRect().width;
+ setIsCompact(width <= MOBILE_MAX_WIDTH || heightQuery.matches);
+ };
+
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+ heightQuery.addEventListener('change', update);
+ update();
+
+ return () => {
+ ro.disconnect();
+ heightQuery.removeEventListener('change', update);
+ };
+ }, [containerRef]);
+
+ return isCompact;
+}
diff --git a/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss b/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss
index 003bbd6e..20cbe3bc 100644
--- a/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss
+++ b/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss
@@ -91,6 +91,12 @@
font-size: v.$text-sm;
font-weight: v.$fw-medium;
cursor: grab;
+ // Touch drag: prevent the webview from consuming the finger-drag as a scroll
+ // so react-dnd's TouchBackend receives the move (else the chip won't move on
+ // mobile).
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
&:hover:not(:disabled) {
border-color: v.$brick;
diff --git a/projects/sunbird-quml-player-react/src/dev/sample-data.ts b/projects/sunbird-quml-player-react/src/dev/sample-data.ts
index 5b8761ca..4bd74c8c 100644
--- a/projects/sunbird-quml-player-react/src/dev/sample-data.ts
+++ b/projects/sunbird-quml-player-react/src/dev/sample-data.ts
@@ -10,7 +10,10 @@ import type { PlayerConfig } from '../types';
*/
export const sampleConfig: PlayerConfig = {
context: { uid: 'dev-user', sid: 'dev-session', channel: 'dev' },
- config: { language: 'en', showFeedback: true, maxAttempts: 3 },
+ config: { language: 'en', showFeedback: true },
+ // maxAttempts is host/backend data (Angular parity: playerConfig.metadata),
+ // not a player UI setting.
+ metadata: { maxAttempts: 3 },
data: {
identifier: 'do_sample_set',
name: 'Sunbird Assessment',
diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts b/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts
index c1be888b..1ee31d39 100644
--- a/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts
+++ b/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts
@@ -38,6 +38,7 @@ export const translations: Record = {
MINUTES: 'دقائق',
// Scoring
+ QUIZ_SUMMARY: 'ملخّص الاختبار',
SCORE: 'الدرجة',
TOTAL_SCORE: 'الدرجة الكلية',
CORRECT: 'صحيح',
@@ -95,9 +96,12 @@ export const translations: Record = {
ASSESSMENT_SECTIONS: 'أقسام التقييم',
SECTIONS_COVER_NOTE: 'إليك ما ستغطّيه في هذا التقييم.',
START_ASSESSMENT: 'بدء التقييم',
+ RESUME_ASSESSMENT: 'استئناف التقييم',
ATTEMPTS_LEFT: 'المحاولات المتبقية',
MINUTES_LABEL: 'دقائق',
+ NO_LIMIT: 'بلا حدّ',
TIMER_START_NOTE: 'يبدأ المؤقّت عند النقر. لديك {attempts} محاولات لهذا التقييم.',
+ TIMER_RESUME_NOTE: 'المؤقّت ما زال يعمل. لديك {attempts} محاولات لهذا التقييم.',
// Assessment shell header / section intro (Phase 6 design)
START_SECTION: 'بدء القسم',
diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-en.ts b/projects/sunbird-quml-player-react/src/i18n/translations-en.ts
index e2e94adf..7fed1c3c 100644
--- a/projects/sunbird-quml-player-react/src/i18n/translations-en.ts
+++ b/projects/sunbird-quml-player-react/src/i18n/translations-en.ts
@@ -35,6 +35,7 @@ export const translations: Record = {
MINUTES: 'minutes',
// Scoring
+ QUIZ_SUMMARY: 'Quiz Summary',
SCORE: 'Score',
TOTAL_SCORE: 'Total Score',
CORRECT: 'Correct',
@@ -92,9 +93,12 @@ export const translations: Record = {
ASSESSMENT_SECTIONS: 'Assessment sections',
SECTIONS_COVER_NOTE: "Here's what you'll be covering in this assessment.",
START_ASSESSMENT: 'Start assessment',
+ RESUME_ASSESSMENT: 'Resume assessment',
ATTEMPTS_LEFT: 'Attempts Left',
MINUTES_LABEL: 'Minutes',
+ NO_LIMIT: 'No Limit',
TIMER_START_NOTE: 'The timer starts when you click. You have {attempts} attempts for this assessment.',
+ TIMER_RESUME_NOTE: 'Your timer is still running. You have {attempts} attempts for this assessment.',
// Assessment shell header / section intro (Phase 6 design)
START_SECTION: 'Start section',
diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts b/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts
index 2f05e04e..6a2a9cbd 100644
--- a/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts
+++ b/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts
@@ -38,6 +38,7 @@ export const translations: Record = {
MINUTES: 'minutes',
// Scoring
+ QUIZ_SUMMARY: 'Résumé du quiz',
SCORE: 'Score',
TOTAL_SCORE: 'Score total',
CORRECT: 'Correct',
@@ -95,10 +96,14 @@ export const translations: Record = {
ASSESSMENT_SECTIONS: 'Sections de l’évaluation',
SECTIONS_COVER_NOTE: 'Voici ce que vous allez aborder dans cette évaluation.',
START_ASSESSMENT: 'Commencer l’évaluation',
+ RESUME_ASSESSMENT: 'Reprendre l’évaluation',
ATTEMPTS_LEFT: 'Tentatives restantes',
MINUTES_LABEL: 'Minutes',
+ NO_LIMIT: 'Sans limite',
TIMER_START_NOTE:
'Le minuteur démarre lorsque vous cliquez. Vous avez {attempts} tentatives pour cette évaluation.',
+ TIMER_RESUME_NOTE:
+ 'Votre minuteur est toujours en cours. Vous avez {attempts} tentatives pour cette évaluation.',
// Assessment shell header / section intro (Phase 6 design)
START_SECTION: 'Commencer la section',
diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts b/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts
index 0a124d2e..8126f13d 100644
--- a/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts
+++ b/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts
@@ -38,6 +38,7 @@ export const translations: Record = {
MINUTES: 'minutos',
// Scoring
+ QUIZ_SUMMARY: 'Resumo do questionário',
SCORE: 'Pontuação',
TOTAL_SCORE: 'Pontuação total',
CORRECT: 'Correto',
@@ -95,10 +96,14 @@ export const translations: Record = {
ASSESSMENT_SECTIONS: 'Seções da avaliação',
SECTIONS_COVER_NOTE: 'Veja o que você abordará nesta avaliação.',
START_ASSESSMENT: 'Iniciar avaliação',
+ RESUME_ASSESSMENT: 'Continuar avaliação',
ATTEMPTS_LEFT: 'Tentativas restantes',
MINUTES_LABEL: 'Minutos',
+ NO_LIMIT: 'Sem limite',
TIMER_START_NOTE:
'O cronômetro começa quando você clica. Você tem {attempts} tentativas para esta avaliação.',
+ TIMER_RESUME_NOTE:
+ 'Seu cronômetro continua em execução. Você tem {attempts} tentativas para esta avaliação.',
// Assessment shell header / section intro (Phase 6 design)
START_SECTION: 'Iniciar seção',
diff --git a/projects/sunbird-quml-player-react/src/styles/mixins.scss b/projects/sunbird-quml-player-react/src/styles/mixins.scss
index 03bf373b..37533e92 100644
--- a/projects/sunbird-quml-player-react/src/styles/mixins.scss
+++ b/projects/sunbird-quml-player-react/src/styles/mixins.scss
@@ -34,6 +34,37 @@
}
}
+// Respond to a short viewport (e.g. a phone in landscape), regardless of width.
+// `.appShell`'s container is `inline-size`-only (see its comment), so width
+// tiers above can't see height at all — a landscape phone can be "desktop" or
+// "tablet" width while its actual height is a fraction of a portrait phone's,
+// pushing primary CTAs (e.g. "Start assessment") below the fold. This is a real
+// `@media` height query (not `@container`), because block-size containment
+// isn't set up (doing so risks the height:100% chain — see the freeze-fix
+// history) and, on real devices, the player fills the viewport anyway so
+// viewport height == available height.
+@mixin short {
+ @media (max-height: #{v.$bp-short-height}) {
+ @content;
+ }
+}
+
+// "Are we effectively the mobile app" — narrow width OR short height. A real
+// device is one or the other depending on orientation (portrait → narrow,
+// landscape → short), while portal/desktop/editor stay comfortably outside
+// both. Use this instead of `mobile`/`short` individually for changes that
+// should be mobile-app-only and must NOT show up on desktop/portal/editor at
+// any orientation. `@container` and `@media` can't be OR'd in one rule, so
+// this just repeats @content under each condition.
+@mixin compact {
+ @include mobile {
+ @content;
+ }
+ @include short {
+ @content;
+ }
+}
+
// Accessible focus ring — white halo + primary outline (keyboard focus).
@mixin focus-ring($color: v.$brick) {
outline: none;
diff --git a/projects/sunbird-quml-player-react/src/styles/variables.scss b/projects/sunbird-quml-player-react/src/styles/variables.scss
index 0635f56d..11887845 100644
--- a/projects/sunbird-quml-player-react/src/styles/variables.scss
+++ b/projects/sunbird-quml-player-react/src/styles/variables.scss
@@ -112,3 +112,4 @@ $sidebar-w: 280px;
$header-h: 4rem;
$bp-mobile: 768px;
$bp-tablet: 1024px; // tablet/laptop boundary — ≤1023px is "tablet and below"
+$bp-short-height: 720px; // landscape-phone height boundary — see m.short