feat(practice): add Remediation CTA for weak skill focus sessions
When a student is in remediation mode (has weak skills to strengthen), the StartPracticeModal now shows a special amber-themed CTA similar to the tutorial CTA: - 💪 "Time to build strength!" heading - Lists weak skills with pKnown percentages - "Start Focus Practice →" amber button - Shows up to 4 skills with "+N more" overflow Includes Storybook stories for: - Single weak skill - Multiple weak skills (2) - Many weak skills (6, with overflow) - Dark theme variant 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9851c01026
commit
7d8bb2f525
|
|
@ -0,0 +1,151 @@
|
|||
# Remediation CTA Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add special "fancy" treatment to the StartPracticeModal when the student is in remediation mode (has weak skills that need strengthening). This mirrors the existing tutorial CTA treatment.
|
||||
|
||||
## Current Tutorial CTA Treatment (lines 1311-1428)
|
||||
|
||||
When `sessionMode.type === 'progression' && tutorialRequired`:
|
||||
|
||||
1. **Visual Design:**
|
||||
- Green gradient background with border
|
||||
- 🌟 icon
|
||||
- "You've unlocked: [skill name]" heading
|
||||
- "Start with a quick tutorial" subtitle
|
||||
- Green gradient button: "🎓 Begin Tutorial →"
|
||||
|
||||
2. **Behavior:**
|
||||
- Replaces the regular "Let's Go!" button
|
||||
- Clicking opens the SkillTutorialLauncher
|
||||
|
||||
## Proposed Remediation CTA
|
||||
|
||||
When `sessionMode.type === 'remediation'`:
|
||||
|
||||
1. **Visual Design:**
|
||||
- Amber/orange gradient background with border (warm "focus" colors)
|
||||
- 💪 icon (strength/building)
|
||||
- "Time to build strength!" heading
|
||||
- "Focusing on [N] skills that need practice" subtitle
|
||||
- Show weak skill badges with pKnown percentages
|
||||
- Amber gradient button: "💪 Start Focus Practice →"
|
||||
|
||||
2. **Behavior:**
|
||||
- Replaces the regular "Let's Go!" button
|
||||
- Clicking goes straight to practice (no separate launcher needed)
|
||||
- The session will automatically target weak skills via sessionMode
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add remediation detection
|
||||
|
||||
```typescript
|
||||
// Derive whether to show remediation CTA
|
||||
const showRemediationCta = sessionMode.type === 'remediation' && sessionMode.weakSkills.length > 0
|
||||
```
|
||||
|
||||
### Step 2: Create RemediationCta component section
|
||||
|
||||
Add after the Tutorial CTA section (line ~1428), or restructure to have a single "special CTA" section that handles both cases.
|
||||
|
||||
```tsx
|
||||
{/* Remediation CTA - Weak skills need strengthening */}
|
||||
{showRemediationCta && !showTutorialGate && (
|
||||
<div
|
||||
data-element="remediation-cta"
|
||||
className={css({...})}
|
||||
style={{
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(234, 88, 12, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(234, 88, 12, 0.05) 100%)',
|
||||
border: `2px solid ${isDark ? 'rgba(245, 158, 11, 0.25)' : 'rgba(245, 158, 11, 0.2)'}`,
|
||||
}}
|
||||
>
|
||||
{/* Info section */}
|
||||
<div className={css({...})}>
|
||||
<span>💪</span>
|
||||
<div>
|
||||
<p>Time to build strength!</p>
|
||||
<p>Focusing on {weakSkills.length} skill{weakSkills.length > 1 ? 's' : ''} that need practice</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weak skills badges */}
|
||||
<div className={css({...})}>
|
||||
{sessionMode.weakSkills.slice(0, 4).map((skill) => (
|
||||
<span key={skill.skillId} className={css({...})}>
|
||||
{skill.displayName} ({Math.round(skill.pKnown * 100)}%)
|
||||
</span>
|
||||
))}
|
||||
{sessionMode.weakSkills.length > 4 && (
|
||||
<span>+{sessionMode.weakSkills.length - 4} more</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Integrated start button */}
|
||||
<button
|
||||
data-action="start-focus-practice"
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
}}
|
||||
>
|
||||
{isStarting ? 'Starting...' : (
|
||||
<>
|
||||
<span>💪</span>
|
||||
<span>Start Focus Practice</span>
|
||||
<span>→</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Step 3: Update start button visibility logic
|
||||
|
||||
Change from:
|
||||
```tsx
|
||||
{!showTutorialGate && (
|
||||
<button>Let's Go! →</button>
|
||||
)}
|
||||
```
|
||||
|
||||
To:
|
||||
```tsx
|
||||
{!showTutorialGate && !showRemediationCta && (
|
||||
<button>Let's Go! →</button>
|
||||
)}
|
||||
```
|
||||
|
||||
## Visual Comparison
|
||||
|
||||
| Mode | Icon | Color Theme | Heading | Button Text |
|
||||
|------|------|-------------|---------|-------------|
|
||||
| Tutorial | 🌟 | Green | "You've unlocked: [skill]" | "🎓 Begin Tutorial →" |
|
||||
| Remediation | 💪 | Amber | "Time to build strength!" | "💪 Start Focus Practice →" |
|
||||
| Normal | - | Blue | "Ready to practice?" | "Let's Go! →" |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `apps/web/src/components/practice/StartPracticeModal.tsx`
|
||||
- Add `showRemediationCta` derived state
|
||||
- Add Remediation CTA section (similar structure to Tutorial CTA)
|
||||
- Update regular start button visibility condition
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
1. Storybook stories should cover:
|
||||
- Remediation mode with 1 weak skill
|
||||
- Remediation mode with 3+ weak skills
|
||||
- Remediation mode with 5+ weak skills (overflow)
|
||||
|
||||
2. The existing `StartPracticeModal.stories.tsx` already has sessionMode mocks - add remediation variants.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Ensure proper ARIA labels on the remediation CTA
|
||||
- Color contrast should meet WCAG guidelines (amber text on amber background needs checking)
|
||||
- Screen reader should announce the focus practice intent
|
||||
|
|
@ -108,6 +108,25 @@ const mockRemediationMode: RemediationMode = {
|
|||
focusDescription: 'Strengthening: +3 and +4',
|
||||
}
|
||||
|
||||
const mockRemediationModeSingleSkill: RemediationMode = {
|
||||
type: 'remediation',
|
||||
weakSkills: [{ skillId: 'add-2', displayName: '+2', pKnown: 0.28 }],
|
||||
focusDescription: 'Strengthening: +2',
|
||||
}
|
||||
|
||||
const mockRemediationModeManySkills: RemediationMode = {
|
||||
type: 'remediation',
|
||||
weakSkills: [
|
||||
{ skillId: 'add-1', displayName: '+1', pKnown: 0.31 },
|
||||
{ skillId: 'add-2', displayName: '+2', pKnown: 0.38 },
|
||||
{ skillId: 'add-3', displayName: '+3', pKnown: 0.25 },
|
||||
{ skillId: 'add-4', displayName: '+4', pKnown: 0.42 },
|
||||
{ skillId: 'sub-1', displayName: '-1', pKnown: 0.33 },
|
||||
{ skillId: 'sub-2', displayName: '-2', pKnown: 0.29 },
|
||||
],
|
||||
focusDescription: 'Strengthening: +1, +2, +3, +4, -1, -2',
|
||||
}
|
||||
|
||||
// Default props
|
||||
const defaultProps = {
|
||||
studentId: 'test-student-1',
|
||||
|
|
@ -183,7 +202,7 @@ export const DarkTheme: Story = {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remediation mode - student has weak skills to strengthen
|
||||
* Remediation mode - student has weak skills to strengthen (2 skills)
|
||||
*/
|
||||
export const RemediationMode: Story = {
|
||||
render: () => (
|
||||
|
|
@ -198,6 +217,56 @@ export const RemediationMode: Story = {
|
|||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation mode with a single weak skill
|
||||
*/
|
||||
export const RemediationModeSingleSkill: Story = {
|
||||
render: () => (
|
||||
<StoryWrapper>
|
||||
<StartPracticeModal
|
||||
{...defaultProps}
|
||||
studentName="Jordan"
|
||||
sessionMode={mockRemediationModeSingleSkill}
|
||||
focusDescription={mockRemediationModeSingleSkill.focusDescription}
|
||||
/>
|
||||
</StoryWrapper>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation mode with many weak skills (shows overflow)
|
||||
*/
|
||||
export const RemediationModeManySkills: Story = {
|
||||
render: () => (
|
||||
<StoryWrapper>
|
||||
<StartPracticeModal
|
||||
{...defaultProps}
|
||||
studentName="Riley"
|
||||
sessionMode={mockRemediationModeManySkills}
|
||||
focusDescription={mockRemediationModeManySkills.focusDescription}
|
||||
/>
|
||||
</StoryWrapper>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation mode - dark theme
|
||||
*/
|
||||
export const RemediationModeDark: Story = {
|
||||
render: () => (
|
||||
<StoryWrapper theme="dark">
|
||||
<div data-theme="dark">
|
||||
<StartPracticeModal
|
||||
{...defaultProps}
|
||||
studentName="Alex"
|
||||
sessionMode={mockRemediationMode}
|
||||
focusDescription={mockRemediationMode.focusDescription}
|
||||
/>
|
||||
</div>
|
||||
</StoryWrapper>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Progression mode - student is ready to learn a new skill
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export function StartPracticeModal({
|
|||
// Whether to show the tutorial gate prompt
|
||||
const showTutorialGate = !!tutorialConfig && !showTutorial
|
||||
|
||||
// Whether to show the remediation CTA (weak skills need strengthening)
|
||||
const showRemediationCta =
|
||||
sessionMode.type === 'remediation' && sessionMode.weakSkills.length > 0
|
||||
|
||||
// Get skill info for tutorial from sessionMode
|
||||
const nextSkill = sessionMode.type === 'progression' ? sessionMode.nextSkill : null
|
||||
|
||||
|
|
@ -1427,6 +1431,181 @@ export function StartPracticeModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Remediation CTA - Weak skills need strengthening */}
|
||||
{showRemediationCta && !showTutorialGate && sessionMode.type === 'remediation' && (
|
||||
<div
|
||||
data-element="remediation-cta"
|
||||
className={css({
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
'@media (max-height: 700px)': {
|
||||
borderRadius: '10px',
|
||||
marginTop: 'auto',
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(234, 88, 12, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(234, 88, 12, 0.05) 100%)',
|
||||
border: `2px solid ${isDark ? 'rgba(245, 158, 11, 0.25)' : 'rgba(245, 158, 11, 0.2)'}`,
|
||||
}}
|
||||
>
|
||||
{/* Info section */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '0.875rem 1rem',
|
||||
display: 'flex',
|
||||
gap: '0.625rem',
|
||||
alignItems: 'flex-start',
|
||||
'@media (max-height: 700px)': {
|
||||
padding: '0.5rem 0.75rem',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: 1,
|
||||
'@media (max-height: 700px)': {
|
||||
fontSize: '1.25rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
💪
|
||||
</span>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
'@media (max-height: 700px)': {
|
||||
fontSize: '0.8125rem',
|
||||
},
|
||||
})}
|
||||
style={{ color: isDark ? '#fcd34d' : '#b45309' }}
|
||||
>
|
||||
Time to build strength!
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
marginTop: '0.125rem',
|
||||
'@media (max-height: 700px)': {
|
||||
fontSize: '0.6875rem',
|
||||
},
|
||||
})}
|
||||
style={{ color: isDark ? '#a1a1aa' : '#6b7280' }}
|
||||
>
|
||||
Focusing on {sessionMode.weakSkills.length} skill
|
||||
{sessionMode.weakSkills.length > 1 ? 's' : ''} that need practice
|
||||
</p>
|
||||
{/* Weak skills badges */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.25rem',
|
||||
marginTop: '0.5rem',
|
||||
'@media (max-height: 700px)': {
|
||||
marginTop: '0.375rem',
|
||||
gap: '0.1875rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{sessionMode.weakSkills.slice(0, 4).map((skill) => (
|
||||
<span
|
||||
key={skill.skillId}
|
||||
data-skill={skill.skillId}
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
'@media (max-height: 700px)': {
|
||||
fontSize: '0.5625rem',
|
||||
padding: '0.0625rem 0.25rem',
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
backgroundColor: isDark
|
||||
? 'rgba(245, 158, 11, 0.2)'
|
||||
: 'rgba(245, 158, 11, 0.15)',
|
||||
color: isDark ? '#fcd34d' : '#92400e',
|
||||
}}
|
||||
>
|
||||
{skill.displayName}{' '}
|
||||
<span style={{ opacity: 0.7 }}>
|
||||
({Math.round(skill.pKnown * 100)}%)
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{sessionMode.weakSkills.length > 4 && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
padding: '0.125rem 0.375rem',
|
||||
'@media (max-height: 700px)': {
|
||||
fontSize: '0.5625rem',
|
||||
padding: '0.0625rem 0.25rem',
|
||||
},
|
||||
})}
|
||||
style={{ color: isDark ? '#a1a1aa' : '#6b7280' }}
|
||||
>
|
||||
+{sessionMode.weakSkills.length - 4} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Integrated start button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="start-focus-practice"
|
||||
data-status={isStarting ? 'starting' : 'ready'}
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '0.875rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
cursor: isStarting ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
_hover: {
|
||||
filter: isStarting ? 'none' : 'brightness(1.05)',
|
||||
},
|
||||
'@media (max-height: 700px)': {
|
||||
padding: '0.75rem',
|
||||
fontSize: '0.9375rem',
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
background: isStarting
|
||||
? '#9ca3af'
|
||||
: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
boxShadow: isStarting ? 'none' : 'inset 0 1px 0 rgba(255,255,255,0.15)',
|
||||
}}
|
||||
>
|
||||
{isStarting ? (
|
||||
'Starting...'
|
||||
) : (
|
||||
<>
|
||||
<span>💪</span>
|
||||
<span>Start Focus Practice</span>
|
||||
<span>→</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{displayError && (
|
||||
<div
|
||||
|
|
@ -1489,8 +1668,8 @@ export function StartPracticeModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Start button - only shown when no tutorial is pending */}
|
||||
{!showTutorialGate && (
|
||||
{/* Start button - only shown when no special CTA is active */}
|
||||
{!showTutorialGate && !showRemediationCta && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="start-practice"
|
||||
|
|
|
|||
Loading…
Reference in New Issue