lesson canvas: glass panels, scroll-driven keyboard, dark mode + completion fixes
Created by: jedmund
Summary
Reworks the lesson view to fit the bento-home aesthetic (rounded glass card over the persistent GenerativeBackground), drives the docked exercise keyboard imperatively from the pager's scroll gesture instead of fighting SwiftUI animation modifiers, fills out dark-mode coverage across lesson chrome, and unblocks completing a lesson end-to-end.
Chrome and canvas
- Lesson sits on a single
glassEffect(.regular)rounded card with uniform 8pt gutters; corner radius dialed to 28pt. - Title bar redesigned:
BackChip+LESSON · TITLEkicker + TOC chip on a rounded glass surface. - Card respects the top safe area (read from the active window since the navigation host's hosted VC sets
safeAreaRegions = []). - Page layouts no longer paint opaque backgrounds; lesson exercises drop their tan fills and ride the lesson glass.
- TOC popover anchors to the trailing edge next to its trigger chip.
-
NavigationHostletsignoresSafeAreaactually extend past chrome insets.
Pager and page bleed-through
- Lesson pages migrated from
ScrollView(.paging)to aUIPageViewControllerbridge in.scrollvertical mode, keeping only current and adjacent pages in the hierarchy. Eliminates the off-screen blur bleed from iOS 26 liquid-glass sampling. - Hosting controllers cached per page index; mid-transition
setViewControllerscalls guarded so UIPageViewController doesn't orphan a hosting view mid-gesture.
Inline content keyboard
- Offset driven by KVO on the pager's internal
UIScrollView.contentOffsetvia a signedscrollProgress ∈ [-1, 1], feeding a direction-aware formula:- both pages interactive: keyboard stays docked, content swaps atomically
- exiting interactive: slides off the top forward, off the bottom backward
- entering interactive: rises from below forward, descends from above backward
- neither interactive: parked off-bottom
- Rest offset derived from
scrollView.contentSize.height(UIPageViewController keeps a 3-slot layout even when prev/next don't exist). - Hardcoded tan swapped for
Color.interactiveBackground(translucent black tint, dark-mode adaptive) so the glass beneath drives color. - Inline keyboard no longer falls back to the retained content config on non-interactive pages, so it stops peeking out from under the docked keyboard during overscroll.
Docked exercise keyboard
- Lifted out of the lesson card to a root-level overlay; positioning moved from SwiftUI to UIKit. A new
DockedKeyboardOverlayrepresentable hosts the keyboard viaUIHostingControllerand updatestransform.tyimperatively from the same scroll-view KVO callback, bypassing SwiftUI's per-frame re-render cycle and the@Observablebatching / PreferenceKey feedback loop / animation modifier scope that were producing entry pops, exit flickers, and 3-flash glitches on the inline keyboard. - Plate extends through the bottom safe area; keys stay above the home indicator (
safeAreaRegions = []on the host plus an explicit bottom inset in the SwiftUI rootView). - Each exercise's
lessonLayoutowns a.safeAreaInset(.bottom)readingcoordinator.dockedKeyboardHeight, so the centered exercise card is sized correctly during preload and tracks the swipe-in instead of popping at commit. - Bindings swap cleanly between exercises:
LessonScreenreadscoordinator.dockedBindingso SwiftUI observes the change, and the keyboard subtree gets a fresh identity perbinding.ownerIDso Tonic'sKeyboard(which capturesnoteOn/noteOffinto internal state on first build) actually picks up the new exercise's handlers.
Completion flow
-
LessonPagernow flushes UIPageViewController's cachedviewControllerAfterresult when the current page's advance gate flips from blocked to unblocked, so users can swipe past an exercise once they finish it. -
NoteIdentifierExerciseseeds its prompt pool frominitviaState(initialValue:)so the prompt is visible during preloaded swipes (UIPageViewController doesn't fire appearance callbacks for adjacent VCs).
Dark mode
- Color tokens: cream values in
DSColorreplaced withColor.white. - Hero card play button uses the raw
DSColor.fg.color/lessonBg.color(bypassing the user-palette tint blend) so the dark-mode button is pure white-on-dark, not warm cream. - Inline quiz buttons, callout blocks, and split-quiz answer options switched to translucent backgrounds over the lesson glass, with
Color(light:dark:)pairs for correct/wrong indicators.
Home
- Hero card:
StartvsResumelabel based on whether aLessonRecordexists;Next · …subtitle removed; activity ring centered against the play button. - Debug toolbar gains a reset-all-progress button (alert-confirmed) wiping every
LessonRecordandActivityRecord.
Known follow-ups
- Inline keyboard outer stroke is a 20% opacity placeholder for the styling pass; not the final value.
- First-ever swipe into a docked exercise in a fresh launch still pops slightly because
dockedKeyboardHeightis 0 until the first binding publishes and the host measures itself. Subsequent visits in the same session use the cached height and are smooth.
Test plan
-
Open "Half Steps and Whole Steps". Lesson card sits on glass, 8pt gutters, 28pt corner radius, top edge below the status bar. -
Title bar shows BackChip + kicker + TOC chip on glass; back chip pops to home; TOC popover opens anchored to its chip and closes on outside tap. -
Toggle dark mode: callouts, inline quiz buttons, split-quiz answers, hero play button, lesson card, and reading text all read cleanly. No remaining cream surfaces. -
Swipe forward from a reading page into the first Note Identifier: the docked keyboard rises from the bottom in lockstep with the finger; the exercise card stays centered above it (no pop at commit). Swipe back out: keyboard descends in sync, no flicker back to docked. -
Swipe between content pages with inline keyboards: keyboard slides per the offset formula; no flash on the inline keyboard at commit. -
Overscroll forward on an exercise that hasn't been completed: docked keyboard rubber-bands; no inline keyboard peeks out from under it. -
Complete the first Note Identifier, swipe forward to the second Note Identifier, tap a key: the second exercise's handleKeyDownregisters the answer (not the first exercise's stale handler). -
Finish an exercise: forward swipe unblocks immediately (no need to swipe twice or navigate away). -
Open the end-of-lesson quiz: option buttons render translucent over glass; correct/wrong states use green/red tints in both modes. -
Debug toolbar (DEBUG only): tap the reset button, confirm; hero card flips to "Start" and progress rings clear.