Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
542 changes: 542 additions & 0 deletions docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Phonics Blends Worksheets — Design Spec

**Date:** 2026-05-19
**Status:** Approved

## Summary

A standalone Python script that generates 4 print-ready HTML worksheet files covering phonics blends. Intended for a 3-year-old learning to sound out words. No interactive activities — each page is a word list to read aloud, with a subset of words broken into grapheme chunks to scaffold sounding-out.

## Output Files

All files written to `output/phonics_blends/`:

| File | Blend Family | Blends Covered | Target Word Count |
|------|-------------|----------------|-------------------|
| `01_l_blends.html` | L-blends | bl, cl, fl, gl, pl, sl | ~60 |
| `02_r_blends.html` | R-blends | br, cr, dr, fr, gr, pr, tr | ~70 |
| `03_s_blends.html` | S-blends | sc, sk, sm, sn, sp, st, sw | ~70 |
| `04_final_blends.html` | Final blends | nd, nt, st, sk, lk, mp | ~60 |

**Total: ~260 words. At least 10% (~26) displayed with grapheme chunks.**

## Page Layout

Each HTML file follows the existing project pattern:

- **Font:** OpenDyslexic (primary), Trebuchet MS / Arial fallback
- **Font size:** 20pt for words, 13pt for sub-headers
- **Page size:** US Letter, 0.45in top/bottom margins, 0.5in left/right margins
- **Auto-print:** `window.addEventListener("load", () => window.print())` trigger
- **Color scheme:** One accent color per blend family, drawn from the existing day palette:
- L-blends → Blue (`#1d4ed8` / `#dbeafe`)
- R-blends → Green (`#15803d` / `#dcfce7`)
- S-blends → Purple (`#7c3aed` / `#ede9fe`)
- Final blends → Orange (`#c2410c` / `#ffedd5`)

**Page structure (top to bottom):**
1. Full-width color header bar — blend family name (e.g., "L-Blends") + subtitle listing the blends covered (e.g., "bl · cl · fl · gl · pl · sl")
2. For each blend in the family: a sub-header (e.g., "bl words") followed by its word list
3. Words displayed in a 3-column CSS grid
4. Name line + date line at the bottom of each page (standard across all worksheets)

## Grapheme Chunk Format

Approximately 2–3 words per blend section are shown with the blend chunk visually separated:

```
bl · ue cr · ab st · op
```

- The blend letters are rendered in the page's accent color, bold weight
- The separator `·` (U+00B7 middle dot) is in a muted gray
- The remainder of the word is normal weight, black
- Regular (non-chunked) words are displayed as plain text at the same size

Chunked words are chosen to be short and decodable (CVC+blend structure preferred), e.g. `bl·ot`, `cr·ab`, `st·op` rather than complex vowel patterns.

## Script Structure

**File:** `scripts/generate_phonics_blends_series.py`

Follows the pattern of existing generators (e.g., `generate_mancala_math_series.py`):

1. Embed OpenDyslexic `@font-face` CSS block at the top of the script
2. Define word lists per blend as Python dicts — `{blend: [words]}` — with a separate dict marking which words get grapheme-chunk display and where the split point is
3. For each blend family, call a `_build_page_html(family_name, blends_dict, palette)` helper that assembles the full HTML document
4. Write each file to `output/phonics_blends/`
5. Print confirmation for each file written

## Word Selection Constraints

- All words must be real, common English words a 3-year-old would recognise or can be sounded out
- Prefer CVC or CCVC structure (short vowels) for accessibility
- Avoid multi-syllable words except where the blend is very clear (e.g., "blanket")
- Final blends: ensure words are monosyllabic where possible (hand, mint, desk)

## Out of Scope

- No images or picture matching
- No tracing lines or write-in activities
- No integration with the FastAPI backend or database
- No frontend UI changes
97 changes: 74 additions & 23 deletions frontend/app/plans/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Card, Button, Badge, Modal } from '@/components/ui';
import { Navigation } from '@/components/Navigation';
Expand All @@ -21,6 +21,23 @@ const QUANTITY_RATING_MAP: Record<string, number> = {
TOO_MUCH: -2,
};

// Reverse map: backend integer to UI rating label
function quantityToRating(qty: number): string {
if (qty > 0) return 'TOO_LITTLE';
if (qty < 0) return 'TOO_MUCH';
return 'JUST_RIGHT';
}

const FEEDBACK_LOCK_WEEKS = 3;

function isFeedbackLocked(feedbackCompletedAt: string | null): boolean {
if (!feedbackCompletedAt) return false;
const submitted = new Date(feedbackCompletedAt);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - FEEDBACK_LOCK_WEEKS * 7);
return submitted < cutoff;
}

export default function PlansPage() {
const { data: students } = useStudents();
const { packets: pendingPackets, isLoading: pendingLoading } = usePendingPackets();
Expand All @@ -42,6 +59,40 @@ export default function PlansPage() {

const queryClient = useQueryClient();

// Helper to find the selected packet from lists
const selectedPacket = useMemo(() => {
if (!selectedPacketIds) return null;
const allPackets = [...pendingPackets, ...completedPackets];
return allPackets.find(
(p) =>
p.student_id === selectedPacketIds.studentId && p.packet_id === selectedPacketIds.packetId
);
}, [selectedPacketIds, pendingPackets, completedPackets]);

// Fetch existing feedback when the feedback modal is open for a packet that has feedback
const { data: existingFeedback } = useQuery({
queryKey: ['packet-feedback', selectedPacketIds?.studentId, selectedPacketIds?.packetId],
queryFn: async () => {
if (!selectedPacketIds) return null;
return await plansApi.getFeedback(selectedPacketIds.studentId, selectedPacketIds.packetId);
},
enabled: !!selectedPacketIds && feedbackModalOpen && !!selectedPacket?.has_feedback,
});

// Pre-populate feedback modal with existing values when editing
useEffect(() => {
if (existingFeedback && feedbackModalOpen && masteryRating === null) {
const mastery = existingFeedback.mastery_feedback?.overall ?? null;
setMasteryRating(mastery);
if (
existingFeedback.quantity_feedback !== null &&
existingFeedback.quantity_feedback !== undefined
) {
setQuantityRating(quantityToRating(existingFeedback.quantity_feedback));
}
}
}, [existingFeedback, feedbackModalOpen, masteryRating]);

// Fetch plan detail when packet is selected
const { data: planDetail, isLoading: planDetailLoading } = useQuery({
queryKey: ['plan-detail', selectedPacketIds?.studentId, selectedPacketIds?.packetId],
Expand Down Expand Up @@ -99,16 +150,6 @@ export default function PlansPage() {

const { mutate: submitFeedback } = feedbackMutation;

// Helper to find the selected packet from lists
const selectedPacket = useMemo(() => {
if (!selectedPacketIds) return null;
const allPackets = [...pendingPackets, ...completedPackets];
return allPackets.find(
(p) =>
p.student_id === selectedPacketIds.studentId && p.packet_id === selectedPacketIds.packetId
);
}, [selectedPacketIds, pendingPackets, completedPackets]);

const handleViewPlan = useCallback((packet: WeeklyPacketWithStudent) => {
setSelectedPacketIds({
studentId: packet.student_id,
Expand Down Expand Up @@ -522,14 +563,19 @@ export default function PlansPage() {
Print All
</Button>
)}
{selectedPacket.status === 'ready' && (
<Button
className="bg-primary-600 hover:bg-primary-700"
onClick={handleProvideFeedback}
>
Provide Feedback
</Button>
)}
{selectedPacket.status === 'ready' &&
(isFeedbackLocked(selectedPacket.feedback_completed_at) ? (
<Button variant="ghost" disabled className="cursor-default opacity-60">
Feedback Submitted
</Button>
) : (
<Button
className="bg-primary-600 hover:bg-primary-700"
onClick={handleProvideFeedback}
>
{selectedPacket.has_feedback ? 'Edit Feedback' : 'Provide Feedback'}
</Button>
))}
</div>
</div>
</Modal>
Expand All @@ -543,12 +589,13 @@ export default function PlansPage() {
setMasteryRating(null);
setQuantityRating(null);
}}
title="Provide Feedback"
title={selectedPacket.has_feedback ? 'Edit Feedback' : 'Provide Feedback'}
>
<div className="space-y-6">
<p className="text-neutral-600">
Help the AI understand how {selectedPacket.studentName} did with this week&apos;s
plan.
{selectedPacket.has_feedback
? `Update how ${selectedPacket.studentName} did with this week’s plan.`
: `Help the AI understand how ${selectedPacket.studentName} did with this week’s plan.`}
</p>

{/* Mastery Rating */}
Expand Down Expand Up @@ -630,7 +677,11 @@ export default function PlansPage() {
onClick={handleSubmitFeedback}
disabled={!masteryRating || !quantityRating || feedbackMutation.isPending}
>
{feedbackMutation.isPending ? 'Submitting...' : 'Submit Feedback'}
{feedbackMutation.isPending
? 'Submitting...'
: selectedPacket.has_feedback
? 'Update Feedback'
: 'Submit Feedback'}
</Button>
</div>
</div>
Expand Down
19 changes: 19 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export interface WeeklyPacketSummary {
resource_days: number;
daily_count: number;
updated_at: string;
has_feedback: boolean;
feedback_completed_at: string | null;
}

export interface FeedbackData {
mastery_feedback: Record<string, string> | null;
quantity_feedback: number | null;
completed_at: string | null;
}

export interface PaginatedResponse<T> {
Expand Down Expand Up @@ -196,6 +204,17 @@ export const plansApi = {
return data;
},

getFeedback: async (studentId: string, packetId: string): Promise<FeedbackData | null> => {
try {
const { data } = await apiClient.get(
`/students/${studentId}/weekly-packets/${packetId}/feedback`
);
return data;
} catch {
return null;
}
},

submitFeedback: async (
studentId: string,
packetId: string,
Expand Down
Loading
Loading