Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player / smarthome / typing), replacing the old single room-toggle corner button with a vertical icon tab rail. Curriculum and progress now come from the backend (musicmouse/tippen/*) instead of a build-time YAML import and localStorage. Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question mark for locked albums/tracks with a hint on what unlocks them, and ResultSheet gets a new unlock-animation block alongside the existing lesson-unlock and aquarium-creature celebrations. CSS from the two apps is merged carefully: identical rules (bubble/card/ key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is, while rules that bake in each app's own hue are kept separate under a `tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so neither app's look bleeds into the other's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
65
web/src/components/TabRail.tsx
Normal file
65
web/src/components/TabRail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/** The tab rail: icon-only, vertically centered on the right edge - audio player and
|
||||
* typing always shown, the smarthome tab only when Home Assistant is configured, same
|
||||
* gate the old single toggle button used. Replaces that button now that there are
|
||||
* three destinations instead of two. */
|
||||
|
||||
import { PAGE_ICON, PAGE_LABEL } from "../lib/theme";
|
||||
import type { UiState } from "../lib/keyboard";
|
||||
|
||||
interface Props {
|
||||
page: UiState["page"];
|
||||
onSelect: (page: UiState["page"]) => void;
|
||||
showRoom: boolean;
|
||||
}
|
||||
|
||||
const PAGES: readonly UiState["page"][] = ["music", "typing", "room"];
|
||||
|
||||
export function TabRail({ page, onSelect, showRoom }: Props) {
|
||||
const pages = PAGES.filter((candidate) => candidate !== "room" || showRoom);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
right: 24,
|
||||
transform: "translateY(-50%)",
|
||||
zIndex: 3,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{pages.map((candidate) => {
|
||||
const active = candidate === page;
|
||||
return (
|
||||
<button
|
||||
key={candidate}
|
||||
onClick={() => onSelect(candidate)}
|
||||
aria-label={PAGE_LABEL[candidate]}
|
||||
aria-current={active}
|
||||
title={PAGE_LABEL[candidate]}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
background: active ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
|
||||
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color: active ? "#fff" : "var(--ink)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s ease, color 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{PAGE_ICON[candidate]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user