Skip to content

lesson canvas: glass panels, scroll-driven keyboard, dark mode + completion fixes

jedmund requested to merge jedmund/lesson-transparent into main

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 · TITLE kicker + 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.
  • NavigationHost lets ignoresSafeArea actually extend past chrome insets.

Pager and page bleed-through

  • Lesson pages migrated from ScrollView(.paging) to a UIPageViewController bridge in .scroll vertical 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 setViewControllers calls guarded so UIPageViewController doesn't orphan a hosting view mid-gesture.

Inline content keyboard

  • Offset driven by KVO on the pager's internal UIScrollView.contentOffset via a signed scrollProgress ∈ [-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 DockedKeyboardOverlay representable hosts the keyboard via UIHostingController and updates transform.ty imperatively from the same scroll-view KVO callback, bypassing SwiftUI's per-frame re-render cycle and the @Observable batching / 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 lessonLayout owns a .safeAreaInset(.bottom) reading coordinator.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: LessonScreen reads coordinator.dockedBinding so SwiftUI observes the change, and the keyboard subtree gets a fresh identity per binding.ownerID so Tonic's Keyboard (which captures noteOn/noteOff into internal state on first build) actually picks up the new exercise's handlers.

Completion flow

  • LessonPager now flushes UIPageViewController's cached viewControllerAfter result when the current page's advance gate flips from blocked to unblocked, so users can swipe past an exercise once they finish it.
  • NoteIdentifierExercise seeds its prompt pool from init via State(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 DSColor replaced with Color.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: Start vs Resume label based on whether a LessonRecord exists; Next · … subtitle removed; activity ring centered against the play button.
  • Debug toolbar gains a reset-all-progress button (alert-confirmed) wiping every LessonRecord and ActivityRecord.

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 dockedKeyboardHeight is 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 handleKeyDown registers 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.

Merge request reports

Loading