Übungsblatt mit Text: Vorschrift lines on the ruling with a grey trace row, composed like the Federprobe and printed in the browser - #458
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
placeText fails to reserve rows for composed lines with zero draw items, which can cause later lines to shift unexpectedly and misrepresent “no jumping” behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a content-aware “Übungstext” to the /schreiben/uebungsblatt worksheet: each input line is composed via the existing /write/word render path (Federprobe-style), then placed onto the selected ruling rows in the browser (with optional grey trace row and configurable empty practice rows), and rendered consistently in both SVG preview and the downloaded PDF.
Changes:
- Introduces client-side composition+placement for worksheet text (
useWorksheetText.ts,lib/uebungstext.ts) and wires it into the worksheet UI. - Extends the shared worksheet renderers to draw composed ink shapes in both preview SVG and PDF (
PreviewSvg,lib/pdf.ts). - Updates docs/SEO/prerender and glossary/changelog to reflect the new “Vorschrift” feature.
File summaries
| File | Description |
|---|---|
| docs/reference/glossar.md | Adds glossary entry + index mention for “Vorschrift”. |
| docs/reference/frontend-stack.md | Documents the new worksheet text feature and module responsibilities. |
| docs/concepts/architektur.md | Notes the client-side implementation status for the single-page text worksheet. |
| CHANGELOG.md | Adds an [Unreleased] entry describing the feature and implementation approach. |
| app/src/sections/worksheet/WorksheetView.tsx | Wires worksheet text state, placement, status, and PDF/preview rendering. |
| app/src/sections/worksheet/useWorksheetText.ts | New hook to debounce and cache /write/word compositions per line text. |
| app/src/sections/worksheet/PreviewSvg.tsx | Draws InkShape overlays (fills + connector polylines) in the SVG preview. |
| app/src/sections/worksheet/ConfigPanel.tsx | Adds the “Vorschrift” textarea and options; disables download while pending. |
| app/src/sections/vergleichen/VergleichenView.tsx | Uses shared lettersFromKeys helper from domain glyph registry. |
| app/src/sections/scribe/ScribeView.tsx | Uses shared lettersFromKeys helper from domain glyph registry. |
| app/src/locales/de/worksheet.ts | Adds German UI copy for the new worksheet text feature + status messages. |
| app/src/locales/de/seo.ts | Updates worksheet SEO description to mention optional “Vorschrift” text. |
| app/src/locales/de/landing.ts | Updates landing card copy to mention “Vorschrift” text. |
| app/src/locales/de/hub.ts | Updates Schreiben hub copy to mention “Vorschrift” on the worksheet. |
| app/src/lib/uebungstext.ts | New pure placement + limits reporting for composed lines into ruling rows. |
| app/src/lib/uebungstext.test.ts | Adds unit tests for splitting/placement/trace row/overflow + PDF ops. |
| app/src/lib/seo/prerender.ts | Includes the new worksheet text section in prerendered body content. |
| app/src/lib/pdf.ts | Adds InkShape + ContentStream.polyline() and draws shapes in lineaturePdf. |
| app/src/lib/lineatur.ts | Exposes per-row metrics (rows) alongside ruling segments/marks for placement. |
| app/src/domain/glyphs.ts | Adds shared lettersFromKeys() helper for “missing glyphs” user notes. |
| app/prerender/schreiben/uebungsblatt.html | Updates prerendered worksheet page content + meta descriptions. |
| app/prerender/schreiben.html | Updates prerendered Schreiben hub copy to mention “Vorschrift”. |
| app/prerender/index.html | Updates prerendered landing copy to mention “Vorschrift” on the worksheet. |
Review details
- Files reviewed: 23/23 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Fixed in the last push: a composition with no writable items now reserves its rows exactly like a pending line (one branch for both), so nothing jumps when it resolves, and the missing-letter note under the field names what stays blank; test added. |
…g one Review round on #458: a composition whose every letter is missing (items empty) used to release its rows, so the lines below jumped up once it resolved; it now reserves them like a pending line, and the missing-letter note names what stays blank.
There was a problem hiding this comment.
🔵 Needs a closer look
The new useWorksheetText hook can return stale compositions across sourceId changes and can update state after unmount, and the worksheet PDF download revokes its object URL too aggressively (risking flaky downloads).
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
app/src/sections/worksheet/WorksheetView.tsx:145
- The download URL is revoked with setTimeout(..., 0). That effectively revokes on the next tick, which can still race the download start on slower devices/browsers (and contradicts the comment). Other download helpers in the app defer revocation for seconds to avoid this.
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lineatur-${presetId || 'eigen'}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
// Defer revocation so it can't race the download start on slower devices.
setTimeout(() => URL.revokeObjectURL(url), 0);
};
app/src/sections/worksheet/useWorksheetText.ts:46
- useWorksheetText caches requested/answers across sourceId changes and has no unmount guard for in-flight requests. If sourceId ever changes, the hook can return compositions from the previous sourceId without refetching; and if the component unmounts while requests are in flight, the promise handlers can still call setAnswers, causing React "setState on unmounted" warnings in dev.
export function useWorksheetText(text: string, sourceId: string = CONFIG.sourceId): WorksheetText {
const [answers, setAnswers] = useState<Map<string, ComposedWordOut | 'error'>>(() => new Map());
// Lines already asked for — a failure is removed again so the next edit retries it.
const requested = useRef(new Set<string>());
const wanted = useMemo(() => textLines(text), [text]);
useEffect(() => {
const todo = wanted.filter((line) => !requested.current.has(line));
if (!todo.length) return;
const timer = setTimeout(() => {
for (const line of todo) {
requested.current.add(line);
fetchRenderWord(sourceId, line).then(
(c) => setAnswers((m) => new Map(m).set(line, c)),
() => {
requested.current.delete(line);
setAnswers((m) => new Map(m).set(line, 'error'));
},
);
}
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [wanted, sourceId]);
- Files reviewed: 23/23 changed files
- Comments generated: 0 new
- Review effort level: Lite
…like the Federprobe, placed and printed in the browser Each line of the worksheet's Übungstext is composed server-side (GET /write/word through the shared render cache, debounced, kept by its text) and placed by the pure lib/uebungstext.ts onto a row of the chosen ruling — Mittelband as x-height, empty practice rows beneath, a pending line keeping its row, lines too wide or without a row named under the field instead of drawn, unwritten letters listed. The SVG preview and the PDF draw the same InkShape list (ContentStream.polyline for the connectors, even-odd fills for the letters); buildLineature now reports its rows. Client-side on the Lesetafel's builder instead of the WeasyPrint backend §15 planned — the doc carries the dated note. lettersFromKeys moved to domain/glyphs (three pages share it). Website audit 2026-08-29, item 7/8.
The audit item asks for grey letters to trace over (graue Nachfahr-Buchstaben): placeText now sets a TRACE-coloured copy of each model line on the row after it (switchable, on by default), the practice rows follow; a copy without room at the page's end is dropped silently while the model line stands. Test, locale, docs and changelog follow.
…g one Review round on #458: a composition whose every letter is missing (items empty) used to release its rows, so the lines below jumped up once it resolved; it now reserves them like a pending line, and the missing-letter note names what stays blank.
f31fd17 to
e2ef549
Compare
There was a problem hiding this comment.
🟡 Changes recommended
placeText currently fails to report “no row left” for pending/failed lines when the page has no remaining rows, which can suppress the intended UI status in offline/loading scenarios.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 23/23 changed files
- Comments generated: 1
- Review effort level: Lite
| if (!c || !c.items.length) { | ||
| // Pending, failed, or nothing writable (every letter still unwritten): | ||
| // the line keeps its rows so the sheet does not jump, and the | ||
| // missing-letter note names what stays blank. | ||
| if (rows[next]) next += step; | ||
| continue; | ||
| } |
What
The Übungsblatt takes a text. A „Vorschrift (optional)" field on
/schreiben/uebungsblatt: every line is set in the written script (Sütterlin) on a row of the chosen ruling, a grey copy follows on the next row to trace over („Nachspur-Zeile in Grau", switchable, on by default), then empty rows to write it freely („Leerzeilen je Vorschrift", default 2).GET /write/wordper line through the shared render cache, debounced (450 ms), answers remembered by their text so a composition is never shown under a line that has changed (sections/worksheet/useWorksheetText.ts).lib/uebungstext.ts,placeText): template units onto the row baseline with the Mittelband as x-height;buildLineaturenow reports itsrows. The SVG preview and the PDF draw the sameInkShapelist —ContentStream.polylinefor the connectors (new), even-odd fills for the letters.lettersFromKeysmoved todomain/glyphs; the Federprobe and the Lesart page share it now). The download waits while a line is pending.Why
Website audit 2026-08-29, item 7/8 — vision §2's content-aware practice sheet („Text + passende Lineatur in einem Schritt").
Deviation from the documented plan — your call at merge
architektur.md §15 (and the audit task) planned
POST /worksheet+ WeasyPrint in the API. This PR ships the sheet client-side instead: the text arrives composed from/write/word— the path the Federprobe already uses — andlib/pdf.ts, the Lesetafel's builder since #454, already fills rings and strokes polylines, so the missing piece was placement only (~100 pure lines). WeasyPrint would have added a 200–400 MB rendering dependency to the API image (just halved to 512 Mi in #455) plus a new route for one page. §15 carries a dated note; the WeasyPrint path stays documented as the option for multi-page, HTML-set sheets. If you would rather have the backend path, say so and I will redo it there.Limits (by design, shown in the UI)
One page. A line must fit the writing width at the chosen Mittellänge (about 20 characters at the Sütterlin preset's 6 mm, about 45 at Kurrent's 2.5 mm) — a longer line is named as too wide, not shrunk, so the model always matches the ruling. The text is always set in the written Vorlage (Sütterlin), whatever ruling is chosen; the (i) says so.
Verification
/verify-frontendon the dev server against the deployed API: three lines typed → two placed with their grey trace rows, the third named as too wide for 6 mm; preview and the downloaded PDF match (rendered with PyMuPDF); desktop and mobile viewports; console clean.tsc,eslint(no new warnings),vitestgreen (uebungstext.test.ts: line splitting, placement, trace row, overflow, PDF ops); prerender drift test passes.