Typing lessons like duolingo & musicmouse cleanup

This commit is contained in:
2026-09-12 18:58:02 +02:00
parent 498243af46
commit a5210fead2
50 changed files with 3074 additions and 710 deletions

View File

@@ -297,13 +297,17 @@ def _build_app(
return app
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
return next((s for s in services if isinstance(s, kind)), None)
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
"""The web front-end's own hub, if it is running - so open tabs refetch the library
as background analysis lands, instead of only after a manual reload. `None` when
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
tell".
"""
web_service = next((s for s in services if isinstance(s, WebService)), None)
web_service = _service_of_type(services, WebService)
return web_service.hub.broadcast_library if web_service else None
@@ -339,7 +343,7 @@ def _build_services(
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
# into its folder, so there is nothing to gate here with its own config section.
web_service = next((s for s in services if isinstance(s, WebService)), None)
web_service = _service_of_type(services, WebService)
services.append(
PodcastFeedService(
app.library,

View File

@@ -12,6 +12,7 @@ from musicmouse.config import Config, FigureColors
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player
from musicmouse.library import MusicLibrary
from musicmouse.library.models import Album
from musicmouse.media import Playlist
_log = logging.getLogger(__name__)
@@ -55,6 +56,10 @@ class App:
_log.warning("No playlist for figure %r", figure)
return playlist
def album_for(self, playlist: Playlist | None) -> Album | None:
"""The library album a playlist came from, if any."""
return self.library.get(playlist.album_id if playlist else None)
async def rescan_library(
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
) -> None:

View File

@@ -267,6 +267,8 @@ class VlcPlayer(PlayerBase):
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
def play(self) -> None:
if self._playing:
return
self._list_player.play()
def play_from_start(self) -> None:
@@ -279,6 +281,8 @@ class VlcPlayer(PlayerBase):
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
def pause(self) -> None:
if not self._playing:
return
self._media_player.set_pause(1)
def stop(self) -> None:

View File

@@ -41,7 +41,7 @@ __all__ = [
"PlaySeriesLatestRequested",
"PlaybackChanged",
"PlaylistFinished",
"PrevTrackRequested",
"PreviousTrackRequested",
"RfidTokenRead",
"RotaryTurned",
"SetVolumeRequested",
@@ -135,8 +135,6 @@ class RotaryTurned(InputEvent):
class PlaylistFinished(InputEvent):
"""The player reached the end of the playlist."""
figure: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class DeviceConnected(InputEvent):
@@ -168,7 +166,7 @@ class NextTrackRequested(IntentEvent):
@dataclass(frozen=True, slots=True, kw_only=True)
class PrevTrackRequested(IntentEvent):
class PreviousTrackRequested(IntentEvent):
pass

View File

@@ -74,7 +74,7 @@ def web_playback(event: PlaybackChanged, app: App) -> None:
off_animation(app)
return
album = app.library.get(event.playlist.album_id if event.playlist else None)
album = app.album_for(event.playlist)
if album is None:
return
web_animation(app, parse_color(album.colors[0]))

View File

@@ -20,7 +20,7 @@ from musicmouse.events import (
PlaylistFinished,
PlayRequested,
PlaySeriesLatestRequested,
PrevTrackRequested,
PreviousTrackRequested,
RotaryTurned,
SeekRequested,
SetVolumeRequested,
@@ -118,7 +118,7 @@ def button_pressed(event: ButtonEvent, app: App) -> None:
if event.action is not ButtonAction.PRESSED:
return
if event.button is Button.LEFT and app.player.is_playing:
app.bus.emit(PrevTrackRequested(source="device"))
app.bus.emit(PreviousTrackRequested(source="device"))
elif event.button is Button.RIGHT and app.player.is_playing:
app.bus.emit(NextTrackRequested(source="device"))
# The rotary press is published to Home Assistant by the MQTT service; what it
@@ -143,8 +143,8 @@ def next_track(event: NextTrackRequested, app: App) -> None:
app.player.next_track()
@on(PrevTrackRequested)
def previous_track(event: PrevTrackRequested, app: App) -> None:
@on(PreviousTrackRequested)
def previous_track(event: PreviousTrackRequested, app: App) -> None:
_log.debug("Previous track (%s)", event.source)
app.player.previous_track()

View File

@@ -23,7 +23,7 @@ from musicmouse.events import (
PlayAlbumRequested,
PlayRequested,
PlaySeriesLatestRequested,
PrevTrackRequested,
PreviousTrackRequested,
SetVolumeRequested,
VolumeChangeRequested,
)
@@ -40,8 +40,8 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
# pauses, like its pause button.
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
"KEY_STOP": lambda: PauseRequested(source="lirc"),
"KEY_PREVIOUS": lambda: PrevTrackRequested(source="lirc"),
"KEY_REWIND": lambda: PrevTrackRequested(source="lirc"),
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
}

View File

@@ -13,6 +13,7 @@ from typing import Any, ClassVar
from musicmouse.bus import EventBus
from musicmouse.config import MqttConfig
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player
from musicmouse.events import (
ActiveFigureChanged,
@@ -22,7 +23,7 @@ from musicmouse.events import (
PauseRequested,
PlaybackChanged,
PlayRequested,
PrevTrackRequested,
PreviousTrackRequested,
SetVolumeRequested,
TrackChanged,
VolumeChanged,
@@ -37,9 +38,11 @@ __all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"]
class PlayerSensor(Entity):
component = "sensor"
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None:
def __init__(
self, bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> None:
self.mouse = mouse
self.player = player
self._figure: str | None = None
super().__init__(bus, config, object_id="player", name="Music Mouse Player")
def discovery_payload(self) -> dict[str, Any]:
@@ -56,9 +59,7 @@ class PlayerSensor(Entity):
for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged):
self.bus.subscribe(event_type, self._on_change)
async def _on_change(self, event: Event) -> None:
if isinstance(event, ActiveFigureChanged):
self._figure = event.figure
async def _on_change(self, _event: Event) -> None:
await self.publish_state()
async def publish_state(self) -> None:
@@ -68,7 +69,7 @@ class PlayerSensor(Entity):
await self.publish(
f"{self.base_topic}/attributes",
{
"figure": self._figure,
"figure": self.mouse.active_figure,
"playlist": playlist.name if playlist else None,
"track_index": self.player.track_index,
"track_count": len(playlist) if playlist else 0,
@@ -128,7 +129,7 @@ class TransportButton(Entity):
#: Button object id -> the intent pressing it emits.
INTENTS: ClassVar[dict[str, type[IntentEvent]]] = {
"next": NextTrackRequested,
"previous": PrevTrackRequested,
"previous": PreviousTrackRequested,
"play": PlayRequested,
"pause": PauseRequested,
}
@@ -154,7 +155,9 @@ class TransportButton(Entity):
self.bus.emit(self.INTENTS[self.action](source="mqtt"))
def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[Entity]:
def player_entities(
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> list[Entity]:
names = {
"next": "Music Mouse Next",
"previous": "Music Mouse Previous",
@@ -162,7 +165,7 @@ def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[E
"pause": "Music Mouse Pause",
}
return [
PlayerSensor(bus, config, player),
PlayerSensor(bus, config, mouse, player),
VolumeNumber(bus, config, player),
*(TransportButton(bus, config, action, name) for action, name in names.items()),
]

View File

@@ -41,7 +41,7 @@ def build_entities(
"""Everything this backend exposes to Home Assistant."""
return [
*(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone),
*player_entities(bus, config, player),
*player_entities(bus, config, mouse, player),
*trigger_entities(bus, config),
]

View File

@@ -27,7 +27,7 @@ from musicmouse.events import (
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PrevTrackRequested,
PreviousTrackRequested,
SeekRequested,
SetVolumeRequested,
)
@@ -152,7 +152,7 @@ def build_router(
@router.post("/previous", status_code=204)
def previous_track() -> Response:
return emit(PrevTrackRequested(source="web"))
return emit(PreviousTrackRequested(source="web"))
@router.post("/seek", status_code=204)
def seek(body: SeekIn) -> Response:

View File

@@ -34,6 +34,8 @@ __all__ = [
def to_device_volume(percent: int, general: GeneralConfig) -> int:
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
span = general.max_volume - general.min_volume
if span <= 0:
return general.min_volume
return general.min_volume + round(span * max(0, min(100, percent)) / 100)

View File

@@ -17,7 +17,7 @@ __all__ = ["snapshot"]
def snapshot(app: App) -> PlayerStateOut:
player = app.player
playlist = player.playlist
album = app.library.get(playlist.album_id if playlist else None)
album = app.album_for(playlist)
index = player.track_index
# The player only knows a file path, and a tagged file's name is rarely its title
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.

View File

@@ -26,7 +26,7 @@ from musicmouse.events import (
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PrevTrackRequested,
PreviousTrackRequested,
RfidTokenRead,
RotaryTurned,
SeekRequested,
@@ -164,7 +164,7 @@ class SimulatorDriver:
await self.settle()
async def emit_prev(self) -> None:
self.app.bus.emit(PrevTrackRequested(source="simulator"))
self.app.bus.emit(PreviousTrackRequested(source="simulator"))
await self.settle()
async def set_volume(self, volume: int) -> None:
@@ -235,9 +235,7 @@ class SimulatorDriver:
case "playlist":
return player.playlist.name if player.playlist else "none"
case "album":
album = self.app.library.get(
player.playlist.album_id if player.playlist else None
)
album = self.app.album_for(player.playlist)
return album.title if album else "none"
case "position":
return f"{player.position:.1f}"

View File

@@ -42,7 +42,6 @@ class FakePlayer(PlayerBase):
self._remaining = track_duration
self._started_at: float | None = None
self._timer: asyncio.Task[None] | None = None
self.closed = False
# -------------------------------------------------------------------- state
@@ -112,12 +111,8 @@ class FakePlayer(PlayerBase):
if self._playing:
self._start_timer()
async def run(self) -> None:
return
def close(self) -> None:
self._cancel_timer()
self.closed = True
# ---------------------------------------------------------------- internals

View File

@@ -20,7 +20,7 @@ from musicmouse.events import (
PlayAlbumRequested,
PlayRequested,
PlaySeriesLatestRequested,
PrevTrackRequested,
PreviousTrackRequested,
VolumeChangeRequested,
)
from musicmouse.services.lirc import LircService
@@ -160,7 +160,7 @@ async def test_transport_and_volume_buttons_emit_intents(
PlayRequested,
PauseRequested,
PauseRequested,
PrevTrackRequested,
PreviousTrackRequested,
NextTrackRequested,
VolumeChangeRequested,
]

View File

@@ -419,14 +419,18 @@ async def test_transport_buttons_emit_intents(
async def test_the_player_sensor_reports_the_current_track(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
bus: EventBus,
mqtt_config: MqttConfig,
mouse: MusicMouseDevice,
player: FakePlayer,
publisher: RecordingPublisher,
) -> None:
from pathlib import Path
from musicmouse.media import Playlist, Track
from musicmouse.services.mqtt.player import PlayerSensor
entity = PlayerSensor(bus, mqtt_config, player)
entity = PlayerSensor(bus, mqtt_config, mouse, player)
entity.attach(publisher)
player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),)))
player.play_from_start()
@@ -439,6 +443,29 @@ async def test_the_player_sensor_reports_the_current_track(
assert attributes["volume"] == 40
async def test_the_player_sensor_reports_the_mouse_s_active_figure(
bus: EventBus,
mqtt_config: MqttConfig,
transport: FakeTransport,
mouse: MusicMouseDevice,
player: FakePlayer,
publisher: RecordingPublisher,
) -> None:
"""The sensor reads ``mouse.active_figure`` rather than keeping its own copy of it,
so it agrees with the mouse even if it missed the event that changed it."""
from musicmouse.services.mqtt.player import PlayerSensor
entity = PlayerSensor(bus, mqtt_config, mouse, player)
entity.attach(publisher)
transport.inject(RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"))
await bus.drain()
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
assert attributes["figure"] == "fuchs"
assert mouse.active_figure == "fuchs"
# --------------------------------------------------------------------- triggers

View File

@@ -334,20 +334,76 @@ class _FakeMedia:
class _FakeMediaPlayer:
"""Just enough of libVLC's media player to exercise index syncing."""
"""Just enough of libVLC's media player to exercise index syncing, pause/seek and
the volume/playing poll."""
def __init__(self) -> None:
self.media: _FakeMedia | None = None
self.paused = False
self.time_ms = 0
self.length_ms = 0
self.volume = 50
def get_media(self) -> _FakeMedia | None:
return self.media
def set_pause(self, flag: int) -> None:
self.paused = bool(flag)
def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer:
def set_time(self, ms: int) -> None:
self.time_ms = ms
def get_time(self) -> int:
return self.time_ms
def get_length(self) -> int:
return self.length_ms
def audio_set_volume(self, volume: int) -> None:
self.volume = volume
def audio_get_volume(self) -> int:
return self.volume
class _FakeListPlayer:
"""Just enough of libVLC's list player to exercise play/stop and the is-playing
half of the poll."""
def __init__(self) -> None:
self.playing = False
self.play_calls = 0
self.stop_calls = 0
self.played_index: int | None = None
def play(self) -> None:
self.play_calls += 1
self.playing = True
def stop(self) -> None:
self.stop_calls += 1
self.playing = False
def is_playing(self) -> bool:
return self.playing
def play_item_at_index(self, index: int) -> None:
self.played_index = index
self.playing = True
def _vlc_like(
bus: EventBus,
playlist: Playlist,
*,
media_player: _FakeMediaPlayer | None = None,
list_player: _FakeListPlayer | None = None,
) -> VlcPlayer:
"""A VlcPlayer with its libVLC parts stubbed out, without calling __init__."""
player = object.__new__(VlcPlayer)
PlayerBase.__init__(player, bus)
player._media_player = _FakeMediaPlayer() # type: ignore[assignment]
player._media_player = media_player or _FakeMediaPlayer()
player._list_player = list_player or _FakeListPlayer()
player._playlist = playlist
player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)}
return player
@@ -365,7 +421,7 @@ async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> Non
events: list[Event] = []
bus.subscribe(TrackChanged, events.append)
player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined]
player._media_player.media = _FakeMedia("file:///music/2.mp3")
player._on_next_item(object())
await bus.drain()
@@ -379,10 +435,193 @@ async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None:
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
player = _vlc_like(bus, playlist)
player._media_player.media = None # type: ignore[attr-defined]
player._media_player.media = None
player._on_next_item(object())
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined]
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3")
player._on_next_item(object())
await bus.drain()
assert player.track_index == 0
# ------------------------------------------------------ VlcPlayer actions, stubbed
async def test_vlc_play_starts_playback_when_idle(bus: EventBus) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(), list_player=list_player)
player.play()
assert list_player.play_calls == 1
async def test_vlc_play_is_a_no_op_once_already_playing(bus: EventBus) -> None:
"""Mirrors `FakePlayer.play()`'s own guard, so the two `Player` implementations
agree on what a repeated play() does instead of leaving it to libVLC."""
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(), list_player=list_player)
player._set_playing(True)
player.play()
assert list_player.play_calls == 0
async def test_vlc_pause_pauses_when_playing(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player._set_playing(True)
player.pause()
assert media_player.paused is True
async def test_vlc_pause_is_a_no_op_when_not_playing(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.pause()
assert media_player.paused is False
async def test_vlc_play_track_clamps_to_the_playlist_bounds(bus: EventBus) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(count=3), list_player=list_player)
player.play_track(99)
assert list_player.played_index == 2
async def test_vlc_play_track_on_an_empty_playlist_does_nothing(
bus: EventBus, caplog: pytest.LogCaptureFixture
) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, Playlist(name="leer", tracks=()), list_player=list_player)
with caplog.at_level("WARNING"):
player.play_track(0)
assert list_player.played_index is None
assert "playlist is empty" in caplog.text
async def test_vlc_seek_sets_the_time_in_milliseconds(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.seek(12.5)
assert media_player.time_ms == 12500
async def test_vlc_seek_clamps_negative_positions_to_zero(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.seek(-5.0)
assert media_player.time_ms == 0
async def test_vlc_position_and_duration_read_the_media_player(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
media_player.time_ms = 4_000
media_player.length_ms = 180_000
player = _vlc_like(bus, playlist(), media_player=media_player)
assert player.position == 4.0
assert player.duration == 180.0
async def test_vlc_position_floors_libvlcs_minus_one_at_zero(bus: EventBus) -> None:
"""libVLC reports -1 for both until a media is actually opened."""
media_player = _FakeMediaPlayer()
media_player.time_ms = -1
media_player.length_ms = -1
player = _vlc_like(bus, playlist(), media_player=media_player)
assert player.position == 0.0
assert player.duration == 0.0
async def test_vlc_apply_volume_pushes_to_the_media_player(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.set_volume(77)
assert media_player.volume == 77
async def test_vlc_poll_announces_a_volume_change_made_outside_the_app(
bus: EventBus, seen: list[Event]
) -> None:
"""The physical volume can move without going through `set_volume` - a hardware
knob on the amp, or another process - so the poll is what catches libVLC drifting
from what `PlayerBase` last announced."""
media_player = _FakeMediaPlayer()
media_player.volume = 50
player = _vlc_like(bus, playlist(), media_player=media_player)
media_player.volume = 65
player._poll()
await bus.drain()
assert player.volume == 65
assert only(seen, VolumeChanged) == [VolumeChanged(volume=65, source="player")]
async def test_vlc_poll_ignores_a_negative_volume_reading(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
media_player.volume = -1
player._poll()
assert player.volume == 50
async def test_vlc_poll_syncs_is_playing_from_the_list_player(
bus: EventBus, seen: list[Event]
) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(), list_player=list_player)
list_player.playing = True
player._poll()
await bus.drain()
assert player.is_playing
assert only(seen, PlaybackChanged)[-1].playing is True
async def test_vlc_on_playing_and_on_stopped_announce_playback_changed(
bus: EventBus, seen: list[Event]
) -> None:
player = _vlc_like(bus, playlist())
player._on_playing(object())
await bus.drain()
assert player.is_playing
player._on_stopped(object())
await bus.drain()
assert not player.is_playing
assert [e.playing for e in only(seen, PlaybackChanged)] == [True, False]
async def test_vlc_on_playlist_end_stops_and_announces_playlist_finished(
bus: EventBus, seen: list[Event]
) -> None:
player = _vlc_like(bus, playlist())
player._set_playing(True)
player._on_playlist_end(object())
await bus.drain()
assert not player.is_playing
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]

View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
@@ -2328,6 +2329,21 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.2.0",

View File

@@ -15,12 +15,10 @@ import { Aquarium } from "./components/Aquarium";
import { AppHeader } from "./components/AppHeader";
import { HelpOverlay } from "./components/HelpOverlay";
import { LessonMap } from "./components/LessonMap";
import { ModePicker } from "./components/ModePicker";
import { ResultSheet } from "./components/ResultSheet";
import { Stage } from "./components/Stage";
import { BubblesRun } from "./components/modes/BubblesRun";
import { FeedRun } from "./components/modes/FeedRun";
import { PearlsRun } from "./components/modes/PearlsRun";
import { JellyfishRun } from "./components/modes/JellyfishRun";
import { RaceRun } from "./components/modes/RaceRun";
import { DiveRun } from "./components/modes/DiveRun";
@@ -39,7 +37,7 @@ type Screen = "aquarium" | "map" | "run";
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render
* below narrows on `kind` instead of guessing from which keys are present.
*
* Only two shapes, for six modes: the arcade modes want a stream of single letters, and
* Only two shapes, for five modes: the arcade modes want a stream of single letters, and
* everything else wants a line of chunks. That is the whole reason the modes are cheap
* to add - they are presentations of one of two targets, all driven by the same engine. */
type RunTarget =
@@ -49,6 +47,11 @@ type RunTarget =
/** Modes that drill one key at a time rather than a line. */
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
/** How much of a letters round the spotlighted key(s) should take. An isolated round -
* the key's very first lesson - drills it hard; the mixed round right after blends it
* back in with everything else, which is the whole point of "isolated then mixed". */
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
interface Outcome {
result: RunResult;
unlockedTitle: string | null;
@@ -89,9 +92,17 @@ export function App() {
const spaceActive = true;
if (LETTER_ONLY_MODES.includes(mode)) {
const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined;
return {
kind: "letters",
letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys),
letters: letterStream(
lesson.activeKeys,
rng,
bubbleCountFor(lesson.world),
focusKey,
lesson.spotlightKeys,
share,
),
};
}
const chunks = lineFor(lesson, rng, {
@@ -106,16 +117,13 @@ export function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson, mode, round]);
const start = useCallback(
(lesson: Lesson) => {
const start = useCallback((lesson: Lesson) => {
setLessonId(lesson.id);
setMode(lesson.modes.includes(mode) ? mode : "dive");
setMode(lesson.primaryMode);
setOutcome(null);
setRound((r) => r + 1);
setScreen("run");
},
[mode],
);
}, []);
const onFinished = useCallback(
(result: RunResult) => {
@@ -142,6 +150,16 @@ export function App() {
setRound((r) => r + 1);
}, []);
/** A bonus replay in a mode this lesson didn't gate progress on - feed or race,
* offered on the result sheet once the lesson is passed. Never touches the unlock:
* `onFinished` still runs underneath, so a great bonus run can only improve the best
* score, not change what is unlocked. */
const playBonus = useCallback((bonusMode: ModeId) => {
setMode(bonusMode);
setOutcome(null);
setRound((r) => r + 1);
}, []);
const continueAfterResult = useCallback(() => {
const next = lessonId ? nextLesson(lessonId) : null;
setOutcome(null);
@@ -201,8 +219,8 @@ export function App() {
}
if (current.screen !== "map") return;
const step =
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
// The map is a vertical path now, so "next" is down rather than to the right.
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
if (step !== 0) {
event.preventDefault();
playPop(340);
@@ -290,33 +308,23 @@ export function App() {
)
) : mode === "feed" ? (
<FeedRun {...textProps(run)} />
) : mode === "pearls" ? (
<PearlsRun {...textProps(run)} />
) : mode === "race" ? (
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
) : (
<DiveRun {...textProps(run)} />
)}
<div style={{ padding: "0 32px 18px", flex: "none" }}>
<ModePicker
modes={lesson.modes}
active={mode}
onPick={(chosen) => {
setMode(chosen);
setRound((r) => r + 1);
}}
/>
</div>
</>
)}
{outcome && (
{outcome && lesson && (
<ResultSheet
result={outcome.result}
unlockedTitle={outcome.unlockedTitle}
newCreature={outcome.newCreature}
isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)}
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus}
onRetry={retry}
onContinue={continueAfterResult}
continueLabel={

View File

@@ -1,13 +1,15 @@
/** The map: four worlds, each a row of lesson cards.
/** The map: a single winding path, one world section at a time.
*
* Locked lessons are dimmed rather than hidden - seeing that there is a world called
* "Große Buchstaben" waiting is half the reason to finish the one that is open. Each
* card carries its own best animal and star count, so the map doubles as the trophy
* cabinet. */
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
* waiting is half the reason to finish the world that is open. Each node carries its own
* best animal, star count and a badge for which game it plays, so the map doubles as
* both a path forward and a trophy cabinet. */
import { LESSONS, WORLDS } from "../lib/curriculum";
import type { Lesson } from "../lib/curriculum";
import { animalById } from "../lib/grading";
import { NODE_SPACING, pathD, pointFor } from "../lib/lessonPath";
import { MODE_INFO } from "../lib/modeInfo";
import type { Progress } from "../lib/progress";
interface Props {
@@ -17,32 +19,72 @@ interface Props {
onPick: (lesson: Lesson) => void;
}
const NODE_SIZE = 88;
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
const PATH_HALF_WIDTH = 160;
/** What a consolidation node's key-label line says when it has no keys of its own to
* show - a child-friendly word rather than the raw `LessonKind`. */
const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
fragments: "Wörter",
words: "Wörter",
sentences: "Sätze",
};
export function LessonMap({ progress, selected, onPick }: Props) {
return (
<div
className="view-enter"
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }}
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
>
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
{WORLDS.map((world) => {
const lessons = LESSONS.filter((lesson) => lesson.world === world.number);
const done = lessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
const height = lessons.length * NODE_SPACING;
const points = lessons.map((_, i) => pointFor(i));
return (
<section key={world.number}>
<div
className="glass-panel"
style={{
position: "sticky",
top: 0,
zIndex: 2,
padding: "10px 18px",
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 8,
}}
>
<div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}>
{WORLDS.map((world) => (
<div key={world.number} className="glass-panel" style={{ padding: "16px 20px 20px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 22 }}>{world.emoji}</span>
<span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
Welt {world.number} {world.title}
</span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>
{LESSONS.filter((l) => l.world === world.number && (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length}
/{LESSONS.filter((l) => l.world === world.number).length}
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
{done}/{lessons.length}
</span>
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{LESSONS.filter((lesson) => lesson.world === world.number).map((lesson) => {
<div style={{ position: "relative", height, margin: "0 auto" }}>
<svg
style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
width={PATH_HALF_WIDTH * 2}
height={height}
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
>
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
</svg>
{lessons.map((lesson, i) => {
const entry = progress.lessons[lesson.id];
const locked = !entry?.unlocked;
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
const index = LESSONS.indexOf(lesson);
const point = points[i]!;
const modeInfo = MODE_INFO[lesson.primaryMode];
return (
<button
@@ -52,53 +94,77 @@ export function LessonMap({ progress, selected, onPick }: Props) {
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
style={{ width: 150, padding: 12, flex: "none" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}>
{lesson.number}
</span>
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
</div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}>
{lesson.title}
</div>
{/* The keys themselves, big: for a pre-reader this is the real label
and the title is decoration. A drill has no new keys, so it says
so with a symbol instead of showing an empty line. */}
<div
title={lesson.title}
style={{
position: "absolute",
left: `calc(50% + ${point.x}px)`,
top: point.y,
transform: "translate(-50%, -50%)",
width: NODE_SIZE,
height: NODE_SIZE,
borderRadius: "50%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 0,
textAlign: "center",
}}
>
<span
aria-hidden
style={{
position: "absolute",
top: -4,
right: -4,
fontSize: 15,
filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{/* The keys themselves: for a pre-reader this is the real label, the
title is decoration. A drill has no new keys, so it says so with
a symbol instead of showing an empty line. */}
<span
style={{
fontSize: 11,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.75,
letterSpacing: lesson.isDrill ? "normal" : "0.14em",
marginTop: 3,
minHeight: 20,
opacity: 0.8,
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
lineHeight: 1.1,
}}
>
{lesson.isDrill
? "🔁 Übung"
: lesson.newKeys
.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase()))
.join(" ")}
</div>
? "🔁"
: lesson.newKeys.length > 0
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
: lesson.kind === "letters"
? "üben"
: CONSOLIDATION_LABEL[lesson.kind]}
</span>
<div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}>
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span>
))}
</div>
</span>
</button>
);
})}
</div>
</div>
))}
</section>
);
})}
</div>
</div>
);

View File

@@ -1,40 +0,0 @@
/** Which game a lesson is played as. Dive mode is the measured one; the rest are
* the same drill wearing a costume, which is how variety gets discovered without
* splitting the curriculum. */
import type { ModeId } from "../lib/curriculum";
interface Props {
modes: readonly ModeId[];
active: ModeId;
onPick: (mode: ModeId) => void;
}
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" },
pearls: { emoji: "🦪", name: "Perlentaucher" },
};
export function ModePicker({ modes, active, onPick }: Props) {
return (
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
{modes.map((mode) => {
const info = MODE_INFO[mode];
return (
<button
key={mode}
className="pill"
data-active={mode === active}
onClick={() => onPick(mode)}
>
{info.emoji} {info.name}
</button>
);
})}
</div>
);
}

View File

@@ -9,9 +9,11 @@ import { useEffect, useRef } from "react";
import { creatureById } from "../lib/aquarium";
import type { CreatureId } from "../lib/aquarium";
import type { ModeId } from "../lib/curriculum";
import type { RunResult } from "../lib/grading";
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
import type { AnimalId } from "../lib/grading";
import { MODE_INFO } from "../lib/modeInfo";
interface Props {
result: RunResult;
@@ -23,6 +25,10 @@ interface Props {
/** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */
bestEver: AnimalId | null;
/** Extra games this lesson didn't gate progress on - feed or race, offered only once
* the lesson is passed. Playing one never changes the unlock, only the best score. */
bonusModes: readonly ModeId[];
onPlayBonus: (mode: ModeId) => void;
onRetry: () => void;
onContinue: () => void;
/** Null at the end of the curriculum. */
@@ -35,6 +41,8 @@ export function ResultSheet({
newCreature,
isNewBest,
bestEver,
bonusModes,
onPlayBonus,
onRetry,
onContinue,
continueLabel,
@@ -159,6 +167,33 @@ export function ResultSheet({
</button>
)}
</div>
{/* Extra games this lesson didn't need to pass - a treat, not a requirement, so
they only appear once the lesson is already behind her. `.pill` is styled for
the dark stage background, not this light sheet, so these get their own
(smaller, quieter) version of the sheet's own button look instead. */}
{bonusModes.length > 0 && (
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center", flexWrap: "wrap" }}>
{bonusModes.map((mode) => (
<button
key={mode}
onClick={() => onPlayBonus(mode)}
style={{
border: "none",
borderRadius: 999,
padding: "8px 16px",
fontSize: 14,
fontWeight: 800,
cursor: "pointer",
background: "oklch(90% 0.02 175)",
color: "var(--ink)",
}}
>
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
</button>
))}
</div>
)}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
/** Dive mode - the core drill, and the run that counts for the unlock.
*
* A line of chunks with a moving cursor above the keyboard. Everything else in the
* game is a variation on this; this is the one that is measured. */
/** Dive mode - the plain drill: a line of chunks with a moving cursor above the
* keyboard. Most fragments/words/sentences lessons play this by default, though feed
* and race periodically take a turn as the required mode instead - any of them can
* unlock the next lesson, since `recordRun` doesn't care which mode produced the run. */
import { currentChar } from "../../lib/engine";
import type { RunResult } from "../../lib/grading";

View File

@@ -1,155 +0,0 @@
/** Pearls mode - the mode with no clock.
*
* Every other mode measures something. This one deliberately does not show a timer, a
* progress bar, a streak or a speed: an oyster opens, a word is inside it, and each
* correct letter is a pearl on the string. A mistake costs a pearl, and that is the
* only pressure there is.
*
* It exists because a six-year-old having a bad afternoon needs somewhere to go that is
* still the same lesson and still counts, but cannot be failed at quickly. Every lesson
* offers it, always. The run is still graded the same way underneath - a calm round and
* a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
import { useState } from "react";
import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine";
import { chunkOffsets } from "../../lib/generator";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { useRun } from "../../hooks/useRun";
import { Keyboard } from "../Keyboard";
interface Props {
chunks: readonly string[];
text: string;
spaceActive: boolean;
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
export function PearlsRun({
chunks,
text,
spaceActive,
activeKeys,
progress,
paused,
onFinished,
}: Props) {
const [pearls, setPearls] = useState(0);
const onEvent = (event: RunEvent) => {
// A pearl per correct letter, one lost per mistake - but never below zero. Watching
// the string shrink past empty is the kind of punishment this mode exists to avoid.
if (event.type === "correct") setPearls((n) => n + 1);
else if (event.type === "wrong" && event.firstAt) setPearls((n) => Math.max(0, n - 1));
};
const { state, wrong } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
onEvent,
});
const next = currentChar(state);
const offsets = chunkOffsets(chunks, spaceActive);
// Which word the oyster is holding right now.
const currentChunkIndex = offsets.findIndex((start, i) => {
const end = start + (chunks[i]?.length ?? 0);
return state.index <= end;
});
const word = chunks[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? "";
const wordStart = offsets[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? 0;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px",
minHeight: 0,
}}
>
{/* The pearl string. It only ever grows, one bead per letter. */}
<div
style={{
display: "flex",
gap: 5,
flexWrap: "wrap",
justifyContent: "center",
maxWidth: "70%",
minHeight: 26,
alignItems: "center",
}}
aria-label={`${pearls} Perlen`}
>
{Array.from({ length: pearls }, (_, i) => (
<span
key={i}
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))",
boxShadow: "0 2px 7px var(--shadow)",
animation: i === pearls - 1 ? "correctPop 240ms ease-out" : undefined,
}}
/>
))}
</div>
{/* The oyster, holding one word at a time. */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
padding: "26px 42px",
borderRadius: 26,
background: "linear-gradient(160deg, oklch(97% 0.01 175 / .2), oklch(97% 0.01 175 / .07))",
border: "1px solid oklch(97% 0.01 175 / .22)",
animation: wrong ? "wrongShake 260ms ease" : undefined,
}}
>
<div style={{ fontSize: 34 }}>🦪</div>
<div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}>
{[...word].map((char, i) => {
const at = wordStart + i;
const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
return (
<span
key={i}
className="target-char"
data-state={charState}
data-blank={char === " "}
data-wrong={charState === "current" && wrong}
>
{char === " " ? (charState === "current" ? "␣" : "") : char}
</span>
);
})}
</div>
</div>
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={38}
/>
</div>
);
}

View File

@@ -0,0 +1,422 @@
# The lesson plan, as data rather than code, so wording, word lists and pacing can be
# tweaked here without touching curriculum.ts.
#
# Every lesson: title, subtitle (read aloud). Then either:
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
# already active: the first time a key appears its lesson is "isolated" (heavy
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
# blended with everything learned so far). Two lessons with the same `keys` back to
# back is exactly how you write "isolated, then mixed" - no separate flag needed.
# - drill: true - a pure review round, no new content, whatever is active so far.
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
# default; `mode:` overrides it, see curriculum.ts for which modes fit which kind).
#
# Letter order follows German letter frequency, adapted to a home-row-first, mirrored
# pace for a six-year-old - unchanged from the original course.
worlds:
- number: 1
title: "Die Grundstellung"
emoji: "🏝️"
reward: clownfish
lessons:
- title: "F und J"
subtitle: "Die Zeigefinger - die Tasten mit den Punkten"
keys: [f, j]
- title: "F und J üben"
subtitle: "Die neuen Tasten festigen"
keys: [f, j]
- title: "D und K"
subtitle: "Die Mittelfinger"
keys: [d, k]
- title: "D und K üben"
subtitle: "Die neuen Tasten festigen"
keys: [d, k]
- title: "Übung: F J D K"
subtitle: "Die vier Tasten zusammen"
drill: true
- title: "S und L"
subtitle: "Die Ringfinger"
keys: [s, l]
- title: "S und L üben"
subtitle: "Die neuen Tasten festigen"
keys: [s, l]
- title: "Übung: sechs Tasten"
subtitle: "Alles bisher zusammen"
drill: true
- title: "A und Ö"
subtitle: "Die kleinen Finger"
keys: [a, ö]
- title: "A und Ö üben"
subtitle: "Die neuen Tasten festigen"
keys: [a, ö]
- title: "Übung: die Grundstellung"
subtitle: "Alle acht Finger"
drill: true
- title: "Erste kleine Wörter"
subtitle: "Echte Wörter mit acht Tasten"
kind: fragments
words: [da, ja, das, dass, als, all, fall, falls, lass, saal, kalk, salsa, jass]
- title: "Die Leertaste"
subtitle: "Der Daumen kommt dazu"
keys: [" "]
- title: "Übung: Grundstellung mit Leertaste"
subtitle: "Jetzt mit dem Daumen"
drill: true
- title: "Wörter mit Leertaste"
subtitle: "Kleine Wörter, kleine Sätze"
kind: fragments
words: ["lass das", "das da", "da ja", "fall da", "kalk da", "saal da", "ja lass das", "als da"]
- number: 2
title: "Nach oben"
emoji: "🌊"
reward: octopus
lessons:
- title: "Das E"
subtitle: "Mittelfinger links nach oben"
keys: [e]
- title: "E üben"
subtitle: "Die neue Taste festigen"
keys: [e]
- title: "Das I"
subtitle: "Mittelfinger rechts nach oben"
keys: [i]
- title: "I üben"
subtitle: "Die neue Taste festigen"
keys: [i]
- title: "Übung: E und I"
subtitle: "Beide Mittelfinger nach oben"
drill: true
- title: "Das R"
subtitle: "Zeigefinger links nach oben"
keys: [r]
- title: "R üben"
subtitle: "Die neue Taste festigen"
keys: [r]
- title: "Das U"
subtitle: "Zeigefinger rechts nach oben"
keys: [u]
- title: "U üben"
subtitle: "Die neue Taste festigen"
keys: [u]
- title: "Übung: R und U"
subtitle: "Beide Zeigefinger nach oben"
drill: true
- title: "Kleine Wörter: E I R U"
subtitle: "Erste echte Wörter mit der oberen Reihe"
kind: fragments
words: [die, sie, elf, eis, esel, see, keks, fiel, lied, rad, reis, eier, rufe, feuer, sauer, lauf]
- title: "Das T"
subtitle: "Zeigefinger links weit nach oben"
keys: [t]
- title: "T üben"
subtitle: "Die neue Taste festigen"
keys: [t]
- title: "Das Z"
subtitle: "Zeigefinger rechts weit nach oben"
keys: [z]
- title: "Z üben"
subtitle: "Die neue Taste festigen"
keys: [z]
- title: "Übung: T und Z"
subtitle: "Weit nach oben greifen"
drill: true
- title: "Das O"
subtitle: "Ringfinger rechts nach oben"
keys: [o]
- title: "O üben"
subtitle: "Die neue Taste festigen"
keys: [o]
- title: "Das W"
subtitle: "Ringfinger links nach oben"
keys: [w]
- title: "W üben"
subtitle: "Die neue Taste festigen"
keys: [w]
- title: "Übung: O und W"
subtitle: "Beide Ringfinger nach oben"
drill: true
- title: "Kleine Wörter: T Z O W"
subtitle: "Noch mehr echte Wörter"
kind: fragments
mode: feed
words: [tier, tafel, kette, leiter, zeit, salz, zelt, katze, rot, tor, los, foto, wo, wald, zwei, wolke]
- title: "Das P"
subtitle: "Kleiner Finger rechts nach oben"
keys: [p]
- title: "P üben"
subtitle: "Die neue Taste festigen"
keys: [p]
- title: "Das Q"
subtitle: "Kleiner Finger links nach oben"
keys: [q]
- title: "Q üben"
subtitle: "Die neue Taste festigen"
keys: [q]
- title: "Das Ü"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: [ü]
- title: "Ü üben"
subtitle: "Die neue Taste festigen"
keys: [ü]
- title: "Übung: P Q Ü"
subtitle: "Die kleinen Finger nach oben"
drill: true
- title: "Übung: die obere Reihe"
subtitle: "Die ganze Reihe zusammen"
kind: words
mode: feed
words: [wolke, zeit, pause, prüfe, qualle, torte, reiter, würfel]
- title: "Übung: Welt 2 komplett"
subtitle: "Alles aus der oberen Reihe"
drill: true
- number: 3
title: "Nach unten"
emoji: "🪸"
reward: seahorse
lessons:
- title: "Das N"
subtitle: "Zeigefinger rechts nach unten"
keys: [n]
- title: "N üben"
subtitle: "Die neue Taste festigen"
keys: [n]
- title: "Das M"
subtitle: "Zeigefinger rechts, neben dem N"
keys: [m]
- title: "M üben"
subtitle: "Die neue Taste festigen"
keys: [m]
- title: "Übung: N und M"
subtitle: "Die neuen Tasten festigen"
drill: true
- title: "Das G"
subtitle: "Zeigefinger links, in der Mitte"
keys: [g]
- title: "G üben"
subtitle: "Die neue Taste festigen"
keys: [g]
- title: "Das H"
subtitle: "Zeigefinger rechts, in der Mitte"
keys: [h]
- title: "H üben"
subtitle: "Die neue Taste festigen"
keys: [h]
- title: "Übung: G und H"
subtitle: "Die Mitte der Grundreihe"
drill: true
- title: "Kleine Wörter: N M G H"
subtitle: "Erste echte Wörter nach unten"
kind: fragments
words: [nase, nein, kind, wind, sonne, mama, mond, meer, maus, gut, gans, regen, hase, haus, hund, hupe]
- title: "Das C"
subtitle: "Mittelfinger links nach unten"
keys: [c]
- title: "C üben"
subtitle: "Die neue Taste festigen"
keys: [c]
- title: "Das V"
subtitle: "Zeigefinger links nach unten"
keys: [v]
- title: "V üben"
subtitle: "Die neue Taste festigen"
keys: [v]
- title: "Übung: C und V"
subtitle: "Nach unten greifen"
drill: true
- title: "Das B"
subtitle: "Zeigefinger links, neben dem V"
keys: [b]
- title: "B üben"
subtitle: "Die neue Taste festigen"
keys: [b]
- title: "Das Y"
subtitle: "Kleiner Finger links nach unten"
keys: [y]
- title: "Y üben"
subtitle: "Die neue Taste festigen"
keys: [y]
- title: "Übung: B und Y"
subtitle: "Ganz unten links"
drill: true
- title: "Kleine Wörter: C V B Y"
subtitle: "Noch mehr echte Wörter"
kind: fragments
words: [koch, milch, schule, chaos, vier, vase, voll, vater, baum, boot, bunt, brot, yoga, baby, typ, pony]
- title: "Das X"
subtitle: "Ringfinger links nach unten"
keys: [x]
- title: "X üben"
subtitle: "Die neue Taste festigen"
keys: [x]
- title: "Das Ä"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: [ä]
- title: "Ä üben"
subtitle: "Die neue Taste festigen"
keys: [ä]
- title: "Übung: X und Ä"
subtitle: "Die letzten beiden Tasten"
drill: true
- title: "Übung: alle Buchstaben"
subtitle: "Das ganze Alphabet"
kind: words
mode: race
words: [delfin, wasser, xylofon, bäume, vogel, qualle, muschel, tauchen]
- title: "Übung: Welt 3 komplett"
subtitle: "Alles aus der unteren Reihe"
drill: true
- number: 4
title: "Große Buchstaben"
emoji: "👑"
reward: turtle
lessons:
- title: "Umschalttaste rechts, Teil 1"
subtitle: "Große Buchstaben der linken Hand"
keys: ["⇧"]
kind: words
words: [Delfin, Wal, Fisch, Baum]
- title: "Umschalttaste rechts, Teil 2"
subtitle: "Noch mehr große Buchstaben"
kind: words
words: [Garten, Ente, Vogel, Riff]
- title: "Umschalttaste links, Teil 1"
subtitle: "Große Buchstaben der rechten Hand"
kind: words
words: [Haus, Kind, Mond, Nase]
- title: "Umschalttaste links, Teil 2"
subtitle: "Noch mehr große Buchstaben"
kind: words
words: [Lampe, Onkel, Uhr, Puppe]
- title: "Übung: Namen, Teil 1"
subtitle: "Namen fangen groß an"
kind: words
words: [Anna, Lena, Paul, Mia, Emil, Jonas, Tom, Lisa]
- title: "Übung: Namen, Teil 2"
subtitle: "Noch mehr Namen"
kind: words
mode: feed
words: [Ben, Nora, Finn, Ida, Max, Ella, Oskar, Greta]
- title: "Übung: große und kleine"
subtitle: "Beides gemischt"
kind: words
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"]
- title: "Übung: Welt 4 komplett"
subtitle: "Groß und klein zusammen"
kind: words
drill: true
words: [Delfin, Haus, Anna, Ben, "Das Meer", "Der Wal", Mond, Riff]
- number: 5
title: "Ganze Sätze"
emoji: "📖"
reward: pearlmussel
lessons:
- title: "Der Punkt"
subtitle: "Ringfinger rechts nach unten"
keys: ["."]
- title: "Punkt üben"
subtitle: "Die neue Taste festigen"
keys: ["."]
- title: "Erste Sätze mit Punkt"
subtitle: "Ein Satz, ein Punkt"
kind: sentences
words:
- "Das Meer ist tief."
- "Der Hund bellt."
- "Ich mag Kekse."
- "Die Sonne scheint."
- "Der Wal ist riesig."
- "Wir gehen baden."
- "Mama liest ein Buch."
- "Der Fisch schwimmt."
- "Heute ist es warm."
- "Ich habe einen Ball."
- "Die Welle ist hoch."
- "Papa kocht Suppe."
- title: "Das Komma"
subtitle: "Mittelfinger rechts nach unten"
keys: [","]
- title: "Komma üben"
subtitle: "Die neue Taste festigen"
keys: [","]
- title: "Sätze mit Komma"
subtitle: "Zwei Gedanken, ein Satz"
kind: sentences
mode: race
words:
- "Ich mag Wale, Delfine und Fische."
- "Erst lesen, dann tippen."
- "Es ist warm, also baden wir."
- "Rot, gelb und blau sind Farben."
- "Wenn es regnet, bleiben wir drinnen."
- "Der Delfin springt, taucht und spielt."
- "Morgen, sagt Papa, fahren wir los."
- "Eins, zwei, drei, vier."
- "Oma, Opa und ich gehen schwimmen."
- "Die Sonne scheint, das Meer glitzert."
- "Muscheln, Steine und Sand liegen am Strand."
- title: "Der Bindestrich"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: ["-"]
- title: "Bindestrich üben"
subtitle: "Die neue Taste festigen"
keys: ["-"]
- title: "Sätze mit Bindestrich"
subtitle: "Zwei Wörter, ein Strich"
kind: sentences
words:
- "Wir spielen mit dem Wasser-Ball."
- "Das ist ein Delfin-Baby."
- "Meine Ur-Oma kommt heute."
- "Wir bauen eine Sand-Burg."
- "Der Fisch-Schwarm ist riesig."
- "Ich trage mein T-Shirt."
- "Das Schwimm-Bad ist offen."
- "Die Bade-Hose ist nass."
- "Wir essen ein Eis-Hörnchen."
- "Das Segel-Boot ist blau."
- "Mein Lieblings-Tier ist der Delfin."
- title: "Fragezeichen und Ausrufezeichen"
subtitle: "Mit der Umschalttaste"
keys: ["ß", "1"]
- title: "Fragezeichen und Ausrufezeichen üben"
subtitle: "Die neuen Tasten festigen"
keys: ["ß", "1"]
- title: "Fragen und Rufe"
subtitle: "Wie klingt ein Satz?"
kind: sentences
words:
- "Wo ist der Delfin?"
- "Das war toll!"
- "Wie geht es dir?"
- "Pass auf!"
- "Kommst du mit?"
- "Der Wal ist so groß!"
- "Hast du Hunger?"
- "Hurra, Ferien!"
- "Was schwimmt da?"
- "Schau mal, ein Hai!"
- "Wie tief ist das Meer?"
- "Wir haben es geschafft!"
- title: "Übung: ganze Sätze"
subtitle: "Alles zusammen"
kind: sentences
drill: true
mode: race
words:
- "Der Delfin schwimmt sehr schnell."
- "Wo ist mein Boot?"
- "Ich tippe jetzt mit zehn Fingern!"
- "Das Meer ist blau, tief und kalt."
- "Kannst du das auch?"
- "Wir bauen eine Sand-Burg am Strand."
- "Die Möwe fliegt über das Wasser."
- "Oma, Opa und ich gehen schwimmen."
- "Das ist ja super!"
- "Wie heißt der große Wal?"
- "Im Riff wohnen bunte Fische."
- "Der Krake hat acht Arme."

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => {
@@ -125,15 +125,52 @@ describe("LESSONS", () => {
}
});
it("only offers word modes where words exist", () => {
it("plays a mode that actually fits its kind", () => {
for (const lesson of LESSONS) {
const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
expect(wordModes).toBe(lesson.words.length > 0);
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(lesson.primaryMode);
for (const mode of lesson.bonusModes) {
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(mode);
}
}
});
it("always offers the pressure-free pearl-diving mode", () => {
for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
it("never plays the same arcade game twice in a row", () => {
const arcade = LESSONS.filter((lesson) => lesson.kind === "letters");
for (let i = 1; i < arcade.length; i++) {
expect(arcade[i]!.primaryMode, `${arcade[i]!.number}: ${arcade[i]!.title}`).not.toBe(arcade[i - 1]!.primaryMode);
}
});
it("offers every eligible mode this lesson isn't gated on, as a bonus", () => {
for (const lesson of LESSONS) {
const expected = ELIGIBLE_MODES[lesson.kind].filter(
(mode) => (mode === "feed" || mode === "race") && mode !== lesson.primaryMode,
);
expect([...lesson.bonusModes].sort()).toEqual([...expected].sort());
}
});
it("mixes the games instead of always diving - feed and race take a turn as the required mode too", () => {
const words = LESSONS.filter((l) => l.kind === "words");
const sentences = LESSONS.filter((l) => l.kind === "sentences");
expect(words.some((l) => l.primaryMode === "feed")).toBe(true);
expect(words.some((l) => l.primaryMode === "race")).toBe(true);
expect(sentences.some((l) => l.primaryMode === "race")).toBe(true);
});
it("distinguishes a mixed round from a drill - both have no new keys, only one is a review", () => {
const mixed = LESSONS.filter((lesson) => lesson.emphasis === "mixed");
expect(mixed.length).toBeGreaterThan(0);
for (const lesson of mixed) {
expect(lesson.newKeys).toEqual([]);
expect(lesson.isDrill).toBe(false);
}
});
it("gives every isolated round something new to drill", () => {
for (const lesson of LESSONS) {
if (lesson.emphasis === "isolated") expect(lesson.newKeys.length).toBeGreaterThan(0);
}
});
it("teaches capitals only once shift exists", () => {
@@ -154,7 +191,7 @@ describe("LESSONS", () => {
it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(drills.length).toBeGreaterThanOrEqual(12);
expect(drills.length).toBeGreaterThanOrEqual(15);
// A drill never introduces anything, and always has something to practise.
for (const lesson of drills) {
expect(lesson.newKeys).toEqual([]);
@@ -162,8 +199,8 @@ describe("LESSONS", () => {
}
});
it("is long enough to be a real course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
it("is long enough to be a real, Duolingo-length course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(80);
expect(WORLDS.length).toBe(5);
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { PATH_AMPLITUDE, pathD, pointFor, xOffsetFor } from "../lessonPath";
describe("xOffsetFor", () => {
it("starts centred", () => {
expect(xOffsetFor(0)).toBeCloseTo(0);
});
it("never strays further than the amplitude", () => {
for (let i = 0; i < 40; i++) {
expect(Math.abs(xOffsetFor(i))).toBeLessThanOrEqual(PATH_AMPLITUDE + 1e-9);
}
});
it("is deterministic", () => {
expect(xOffsetFor(5)).toBe(xOffsetFor(5));
});
});
describe("pointFor", () => {
it("places nodes strictly further down as the index grows", () => {
let previousY = -Infinity;
for (let i = 0; i < 10; i++) {
const point = pointFor(i);
expect(point.y).toBeGreaterThan(previousY);
previousY = point.y;
}
});
});
describe("pathD", () => {
it("draws nothing for fewer than two points", () => {
expect(pathD([])).toBe("");
expect(pathD([{ x: 0, y: 0 }])).toBe("");
});
it("starts at the first point and mentions every point", () => {
const points = [pointFor(0), pointFor(1), pointFor(2)];
const d = pathD(points);
expect(d.startsWith(`M ${points[0]!.x} ${points[0]!.y}`)).toBe(true);
for (const point of points) expect(d).toContain(String(point.x));
});
});

View File

@@ -150,7 +150,7 @@ describe("migrate", () => {
it("returns a fresh profile for anything unusable", () => {
for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) {
const progress = migrate(raw);
expect(progress.version).toBe(1);
expect(progress.version).toBe(2);
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
}
});
@@ -177,6 +177,33 @@ describe("migrate", () => {
broken.lessons[FIRST_LESSON_ID] = { ...broken.lessons[FIRST_LESSON_ID]!, unlocked: false };
expect(migrate(broken).lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
});
it("restarts the lesson path from an old version, but keeps the aquarium, pearls and streak", () => {
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum,
// so there is nothing sensible to remap them onto - she starts the path over, but
// does not lose what she already earned.
const old = {
version: 1,
lessons: { l09: { unlocked: true, runs: 3, bestStars: 3, bestAnimal: "orca", bestPoints: 999, ghost: null } },
keyStats: { a: { ema: 400, attempts: 10, errors: 1 } },
pearls: 42,
aquarium: ["clownfish", "octopus"],
streak: { days: 5, lastPlayed: "2024-01-01" },
settings: { sound: false, keyboardHint: "off" },
};
const restored = migrate(JSON.parse(JSON.stringify(old)));
expect(restored.version).toBe(2);
// "l09" exists in the new curriculum too, just as a different lesson - its old
// three-star, level-99 progress must not carry over onto whatever l09 means now.
expect(restored.lessons["l09"]).toEqual({ unlocked: false, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null });
expect(restored.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
expect(Object.values(restored.lessons).filter((l) => l.unlocked)).toHaveLength(1);
expect(restored.pearls).toBe(42);
expect(restored.aquarium).toEqual(["clownfish", "octopus"]);
expect(restored.keyStats["a"]?.attempts).toBe(10);
expect(restored.streak).toEqual({ days: 5, lastPlayed: "2024-01-01" });
expect(restored.settings).toEqual({ sound: false, keyboardHint: "off" });
});
});
describe("focusKeyFor", () => {

View File

@@ -1,41 +1,36 @@
/** The lesson plan: 48 lessons in 5 worlds.
/** The lesson plan: content lives in data/curriculum.yaml, this file only derives and
* validates it.
*
* The shape follows what every serious ten-finger course does - start on the
* home row `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S,
* R, A, T, D, H, U, L …), and drill everything learned so far each time. TIPP10's
* German course is the same idea in 18 lessons.
* The shape follows what every serious ten-finger course does - start on the home row
* `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S, R, A, T, D, H,
* U, L …), and drill everything learned so far each time. TIPP10's German course is the
* same idea in 18 lessons.
*
* Where this deviates, it deviates for the age, and the deviation is *pace*. A
* six-year-old gets:
* Where this deviates, it deviates for the age and for the Duolingo-style path this app
* wants: every new key gets an **isolated** round (heavy weight, alone) before a
* **mixed** round (lighter weight, blended with everything learned so far) - the same
* new-key / mixed-key / real-content progression typing.com and typingstudy.com use,
* just written as short rounds a six-year-old can climb one at a time. A drill lesson
* follows every couple of keys, and periodically a **fragments** round turns the
* newly-active keys into real short German words - the bridge between drilling letters
* and typing whole words or sentences.
*
* - **one new key per lesson** from world 2 onward (world 1 pairs the same finger on
* both hands, which is one motion, not two);
* - **a drill lesson after every pair of new keys** - no new keys at all, just the
* ones she has. Consolidation is where typing actually becomes automatic, and a
* course that only ever moves forward never gives it room;
* - **short rounds**, growing from twelve characters in world 1 to whole sentences in
* world 5.
*
* That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
* steps between are small enough to climb.
*
* `activeKeys` is cumulative on purpose: lesson 30 still drills `f`, or the first
* `activeKeys` is cumulative on purpose: lesson 80 still drills `f`, or the first
* lessons rot while the last ones are learned. */
import { parse } from "yaml";
import curriculumYaml from "../data/curriculum.yaml?raw";
import type { CreatureId } from "./aquarium";
import { HOME_ROW, SPACE_KEY } from "./fingers";
export type ModeId =
| "dive"
| "bubbles"
| "jellyfish"
| "feed"
| "race"
| "pearls";
/** What a lesson's targets are made of. `fragments` is short real syllables/mini-words -
* the bridge between drilling isolated letters and typing whole words or sentences. */
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
/** What a lesson's targets are made of. Decides which modes make sense: jellyfish mode
* cannot show a sentence, and feed mode cannot put one on a fish. */
export type LessonKind = "letters" | "words" | "sentences";
/** Which game a lesson can be played as. `pearls` is gone - every other mode measures
* something, and that one deliberately didn't. */
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
export interface Lesson {
id: string;
@@ -44,16 +39,31 @@ export interface Lesson {
title: string;
/** What this lesson is about, in words a six-year-old hears read aloud. */
subtitle: string;
/** The keys introduced here - what the generator weights toward. Empty for a drill. */
kind: LessonKind;
/** The keys that became active *this* lesson - empty for a mixed round or a drill.
* Used to size how gently a round starts (see World 1's mirrored pairs) and to check
* the course never introduces more than it should. */
newKeys: readonly string[];
/** The keys this round is about, whether or not they are new - what the generator
* over-represents. Equal to `newKeys` for an isolated round, the same keys again
* (already active) for the mixed round that follows it, empty for a drill. */
spotlightKeys: readonly string[];
/** Isolated: newly-active keys, drilled alone. Mixed: the same keys, blended with
* everything else. `null` for a drill or for anything that isn't a letters round. */
emphasis: "isolated" | "mixed" | null;
/** Everything typable in this lesson, cumulative. */
activeKeys: readonly string[];
/** Which game modes this lesson offers, in carousel order. */
modes: readonly ModeId[];
/** Real German words (or sentences) for the word modes. */
/** The one mode that gates progress this round - passing a run in this mode is what
* can unlock the next lesson. Mostly `dive` for non-letters kinds, but `feed`/`race`
* take a periodic turn so the games stay mixed, not just `dive` end to end. */
primaryMode: ModeId;
/** Extra replays, offered on the result sheet once this lesson is passed. Never gate
* anything - they exist so a favourite game can be played again. */
bonusModes: readonly ModeId[];
/** Real German words, fragments or sentences for the non-letters kinds. */
words: readonly string[];
kind: LessonKind;
/** True for a consolidation lesson - no new keys, just practice. */
/** True for a pure review round - no fresh content, just practice. Independent of
* `newKeys`: a mixed round also has no new keys but is not a drill. */
isDrill: boolean;
/** How long one run is. Grows with the curriculum - see `lengthFor`. */
chunks: number;
@@ -70,233 +80,168 @@ export interface World {
/** A world's creature is a pet that stays - a drawing that swims behind every screen
* from then on. An animal in grading.ts is a speed trophy that changes, and is an
* emoji. lib/aquarium.ts has why the two are kept in different visual languages. The
* pets grow with the worlds: a small fish first, the pearl mussel - the pearls' own
* home - last. */
export const WORLDS: readonly World[] = [
{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
{ number: 2, title: "Nach oben", emoji: "🌊", reward: "octopus" },
{ number: 3, title: "Nach unten", emoji: "🪸", reward: "seahorse" },
{ number: 4, title: "Große Buchstaben", emoji: "👑", reward: "turtle" },
{ number: 5, title: "Ganze Sätze", emoji: "📖", reward: "pearlmussel" },
];
* emoji. lib/aquarium.ts has why the two are kept in different visual languages. */
const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"];
/** Letters only - no words can be spelled yet. */
const LETTER_MODES: readonly ModeId[] = ["dive", "bubbles", "jellyfish", "pearls"];
/** Real words exist: the word modes and the race join in. */
const WORD_MODES: readonly ModeId[] = ["dive", "feed", "bubbles", "race", "jellyfish", "pearls"];
/** Sentences do not fit on a bubble or a fish. */
const SENTENCE_MODES: readonly ModeId[] = ["dive", "race", "pearls"];
/** Which modes make sense for a kind. Letters rounds are single keys, so only the
* arcade modes fit; fragments/words can be fed to a fish; only words and sentences are
* long enough for a race. */
export const ELIGIBLE_MODES: Record<LessonKind, readonly ModeId[]> = {
letters: ["bubbles", "jellyfish"],
fragments: ["dive", "feed"],
words: ["dive", "feed", "race"],
sentences: ["dive", "race"],
};
/** Line length by world - a full block of text per round, at least five lines of it.
*
* Deliberately five times what the first version used. Twelve characters was long
* enough to *finish*, which is what the first day needs, but far too short to build
* anything: a round that ends before the hands settle measures reaction time rather
* than typing, and the score bounces around so much that getting better is invisible.
* Sixty-plus characters is long enough for a rhythm to appear and for the characters
* per minute to mean something.
*
* World 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
/** Line length by world and kind - a full block of text per round. Letters rounds are
* never rendered through `dive` any more (they are bubbles/jellyfish), but the length is
* still computed for type uniformity and so the "still starts gentler than it ends"
* shape is preserved if a letters round is ever asked for one. */
function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } {
if (world === 1) return kind === "words" ? { chunks: 25, chunkSize: 3 } : { chunks: 24, chunkSize: 3 }; // 72 characters
if (kind === "fragments") return { chunks: 16, chunkSize: 4 }; // 64
if (kind === "sentences") return { chunks: 10, chunkSize: 4 }; // ten whole sentences
if (kind === "words") return { chunks: 25, chunkSize: 4 };
// kind === "letters"
if (world === 1) return { chunks: 24, chunkSize: 3 }; // 72 characters
if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100
if (world === 3) return { chunks: 30, chunkSize: 4 }; // 120
if (world === 4) return { chunks: 25, chunkSize: 4 }; // 25 words
return { chunks: 10, chunkSize: 4 }; // world 5: ten whole sentences
return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward
}
interface PlanEntry {
world: number;
interface YamlLesson {
title: string;
subtitle: string;
/** Empty marks a drill - consolidation, no new keys. */
newKeys: readonly string[];
words?: readonly string[];
kind?: LessonKind;
keys?: readonly string[];
drill?: boolean;
mode?: ModeId;
words?: readonly string[];
}
/** The plan. Everything else is derived from this, so a curriculum change is one edit. */
const PLAN: readonly PlanEntry[] = [
// ---------------------------------------------------------------- World 1 --
// Two keys per lesson, always the same finger on each hand - one motion, mirrored.
// F and J first because they carry the tactile bumps: they are the two keys a child
// can find without looking, and every other key is taught as an offset from them.
{ world: 1, title: "F und J", subtitle: "Die Zeigefinger - die Tasten mit den Punkten", newKeys: ["f", "j"] },
{ world: 1, title: "D und K", subtitle: "Die Mittelfinger", newKeys: ["d", "k"] },
{ world: 1, title: "Übung: F J D K", subtitle: "Die vier Tasten zusammen", newKeys: [] },
{ world: 1, title: "S und L", subtitle: "Die Ringfinger", newKeys: ["s", "l"] },
{ world: 1, title: "Übung: sechs Tasten", subtitle: "Alles bisher zusammen", newKeys: [] },
{ world: 1, title: "A und Ö", subtitle: "Die kleinen Finger", newKeys: ["a", "ö"] },
{ world: 1, title: "Übung: die Grundstellung", subtitle: "Alle acht Finger", newKeys: [],
words: [
"da", "das", "dass", "ja", "als", "all", "fall", "falls", "lass",
"aal", "aas", "as", "ass", "fass", "saal", "kalk", "salsa", "jass",
"asa", "sas", "sad", "sal", "dal", "fad", "fal", "jak", "jas", "kal",
"kas", "lak", "las", "lad", "dasa", "sala", "kala", "jala", "fasa",
"daka", "kasa", "salla", "dalla", "jassa", "fassa", "kalla", "falla",
"salak", "dalas", "jasal", "kalas",
"lö", "döl", "söl", "jöl", "köl", "löl",
] },
{ world: 1, title: "Die Leertaste", subtitle: "Der Daumen kommt dazu", newKeys: [SPACE_KEY] },
interface YamlWorld {
number: number;
title: string;
emoji: string;
reward: CreatureId;
lessons: readonly YamlLesson[];
}
// ---------------------------------------------------------------- World 2 --
// One key per lesson from here on, an Übung after every two.
{ world: 2, title: "Das E", subtitle: "Mittelfinger links nach oben", newKeys: ["e"],
words: ["elf", "alle", "esel", "see", "keks", "fell"] },
{ world: 2, title: "Das I", subtitle: "Mittelfinger rechts nach oben", newKeys: ["i"],
words: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
{ world: 2, title: "Übung: E und I", subtitle: "Die neuen Tasten festigen", newKeys: [],
words: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
{ world: 2, title: "Das R", subtitle: "Zeigefinger links nach oben", newKeys: ["r"],
words: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
{ world: 2, title: "Das U", subtitle: "Zeigefinger rechts nach oben", newKeys: ["u"],
words: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
{ world: 2, title: "Übung: R und U", subtitle: "Die Zeigefinger nach oben", newKeys: [],
words: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
{ world: 2, title: "Das T", subtitle: "Zeigefinger links weit nach oben", newKeys: ["t"],
words: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
{ world: 2, title: "Das Z", subtitle: "Zeigefinger rechts weit nach oben", newKeys: ["z"],
words: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
{ world: 2, title: "Übung: T und Z", subtitle: "Weit nach oben greifen", newKeys: [],
words: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
{ world: 2, title: "Das O", subtitle: "Ringfinger rechts nach oben", newKeys: ["o"],
words: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
{ world: 2, title: "Das W", subtitle: "Ringfinger links nach oben", newKeys: ["w"],
words: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
{ world: 2, title: "Übung: W und O", subtitle: "Die Ringfinger nach oben", newKeys: [],
words: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
{ world: 2, title: "Das P", subtitle: "Kleiner Finger rechts nach oben", newKeys: ["p"],
words: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
{ world: 2, title: "Das Q", subtitle: "Kleiner Finger links nach oben", newKeys: ["q"],
words: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
{ world: 2, title: "Das Ü", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ü"],
words: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
{ world: 2, title: "Übung: die obere Reihe", subtitle: "Die ganze Reihe zusammen", newKeys: [],
words: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
interface YamlRoot {
worlds: readonly YamlWorld[];
}
// ---------------------------------------------------------------- World 3 --
{ world: 3, title: "Das N", subtitle: "Zeigefinger rechts nach unten", newKeys: ["n"],
words: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
{ world: 3, title: "Das M", subtitle: "Zeigefinger rechts, neben dem N", newKeys: ["m"],
words: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
{ world: 3, title: "Übung: N und M", subtitle: "Die neuen Tasten festigen", newKeys: [],
words: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
{ world: 3, title: "Das G", subtitle: "Zeigefinger links, in der Mitte", newKeys: ["g"],
words: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
{ world: 3, title: "Das H", subtitle: "Zeigefinger rechts, in der Mitte", newKeys: ["h"],
words: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
{ world: 3, title: "Übung: G und H", subtitle: "Die Mitte der Grundreihe", newKeys: [],
words: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
{ world: 3, title: "Das C", subtitle: "Mittelfinger links nach unten", newKeys: ["c"],
words: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
{ world: 3, title: "Das V", subtitle: "Zeigefinger links nach unten", newKeys: ["v"],
words: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
{ world: 3, title: "Übung: C und V", subtitle: "Nach unten greifen", newKeys: [],
words: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
{ world: 3, title: "Das B", subtitle: "Zeigefinger links, neben dem V", newKeys: ["b"],
words: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
{ world: 3, title: "Das Y", subtitle: "Kleiner Finger links nach unten", newKeys: ["y"],
words: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
{ world: 3, title: "Übung: B und Y", subtitle: "Ganz unten links", newKeys: [],
words: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
{ world: 3, title: "Das X", subtitle: "Ringfinger links nach unten", newKeys: ["x"],
words: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
{ world: 3, title: "Das Ä", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ä"],
words: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
{ world: 3, title: "Übung: alle Buchstaben", subtitle: "Das ganze Alphabet", newKeys: [],
words: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
/** Turns a YAML typo into a clear startup error instead of a silently wrong lesson. */
function validatePlan(worlds: readonly YamlWorld[]): void {
if (worlds.length !== CREATURE_IDS.length) {
throw new Error(`curriculum.yaml: expected ${CREATURE_IDS.length} worlds, found ${worlds.length}`);
}
const rewards = new Set<CreatureId>();
for (const world of worlds) {
if (rewards.has(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} reuses reward "${world.reward}"`);
if (!CREATURE_IDS.includes(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} has an unknown reward "${world.reward}"`);
rewards.add(world.reward);
// ---------------------------------------------------------------- World 4 --
{ world: 4, title: "Umschalttaste rechts", subtitle: "Große Buchstaben der linken Hand", newKeys: ["⇧"],
words: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
"Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] },
{ world: 4, title: "Umschalttaste links", subtitle: "Große Buchstaben der rechten Hand", newKeys: [],
words: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
"Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] },
{ world: 4, title: "Übung: Namen", subtitle: "Namen fangen groß an", newKeys: [],
words: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
"Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] },
{ world: 4, title: "Übung: große und kleine", subtitle: "Beides gemischt", newKeys: [],
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel",
"Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] },
world.lessons.forEach((entry, i) => {
const where = `world ${world.number}, lesson ${i + 1} (${entry.title})`;
const words = entry.words ?? [];
const kind: LessonKind = entry.kind ?? "letters";
if (words.length > 0 && entry.kind === undefined) {
throw new Error(`${where}: has words but no explicit kind`);
}
if (kind === "letters" && words.length > 0) throw new Error(`${where}: kind "letters" cannot have words`);
if (kind !== "letters" && words.length === 0) throw new Error(`${where}: kind "${kind}" needs a non-empty words list`);
if (entry.drill && entry.keys?.length) throw new Error(`${where}: a drill cannot also introduce keys`);
if ((entry.keys?.length ?? 0) > 2) throw new Error(`${where}: at most two keys per lesson`);
if (entry.mode && !ELIGIBLE_MODES[kind].includes(entry.mode)) {
throw new Error(`${where}: mode "${entry.mode}" does not fit kind "${kind}"`);
}
});
}
}
// ---------------------------------------------------------------- World 5 --
{ world: 5, title: "Der Punkt", subtitle: "Ringfinger rechts nach unten", newKeys: ["."], kind: "sentences",
words: [
"Das Meer ist tief.", "Der Hund bellt.", "Ich mag Kekse.", "Die Sonne scheint.",
"Der Wal ist riesig.", "Wir gehen baden.", "Mama liest ein Buch.", "Der Fisch schwimmt.",
"Heute ist es warm.", "Ich habe einen Ball.", "Die Welle ist hoch.", "Papa kocht Suppe.",
] },
{ world: 5, title: "Das Komma", subtitle: "Mittelfinger rechts nach unten", newKeys: [","], kind: "sentences",
words: [
"Ich mag Wale, Delfine und Fische.", "Erst lesen, dann tippen.", "Es ist warm, also baden wir.",
"Rot, gelb und blau sind Farben.", "Wenn es regnet, bleiben wir drinnen.",
"Der Delfin springt, taucht und spielt.", "Morgen, sagt Papa, fahren wir los.",
"Eins, zwei, drei, vier.", "Oma, Opa und ich gehen schwimmen.",
"Die Sonne scheint, das Meer glitzert.", "Muscheln, Steine und Sand liegen am Strand.",
] },
{ world: 5, title: "Der Bindestrich", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["-"], kind: "sentences",
words: [
"Wir spielen mit dem Wasser-Ball.", "Das ist ein Delfin-Baby.", "Meine Ur-Oma kommt heute.",
"Wir bauen eine Sand-Burg.", "Der Fisch-Schwarm ist riesig.", "Ich trage mein T-Shirt.",
"Das Schwimm-Bad ist offen.", "Die Bade-Hose ist nass.", "Wir essen ein Eis-Hörnchen.",
"Das Segel-Boot ist blau.", "Mein Lieblings-Tier ist der Delfin.",
] },
{ world: 5, title: "Fragezeichen und Ausrufezeichen", subtitle: "Mit der Umschalttaste", newKeys: ["ß", "1"], kind: "sentences",
words: [
"Wo ist der Delfin?", "Das war toll!", "Wie geht es dir?", "Pass auf!",
"Kommst du mit?", "Der Wal ist so groß!", "Hast du Hunger?", "Hurra, Ferien!",
"Was schwimmt da?", "Schau mal, ein Hai!", "Wie tief ist das Meer?", "Wir haben es geschafft!",
] },
{ world: 5, title: "Übung: ganze Sätze", subtitle: "Alles zusammen", newKeys: [], kind: "sentences",
words: [
"Der Delfin schwimmt sehr schnell.", "Wo ist mein Boot?", "Ich tippe jetzt mit zehn Fingern!",
"Das Meer ist blau, tief und kalt.", "Kannst du das auch?", "Wir bauen eine Sand-Burg am Strand.",
"Die Möwe fliegt über das Wasser.", "Oma, Opa und ich gehen schwimmen.", "Das ist ja super!",
"Wie heißt der große Wal?", "Im Riff wohnen bunte Fische.", "Der Krake hat acht Arme.",
] },
];
function loadPlan(): readonly YamlWorld[] {
const root = parse(curriculumYaml) as YamlRoot;
validatePlan(root.worlds);
return root.worlds;
}
function buildLessons(): Lesson[] {
function buildLessons(worlds: readonly YamlWorld[]): Lesson[] {
const lessons: Lesson[] = [];
const active = new Set<string>();
const seenBefore = new Set<string>();
// Letters rounds alternate bubbles/jellyfish across the *whole* course, not per world
// or per pair - a fragments/words lesson in between does not reset the count, so the
// arcade game never repeats twice in a row even across a consolidation gap.
let lastArcade: "bubbles" | "jellyfish" = "jellyfish"; // so lesson 1 opens on bubbles
PLAN.forEach((entry, i) => {
for (const key of entry.newKeys) active.add(key);
for (const world of worlds) {
for (const entry of world.lessons) {
const keys = entry.keys ?? [];
const newKeys = keys.filter((key) => !seenBefore.has(key));
for (const key of keys) {
seenBefore.add(key);
active.add(key);
}
// The space-bar lesson is where the whole home row comes together, so it
// activates every home key - a belt-and-braces guarantee that the four finger-pair
// lessons before it really did cover all eight.
if (entry.newKeys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
// activates every home key - belt-and-braces confirmation that the four
// finger-pair lessons before it really did cover all eight.
if (keys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
// Shift is not a character the generator can emit - the capitals in the word list
// are what teaches it - so it never enters activeKeys. The question-mark lesson
// reaches its marks with Shift too, so ß and 1 are active as *keys* even though the
// characters that appear are ? and !.
// Shift is not a character the generator can emit - the capitals in the word
// list are what teaches it - so it never enters activeKeys.
const activeKeys = [...active].filter((key) => key !== "⇧").sort();
const words = entry.words ?? [];
const kind: LessonKind = entry.kind ?? (words.length > 0 ? "words" : "letters");
const kind: LessonKind = entry.kind ?? "letters";
const isDrill = entry.drill === true;
const emphasis: Lesson["emphasis"] =
kind !== "letters" || isDrill ? null : newKeys.length > 0 ? "isolated" : "mixed";
let primaryMode: ModeId;
if (kind === "letters") {
primaryMode = entry.mode ?? (lastArcade === "bubbles" ? "jellyfish" : "bubbles");
lastArcade = primaryMode as "bubbles" | "jellyfish";
} else {
primaryMode = entry.mode ?? "dive";
}
// Bonus replays are only ever feed/race - dive is the plain default, not a treat
// worth offering separately, and bubbles/jellyfish already alternate on their own.
const bonusModes = ELIGIBLE_MODES[kind].filter(
(mode) => (mode === "feed" || mode === "race") && mode !== primaryMode,
);
lessons.push({
id: `l${String(i + 1).padStart(2, "0")}`,
world: entry.world,
number: i + 1,
id: `l${String(lessons.length + 1).padStart(2, "0")}`,
world: world.number,
number: lessons.length + 1,
title: entry.title,
subtitle: entry.subtitle,
newKeys: entry.newKeys,
activeKeys,
modes: kind === "sentences" ? SENTENCE_MODES : kind === "words" ? WORD_MODES : LETTER_MODES,
words,
kind,
isDrill: entry.newKeys.length === 0,
...lengthFor(entry.world, kind),
});
newKeys,
spotlightKeys: kind === "letters" && !isDrill ? keys : [],
emphasis,
activeKeys,
primaryMode,
bonusModes,
words,
isDrill,
...lengthFor(world.number, kind),
});
}
}
return lessons;
}
export const LESSONS: readonly Lesson[] = buildLessons();
const PLAN = loadPlan();
export const WORLDS: readonly World[] = PLAN.map((world) => ({
number: world.number,
title: world.title,
emoji: world.emoji,
reward: world.reward,
}));
export const LESSONS: readonly Lesson[] = buildLessons(PLAN);
const BY_ID = new Map(LESSONS.map((lesson) => [lesson.id, lesson]));

View File

@@ -38,29 +38,33 @@ export interface LineOptions {
chunkSize?: number;
/** The key to over-represent, if any. */
focusKey?: string | null;
/** The keys this lesson introduces. They take the majority of the line; everything
/** The keys this lesson spotlights. They take the majority of the line; everything
* learned earlier keeps appearing as review. */
newKeys?: readonly string[];
/** How much of the line the spotlighted keys should take, in place of the default
* (see `NEW_KEY_SHARE`). An isolated round wants this high; a mixed round - the same
* keys again, but blended with everything else - wants it lower. */
newKeyShare?: number;
}
/** The most of a line one key may ever occupy. Above this it stops being practice and
* starts being a stutter - and on a small key set it starves the other fingers. */
const MAX_FOCUS_SHARE = 0.3;
/** How much of a line the lesson's *new* keys should take. The rest is review of
* everything learned so far, which is what stops the early lessons rotting while the
* late ones are learned.
/** How much of a line the lesson's spotlighted keys should take by default. The rest is
* review of everything learned so far, which is what stops the early lessons rotting
* while the late ones are learned.
*
* Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
* spent half the line on the left hand it had already taught - which is not what a
* lesson called "the right hand" should drill. */
const NEW_KEY_SHARE = 0.6;
/** Copies of each new key needed to reach `NEW_KEY_SHARE` of the pool, clamped so a
* lesson with one new key and many old ones does not bury the review entirely. */
function newKeyCopies(newCount: number, oldCount: number): number {
/** Copies of each spotlighted key needed to reach `share` of the pool, clamped so a
* lesson with one spotlighted key and many old ones does not bury the review entirely. */
function newKeyCopies(newCount: number, oldCount: number, share: number): number {
if (newCount === 0 || oldCount === 0) return 1;
const exact = (NEW_KEY_SHARE * oldCount) / (newCount * (1 - NEW_KEY_SHARE));
const exact = (share * oldCount) / (newCount * (1 - share));
return Math.max(1, Math.min(6, Math.round(exact)));
}
@@ -81,6 +85,7 @@ function weighted(
activeKeys: readonly string[],
focusKey: string | null | undefined,
newKeys: readonly string[] = [],
newKeyShare: number = NEW_KEY_SHARE,
): string[] {
const letters = activeKeys.filter((key) => key !== " ");
if (letters.length === 0) return [];
@@ -91,7 +96,7 @@ function weighted(
const pool = [...letters];
if (newActive.length > 0 && oldActive.length > 0) {
const copies = newKeyCopies(newActive.length, oldActive.length);
const copies = newKeyCopies(newActive.length, oldActive.length, newKeyShare);
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
}
@@ -137,8 +142,8 @@ export function drillChunks(
rng: Rng,
options: LineOptions = {},
): string[] {
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [] } = options;
const pool = weighted(activeKeys, focusKey, newKeys);
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [], newKeyShare } = options;
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
const letters = bagDraw(pool, chunks * chunkSize, rng);
@@ -225,8 +230,9 @@ export function letterStream(
count: number,
focusKey?: string | null,
newKeys: readonly string[] = [],
newKeyShare?: number,
): string[] {
const pool = weighted(activeKeys, focusKey, newKeys);
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
return bagDraw(pool, count, rng);
}

View File

@@ -0,0 +1,45 @@
/** The geometry of the zigzag lesson path - kept pure and out of the component so it can
* be tested the way `aquarium.ts`'s swim physics are. */
/** How many nodes make one full left-to-right-to-left swing. */
export const PATH_PERIOD = 6;
/** The furthest a node strays from the centre line, in px. */
export const PATH_AMPLITUDE = 110;
/** Vertical spacing between two nodes, in px. */
export const NODE_SPACING = 108;
/** Horizontal offset for the nth node of a world's path, centred on 0. A sine wave
* rather than a zigzag of straight segments, so the path reads as one smooth ribbon
* instead of a jagged staircase. */
export function xOffsetFor(indexInWorld: number): number {
return Math.sin((indexInWorld / PATH_PERIOD) * 2 * Math.PI) * PATH_AMPLITUDE;
}
export interface PathPoint {
x: number;
y: number;
}
/** The centre of the nth node, for both its own placement and the connector line. */
export function pointFor(indexInWorld: number): PathPoint {
return { x: xOffsetFor(indexInWorld), y: indexInWorld * NODE_SPACING + NODE_SPACING / 2 };
}
/** An SVG path string threading every node centre with a smooth curve - a straight
* polyline through a sine wave looks faceted; a vertical Bezier through each segment
* does not. Empty/one-point paths draw nothing, which is fine: a one-lesson world needs
* no connector. */
export function pathD(points: readonly PathPoint[]): string {
if (points.length < 2) return "";
const [first, ...rest] = points;
let d = `M ${first!.x} ${first!.y}`;
for (let i = 0; i < rest.length; i++) {
const from = points[i]!;
const to = rest[i]!;
const midY = (from.y + to.y) / 2;
d += ` C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`;
}
return d;
}

View File

@@ -0,0 +1,12 @@
/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
* lesson map's mode badge. */
import type { ModeId } from "./curriculum";
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" },
};

View File

@@ -46,7 +46,7 @@ export interface Settings {
}
export interface Progress {
version: 1;
version: 2;
lessons: Record<string, LessonProgress>;
keyStats: Record<string, KeyStat>;
pearls: number;
@@ -66,7 +66,7 @@ export function freshProgress(): Progress {
lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
}
return {
version: 1,
version: 2,
lessons,
keyStats: {},
pearls: 0,
@@ -76,18 +76,43 @@ export function freshProgress(): Progress {
};
}
/** Everything that survives a curriculum rebuild - the pets, the currency, the per-key
* stats and the streak - pulled defensively off whatever shape was stored, old or new. */
function carryForward(stored: Record<string, unknown>, fresh: Progress): Omit<Progress, "version" | "lessons"> {
return {
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? (stored.keyStats as Progress["keyStats"]) : {},
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
streak:
typeof stored.streak === "object" && stored.streak !== null
? {
days: (stored.streak as Progress["streak"]).days ?? 0,
lastPlayed: (stored.streak as Progress["streak"]).lastPlayed ?? null,
}
: fresh.streak,
settings: { ...fresh.settings, ...((stored.settings as Partial<Settings>) ?? {}) },
};
}
/** Bring any stored value up to the current shape. Anything unrecognisable is thrown
* away rather than trusted - a half-valid Progress would crash the lesson map, and a
* fresh one merely means starting over. */
export function migrate(raw: unknown): Progress {
const fresh = freshProgress();
if (typeof raw !== "object" || raw === null) return fresh;
const stored = raw as Partial<Progress>;
if (stored.version !== 1) return fresh;
const stored = raw as Record<string, unknown>;
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum -
// "l09" used to be "Das E" and might be anything now - so there is nothing sensible to
// remap. She keeps her aquarium, pearls, key stats and streak, and starts the (longer,
// gentler) path over from the first lesson.
if (stored.version !== 2) {
return { version: 2, lessons: fresh.lessons, ...carryForward(stored, fresh) };
}
const lessons = { ...fresh.lessons };
if (typeof stored.lessons === "object" && stored.lessons !== null) {
for (const [id, value] of Object.entries(stored.lessons)) {
for (const [id, value] of Object.entries(stored.lessons as Record<string, unknown>)) {
// Lessons that no longer exist in the curriculum are dropped silently.
if (!(id in lessons) || typeof value !== "object" || value === null) continue;
lessons[id] = { ...emptyLesson(false), ...value };
@@ -96,18 +121,7 @@ export function migrate(raw: unknown): Progress {
// The first lesson is unlocked by definition; a save that says otherwise is wrong.
lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true };
return {
version: 1,
lessons,
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
streak:
typeof stored.streak === "object" && stored.streak !== null
? { days: stored.streak.days ?? 0, lastPlayed: stored.streak.lastPlayed ?? null }
: fresh.streak,
settings: { ...fresh.settings, ...(stored.settings ?? {}) },
};
return { version: 2, lessons, ...carryForward(stored, fresh) };
}
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumTiere.tsx","./src/components/Bubbles.tsx","./src/components/HandHint.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ModePicker.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BlasenRun.tsx","./src/components/modes/FuetternRun.tsx","./src/components/modes/PerlenRun.tsx","./src/components/modes/QuallenRun.tsx","./src/components/modes/RennenRun.tsx","./src/components/modes/TauchgangRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/speech.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumCreatures.tsx","./src/components/Bubbles.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BubblesRun.tsx","./src/components/modes/DiveRun.tsx","./src/components/modes/FeedRun.tsx","./src/components/modes/JellyfishRun.tsx","./src/components/modes/RaceRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/lessonPath.ts","./src/lib/modeInfo.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/lessonPath.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}

24
web/eslint.config.js Normal file
View File

@@ -0,0 +1,24 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config([
{
ignores: ["dist"],
},
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
},
]);

1501
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b",
"check": "tsc -b && eslint .",
"test": "vitest run"
},
"dependencies": {
@@ -15,10 +15,16 @@
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.15.0",
"typescript": "^5.9.0",
"typescript-eslint": "^8.70.0",
"vite": "^7.1.0",
"vitest": "^3.2.0"
},

View File

@@ -154,6 +154,33 @@ export function App() {
void (state.playing ? api.pause() : api.resume());
}, [connection, library.albums, play, results.albums, state]);
const onMute = useCallback(
() => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT),
[setVolume, state],
);
/** The one seek primitive: an optimistic position patch plus the backend call.
* Shared by dragging the progress bar (an absolute target) and Shift+Arrow /
* podcast skip (a target computed from the current position). */
const seekTo = useCallback(
(target: number) => {
connection.optimistic({ position: target });
void api.seek(target);
},
[connection],
);
const skipPodcast = useCallback(
(direction: 1 | -1) => {
const target =
direction > 0
? Math.min(state?.duration ?? 0, (state?.position ?? 0) + PODCAST_SKIP_SECONDS)
: Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
seekTo(target);
},
[seekTo, state],
);
/** The one place a keyboard action, a click or a tap all end up. */
// Read-modify-write the whole mapping, like ParentPanel does for settings: the
// backend takes a full replacement, not a per-slot patch.
@@ -218,12 +245,7 @@ export function App() {
case "next":
playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.min(
state?.duration ?? 0,
(state?.position ?? 0) + PODCAST_SKIP_SECONDS,
);
connection.optimistic({ position: target });
void api.seek(target);
skipPodcast(1);
} else {
connection.optimistic({ position: 0 });
void api.next();
@@ -232,9 +254,7 @@ export function App() {
case "previous":
playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
connection.optimistic({ position: target });
void api.seek(target);
skipPodcast(-1);
} else {
connection.optimistic({ position: 0 });
void api.previous();
@@ -249,8 +269,7 @@ export function App() {
0,
Math.min(state.duration, (state?.position ?? 0) + action.delta),
);
connection.optimistic({ position: target });
void api.seek(target);
seekTo(target);
}
break;
}
@@ -258,12 +277,23 @@ export function App() {
playPop(action.freq);
break;
case "mute":
setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
onMute();
break;
}
}
},
[assignCurrentTo, connection, currentAlbum, play, setVolume, state, toggle],
[
assignCurrentTo,
connection,
currentAlbum,
onMute,
play,
seekTo,
setVolume,
skipPodcast,
state,
toggle,
],
);
// Held in a ref so the listener is installed once rather than on every state change.
@@ -356,11 +386,8 @@ export function App() {
};
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
const onSeek = (target: number) => {
connection.optimistic({ position: target });
void api.seek(target);
};
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
// The single top-left "back" button, shared by every level of the music page -
// search, categories, play view - so it always means "one step out" no matter what
@@ -425,9 +452,9 @@ export function App() {
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
onSeek={onSeek}
onNext={onNext}
onPrevious={onPrevious}
onSeek={seekTo}
onVolume={setVolume}
onMute={onMute}
onOpenAlbum={onOpenCurrentAlbum}
@@ -514,9 +541,9 @@ export function App() {
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
onSeek={onSeek}
onNext={onNext}
onPrevious={onPrevious}
onSeek={seekTo}
onVolume={setVolume}
onMute={onMute}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}

View File

@@ -30,32 +30,34 @@ function post(path: string, body?: unknown): Promise<void> {
});
}
/** `null` means the room-control page isn't configured, not an error - unlike
* `request()`, a 404 here is expected and shouldn't throw. */
async function fetchHaConfig(): Promise<HaConfig | null> {
const response = await fetch("/api/ha");
/** GET, treating a 404 as an expected "not configured" rather than an error - unlike
* `request()`, which throws on it. Anything else that isn't ok still throws. */
async function fetchOrNullOn404<T>(path: string): Promise<T | null> {
const response = await fetch(`/api${path}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET /ha failed: ${response.status}`);
return (await response.json()) as HaConfig;
if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`);
return (await response.json()) as T;
}
/** `null` means the IR remote isn't configured, not an error - same convention as
* `fetchHaConfig`. */
async function fetchLircConfig(): Promise<LircConfig | null> {
const response = await fetch("/api/lirc");
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET /lirc failed: ${response.status}`);
return (await response.json()) as LircConfig;
/** GET, treating *any* non-ok response as "nothing to show" rather than an error - for
* callers that poll and would rather fall back quietly than crash the poll loop. */
async function fetchOrNullOnError<T>(path: string): Promise<T | null> {
const response = await fetch(`/api${path}`);
if (!response.ok) return null;
return (await response.json()) as T;
}
/** `null` means the room-control page isn't configured, not an error. */
const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha");
/** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
* right now" (the backend answers the latter with a 502) - the room page treats a
* device with no state the same way either way, rather than crashing on a poll. */
async function fetchHaState(entityId: string): Promise<HaEntityState | null> {
const response = await fetch(`/api/ha/states/${entityId}`);
if (!response.ok) return null;
return (await response.json()) as HaEntityState;
}
const fetchHaState = (entityId: string): Promise<HaEntityState | null> =>
fetchOrNullOnError<HaEntityState>(`/ha/states/${entityId}`);
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
const results = await Promise.all(entityIds.map(fetchHaState));
@@ -68,11 +70,8 @@ async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEnti
/** `null` means "not analyzed" (the backend's expected 404 for this), not an error -
* the ambient background just falls back to its un-analyzed baseline for that track. */
async function fetchTrackDetail(albumId: string, trackIndex: number): Promise<TrackDetail | null> {
const response = await fetch(`/api/tracks/${albumId}/${trackIndex}/analysis`);
if (!response.ok) return null;
return (await response.json()) as TrackDetail;
}
const fetchTrackDetail = (albumId: string, trackIndex: number): Promise<TrackDetail | null> =>
fetchOrNullOnError<TrackDetail>(`/tracks/${albumId}/${trackIndex}/analysis`);
export const api = {
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),

View File

@@ -234,7 +234,6 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
const baseRef = useRef(ambienceBaseFor(album, trackAnalysis));
useEffect(() => {
baseRef.current = ambienceBaseFor(album, trackAnalysis);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album, trackAnalysis]);
useEffect(() => {

View File

@@ -6,6 +6,7 @@
* the whole page. */
import { useEffect, useMemo, useRef } from "react";
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
import type { Album } from "../api/types";
import {
@@ -546,7 +547,7 @@ function ShelfRow({
children,
focusedIndex,
}: {
children: React.ReactNode;
children: ReactNode;
/** Which tile in this row the keyboard is on, or `null` while some other row has
* focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the
* horizontal equivalent of the outer scroller's own keep-in-view effect above. */
@@ -580,7 +581,7 @@ function ShelfRow({
return () => element.removeEventListener("wheel", onWheel);
}, []);
const onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
const onMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
const element = ref.current;
if (event.button !== 0 || !element) return;
event.preventDefault(); // no text-selection/ghost-drag while panning
@@ -632,7 +633,7 @@ function CategoryTile({
entry: Category;
navIndex?: number;
selected?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
}) {
const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length;
@@ -728,7 +729,7 @@ function SectionTitle({
children,
centered,
}: {
children: React.ReactNode;
children: ReactNode;
centered?: boolean;
}) {
return (
@@ -750,7 +751,7 @@ function SectionTitle({
);
}
function Muted({ children }: { children: React.ReactNode }) {
function Muted({ children }: { children: ReactNode }) {
return (
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
{children}

View File

@@ -7,6 +7,8 @@
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
*/
import type { CSSProperties } from "react";
import { coverUrl } from "../api/client";
import type { Album } from "../api/types";
import { aspectOf, coverBackground, isBook } from "../lib/covers";
@@ -21,7 +23,7 @@ interface Props {
fit?: "width" | "height";
radius?: string;
className?: string;
style?: React.CSSProperties;
style?: CSSProperties;
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
label?: boolean;
}
@@ -29,7 +31,7 @@ interface Props {
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
const book = isBook(album);
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
const box: React.CSSProperties =
const box: CSSProperties =
fit === "height"
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
: { width: "100%", aspectRatio: aspectOf(album) };

View File

@@ -2,7 +2,7 @@
import type { Album, PlayerState } from "../api/types";
import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { albumLine, isBook } from "../lib/covers";
import { isBook, nowPlayingText } from "../lib/covers";
import { clock, remainingInAlbum } from "../lib/format";
import { groupOf } from "../lib/search";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
@@ -41,6 +41,7 @@ export function PlayView({
assignPending,
}: Props) {
const position = usePlaybackClock(state.position, state.playing);
const now = nowPlayingText(state, album);
const book = album ? isBook(album) : false;
const podcast = album ? groupOf(album) === "podcasts" : false;
const remaining = album
@@ -138,7 +139,7 @@ export function PlayView({
textWrap: "pretty",
}}
>
{state.track_title ?? "Wähl ein Album!"}
{now.title}
</div>
<div
style={{
@@ -148,7 +149,7 @@ export function PlayView({
marginTop: 8,
}}
>
{album ? albumLine(album) : "Tippen oder klicken"}
{now.subtitle}
</div>
{album && (
<div
@@ -159,7 +160,7 @@ export function PlayView({
marginTop: 6,
}}
>
{book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count}
{now.progress}
</div>
)}
</div>
@@ -169,7 +170,7 @@ export function PlayView({
position={position}
duration={state.duration}
onSeek={onSeek}
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
resetKey={now.resetKey}
height={14}
interactive
/>

View File

@@ -2,7 +2,7 @@
import type { Album, PlayerState } from "../api/types";
import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { albumLine, isBook } from "../lib/covers";
import { nowPlayingText } from "../lib/covers";
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar";
@@ -33,6 +33,7 @@ export function PlayerBar({
onOpenPlayView,
}: Props) {
const position = usePlaybackClock(state.position, state.playing);
const now = nowPlayingText(state, album);
return (
<div
style={{
@@ -100,7 +101,7 @@ export function PlayerBar({
textOverflow: "ellipsis",
}}
>
{state.track_title ?? "Wähl ein Album!"}
{now.title}
</div>
<div
style={{
@@ -112,7 +113,7 @@ export function PlayerBar({
textOverflow: "ellipsis",
}}
>
{album ? albumLine(album) : "Tippen oder klicken"}
{now.subtitle}
</div>
{album && (
<div
@@ -123,8 +124,7 @@ export function PlayerBar({
marginTop: 1,
}}
>
{isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "}
{state.track_count}
{now.progress}
</div>
)}
</div>
@@ -134,7 +134,7 @@ export function PlayerBar({
position={position}
duration={state.duration}
onSeek={onSeek}
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
resetKey={now.resetKey}
height={10}
interactive
/>

View File

@@ -1,6 +1,7 @@
/** The progress bar, and the one thing the mockup could not do: scrubbing. */
import { useCallback, useRef, useState } from "react";
import type { PointerEvent } from "react";
interface Props {
position: number;
@@ -45,7 +46,7 @@ export function ProgressBar({
const shown = dragging ?? position;
const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0;
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!interactive || !duration) return;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(positionAt(event.clientX));

View File

@@ -29,6 +29,11 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
// when the patch landed resolves with pre-click data - without this, that stale
// response clobbers the optimistic "on" back to "off" until the *next* poll catches
// up, which is what made toggling feel laggy despite the optimistic update existing.
//
// `usePlayerState`'s `optimistic()` solves the same "local guess vs. eventual truth"
// problem far more simply, by just patching the whole object - it can get away with
// that because the backend pushes over a websocket rather than being polled, so
// there's no in-flight request that can resolve late and stomp on a newer patch.
const optimisticAt = useRef<Record<string, number>>({});
const entityIds = useMemo(

View File

@@ -15,7 +15,12 @@ const RECONNECT_DELAY_MS = 1500;
export interface Connection {
state: PlayerState | null;
online: boolean;
/** Apply a change locally so a keypress feels instant; the next frame reconciles. */
/** Apply a change locally so a keypress feels instant; the next frame reconciles.
*
* A plain whole-object patch is enough here because state only ever arrives pushed
* over the websocket - there's no in-flight poll that could resolve late and stomp
* on it. `useHomeAssistant`'s `optimistic()` solves the same problem for a *polled*
* source, where that race is real, with a per-entity timestamp guard instead. */
optimistic: (patch: Partial<PlayerState>) => void;
}
@@ -34,13 +39,21 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
const connect = () => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${location.host}/api/ws`);
const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);
socket = ws;
socket.onopen = () => {
setOnline(true);
// The server sends a snapshot on connect, but a reconnect may have missed
// changes in between, so ask for the truth as well.
void api.state().then(setState).catch(() => undefined);
// changes in between, so ask for the truth as well. Guarded against a slow
// response landing after a *later* reconnect already replaced this socket -
// every other fetch-on-mount hook in this codebase guards the same way.
void api
.state()
.then((fetched) => {
if (!closed && socket === ws) setState(fetched);
})
.catch(() => undefined);
};
socket.onmessage = (event) => {

View File

@@ -6,7 +6,7 @@
* artwork agree - and so do the LED strips, which run the same primary colour.
*/
import type { Album, AlbumKind } from "../api/types";
import type { Album, AlbumKind, PlayerState } from "../api/types";
import { GROUP_HUE } from "./theme";
export const isBook = (album: Album): boolean => album.kind === "book";
@@ -14,8 +14,8 @@ export const isBook = (album: Album): boolean => album.kind === "book";
/** Shape is how you tell the two apart without reading anything: albums are square,
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
export const ALBUM_ASPECT = 1;
export const BOOK_ASPECT = 0.82;
const ALBUM_ASPECT = 1;
const BOOK_ASPECT = 0.82;
export const aspectOfKind = (kind: AlbumKind): number =>
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
@@ -29,7 +29,7 @@ const colours = (album: Album): [string, string, string] => [
];
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
export function stripes(album: Album, width: number): string {
function stripes(album: Album, width: number): string {
const [primary, secondary] = colours(album);
return (
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
@@ -82,3 +82,24 @@ export const albumLine = (album: Album): string => {
? `${prefix}${album.title} · ${album.artist}`
: `${prefix}${album.title}`;
};
export interface NowPlayingText {
title: string;
subtitle: string;
/** "Kapitel 3 von 8" or "Song 3 von 8" - only meaningful while an album is loaded. */
progress: string;
/** Changes whenever the track itself changes, so a progress bar can reset its drag
* state instead of animating across two unrelated tracks. */
resetKey: string;
}
/** The title/subtitle/progress text shared by the player bar and the full-screen
* now-playing view, so the two cannot drift apart on a copy change. */
export function nowPlayingText(state: PlayerState, album: Album | null): NowPlayingText {
return {
title: state.track_title ?? "Wähl ein Album!",
subtitle: album ? albumLine(album) : "Tippen oder klicken",
progress: `${album && isBook(album) ? "Kapitel" : "Song"} ${state.track_index + 1} von ${state.track_count}`,
resetKey: `${state.album_id ?? ""}:${state.track_index}`,
};
}

View File

@@ -1,10 +1,16 @@
/** The browse screen's shelf/row look, in one place: per-group colors and identity,
* and the feature toggles that have gone back and forth while this design was under
* review. Flip a toggle here rather than hunting through BrowseView.tsx/covers.ts.
/** Per-group colors and identity, and the feature toggles that have gone back and
* forth while this design was under review - flip one here rather than hunting
* through the components that read it. Most toggles are about the browse screen's
* shelf/row look, but some (`SHOW_GLASS_BLUR`, `SHOW_AMBIENCE`,
* `SHOW_DECORATIVE_ANIMATIONS`) reach further: the play view, the player bar, the
* room page and `App.tsx` all read this module too, wherever they share the same
* "cut this on weak hardware" knob.
*
* `SHOW_ROW_TITLES` and `SHOW_ROW_ICONS` both `true` reproduces the original shelf
* header - an icon badge plus label button above each row's tiles. */
import type { CSSProperties } from "react";
import type { Group } from "./search";
// ---------------------------------------------------------------- toggles --
@@ -70,7 +76,7 @@ export const GROUP_LABEL: Record<Group, string> = {
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
export function glassTint(hue: number): React.CSSProperties {
export function glassTint(hue: number): CSSProperties {
if (!SHOW_ROW_TINT) return {};
return {
background: `linear-gradient(160deg, oklch(55% 0.1 ${hue} / .32), oklch(30% 0.06 ${hue} / .14))`,
@@ -81,7 +87,7 @@ export function glassTint(hue: number): React.CSSProperties {
/** One list row's background, tinted with its group's hue and brighter/more opaque
* while it's the currently-playing row - or, with `SHOW_ROW_TINT` off, the same
* neutral highlight the row used before tinting existed. */
export function rowTint(hue: number, highlighted: boolean): React.CSSProperties {
export function rowTint(hue: number, highlighted: boolean): CSSProperties {
if (!SHOW_ROW_TINT) {
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
}