Phase 2: Explore's latch, haptics, back button, label overflow
Four things reported on the device, all real. Tapping a modifier did nothing. setLayer was only reachable from a search result or the layer sheet, so the reference's latch was simply never ported. It is now, with the semantics intact rather than reduced to a toggle: hold to peek and it lifts away, tap to lock and it stays, tap again to release, and reading a lit key while holding a modifier spends that modifier so lifting drops it. Knobs and pads never latch, which is what NOLATCH is for. held is now the source of truth and the layer key is derived from it, as in the reference. That needed per-control release events. Panel knew which control each pointer held and threw it away; it now reports onRelease before the gesture-wide callback, because the latch decides hold-versus-tap from how long one key was down. Haptics and the hardware back button were missing entirely - Phase 1 did both in native.js and the port had no equivalent. native.ts adds them, no-ops in a browser so the dev server is unaffected. Back owns the whole stack: sheet, then Explore, then exit. Grade haptics fire from the transition into answered, not from the four dispatch sites. Grading happens in the reducer, so deciding correctness again at each call site would be a second source of truth free to drift. PINGPONG overflowed its key. Lit keys show the entry's short label, which comes from the data and can be one unbreakable word; the soft hyphens only cover printed labels. Keys now break anywhere and clip. 141 tests, typecheck clean, installed and running on the device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
155
src/App.tsx
155
src/App.tsx
@@ -7,20 +7,20 @@
|
||||
* third argument is a lazy initialiser, which is how the stored blob is read
|
||||
* once at startup instead of on every render.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill";
|
||||
import type { DeckId, Direction, InputMode } from "./domain/drill";
|
||||
import { ENTRIES } from "./domain/entries";
|
||||
import { ENTRIES, layerKeyOf } from "./domain/entries";
|
||||
import { controlName } from "./domain/format";
|
||||
import { CTX, CTXHINT } from "./domain/labels";
|
||||
import { CTX, CTXHINT, NOLATCH } from "./domain/labels";
|
||||
import {
|
||||
entriesByTarget,
|
||||
entriesForLayer,
|
||||
layerCounts,
|
||||
orderLayerKeys,
|
||||
} from "./domain/layers";
|
||||
import type { ContextCode, ControlId, Entry } from "./domain/types";
|
||||
import { searchEntries } from "./domain/search";
|
||||
import type { ContextCode, ControlId, Entry } from "./domain/types";
|
||||
import { DrillCard } from "./ui/DrillCard";
|
||||
import { DrillStrip } from "./ui/DrillStrip";
|
||||
import { FindPanel } from "./ui/FindPanel";
|
||||
@@ -29,6 +29,7 @@ import { Panel } from "./ui/Panel";
|
||||
import { RulesPanel } from "./ui/RulesPanel";
|
||||
import { Sheet, SheetOption } from "./ui/Sheet";
|
||||
import { drillReducer, initialDrillState } from "./ui/drillReducer";
|
||||
import { exitApp, gradeFeedback, useBackButton } from "./ui/native";
|
||||
import { loadState, saveState } from "./ui/persistedState";
|
||||
import styles from "./App.module.css";
|
||||
|
||||
@@ -36,6 +37,9 @@ const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
type OpenSheet = null | "mode" | "layer" | "settings";
|
||||
|
||||
/** Longer than this and the press was a hold to peek, not a tap to lock. */
|
||||
const HOLD_MS = 350;
|
||||
|
||||
function Caret() {
|
||||
return (
|
||||
<svg width="9" height="6" viewBox="0 0 9 6" aria-hidden="true">
|
||||
@@ -69,11 +73,39 @@ export function App() {
|
||||
const [tab, setTab] = useState<Tab>("Explore");
|
||||
const [sheet, setSheet] = useState<OpenSheet>(null);
|
||||
|
||||
// Hardware back owns the whole stack: a sheet first, then back to Explore,
|
||||
// and only then leave. Registering at all suppresses Capacitor's default,
|
||||
// which would otherwise just exit from anywhere.
|
||||
useBackButton(
|
||||
useCallback(() => {
|
||||
if (sheet !== null) setSheet(null);
|
||||
else if (tab !== "Explore") setTab("Explore");
|
||||
else exitApp();
|
||||
}, [sheet, tab]),
|
||||
);
|
||||
|
||||
// --- Explore ---
|
||||
const [ctx, setCtx] = useState<ContextCode>(stored.ctx);
|
||||
const [layer, setLayer] = useState("");
|
||||
/** Modifiers currently down or latched. The layer key is derived from these. */
|
||||
const [held, setHeld] = useState<readonly ControlId[]>([]);
|
||||
const [selected, setSelected] = useState<Entry | null>(null);
|
||||
|
||||
// Latch bookkeeping, from the reference: when each key went down, which held
|
||||
// modifiers have been "used" by reading something, and which are due to be
|
||||
// dropped on release because they were already latched when tapped again.
|
||||
const downAt = useRef(new Map<ControlId, number>());
|
||||
const used = useRef(new Set<ControlId>());
|
||||
const pendingRelease = useRef(new Set<ControlId>());
|
||||
|
||||
const heldKey = layerKeyOf(held);
|
||||
const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
|
||||
const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
|
||||
const onLayer = useMemo(
|
||||
() => entriesForLayer(ENTRIES, ctx, heldKey),
|
||||
[ctx, heldKey],
|
||||
);
|
||||
const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
|
||||
|
||||
// --- Find ---
|
||||
const [query, setQuery] = useState("");
|
||||
const results = useMemo(() => searchEntries(ENTRIES, query), [query]);
|
||||
@@ -81,20 +113,11 @@ export function App() {
|
||||
/** Jump from a search result to that combo, lit on the panel. */
|
||||
function goTo(entry: Entry) {
|
||||
setCtx(entry.ctx[0] ?? "S");
|
||||
setLayer(entry.key);
|
||||
setHeld(entry.mods);
|
||||
setSelected(entry);
|
||||
setTab("Explore");
|
||||
}
|
||||
|
||||
const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
|
||||
const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
|
||||
const activeLayer = counts.has(layer) ? layer : "";
|
||||
const onLayer = useMemo(
|
||||
() => entriesForLayer(ENTRIES, ctx, activeLayer),
|
||||
[ctx, activeLayer],
|
||||
);
|
||||
const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
|
||||
|
||||
// --- Drill ---
|
||||
const [drill, dispatch] = useReducer(drillReducer, stored, (s) =>
|
||||
initialDrillState(s.deck, s.dir, s.input, s.boxes),
|
||||
@@ -135,11 +158,74 @@ export function App() {
|
||||
return entriesByTarget([drill.card]);
|
||||
}, [drill.card, drill.answered]);
|
||||
|
||||
// Haptics on a graded answer, driven by the transition into `answered` rather
|
||||
// than by the dispatch sites. Grading lives in the reducer, so re-deciding
|
||||
// correctness at each call site would be a second source of truth that could
|
||||
// drift from it. This fires once per card, however it was graded.
|
||||
const buzzedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!drill.answered || !drill.card) return;
|
||||
if (buzzedFor.current === drill.card.id) return;
|
||||
buzzedFor.current = drill.card.id;
|
||||
gradeFeedback(drill.lastCorrect);
|
||||
}, [drill.answered, drill.card, drill.lastCorrect]);
|
||||
|
||||
const isDrill = tab === "Drill";
|
||||
// Find and Rules take the whole screen: the reference hides the unit on both,
|
||||
// and a list or a page of prose wants the room more than the panel does.
|
||||
const isPanelTab = tab === "Explore" || isDrill;
|
||||
|
||||
/**
|
||||
* Explore's press, ported from the reference.
|
||||
*
|
||||
* A lit control reads out, cycling if it carries several. Anything latchable
|
||||
* goes down as a modifier. Knobs and pads never latch.
|
||||
*/
|
||||
function explorePress(id: ControlId) {
|
||||
if (held.includes(id)) {
|
||||
// Already down: decide on release, so a tap can un-latch it.
|
||||
pendingRelease.current.add(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const here = exploreLit.get(id);
|
||||
if (here && here.length > 0) {
|
||||
const at = selected ? here.indexOf(selected) : -1;
|
||||
setSelected(here[(at + 1) % here.length]!);
|
||||
// Reading something while holding a modifier spends it: lifting drops it.
|
||||
for (const mod of held) used.current.add(mod);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!NOLATCH.includes(id)) {
|
||||
setHeld((current) => [...current, id]);
|
||||
downAt.current.set(id, performance.now());
|
||||
used.current.delete(id);
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelected(null);
|
||||
}
|
||||
|
||||
/** Hold to peek and it lifts away; tap to lock and it stays. */
|
||||
function exploreRelease(id: ControlId) {
|
||||
if (pendingRelease.current.has(id)) {
|
||||
pendingRelease.current.delete(id);
|
||||
setHeld((current) => current.filter((c) => c !== id));
|
||||
return;
|
||||
}
|
||||
|
||||
const wentDown = downAt.current.get(id);
|
||||
if (wentDown === undefined) return;
|
||||
downAt.current.delete(id);
|
||||
|
||||
const wasHold = performance.now() - wentDown > HOLD_MS;
|
||||
if (wasHold || used.current.has(id)) {
|
||||
setHeld((current) => current.filter((c) => c !== id));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPanelTab) {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
@@ -181,7 +267,7 @@ export function App() {
|
||||
<Caret />
|
||||
</button>
|
||||
<button className={styles.picker} onClick={() => setSheet("layer")}>
|
||||
<b>{layerLabel(activeLayer)}</b>
|
||||
<b>{layerLabel(heldKey)}</b>
|
||||
<span className={styles.count}>{onLayer.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -189,8 +275,19 @@ export function App() {
|
||||
|
||||
<Panel
|
||||
lit={isDrill ? drillLit : exploreLit}
|
||||
held={isDrill ? drill.chord : activeLayer === "" ? [] : activeLayer.split("+")}
|
||||
held={isDrill ? drill.chord : held}
|
||||
selected={isDrill ? null : (selected?.tset[0] ?? null)}
|
||||
onPress={(id) => {
|
||||
if (!isDrill) {
|
||||
explorePress(id);
|
||||
return;
|
||||
}
|
||||
if (drill.answered) deal();
|
||||
else if (drill.input === "tap") dispatch({ type: "press", id });
|
||||
}}
|
||||
onRelease={(id) => {
|
||||
if (!isDrill) exploreRelease(id);
|
||||
}}
|
||||
onGestureEnd={(ids) => {
|
||||
// Hold-to-answer grades when the last finger lifts. Tap mode waits for
|
||||
// the Check button instead, and Explore has nothing to grade.
|
||||
@@ -198,20 +295,6 @@ export function App() {
|
||||
dispatch({ type: "release", chord: ids });
|
||||
}
|
||||
}}
|
||||
onPress={(id) => {
|
||||
if (isDrill) {
|
||||
if (drill.answered) deal();
|
||||
else if (drill.input === "tap") dispatch({ type: "press", id });
|
||||
return;
|
||||
}
|
||||
const here = exploreLit.get(id);
|
||||
if (!here || here.length === 0) {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
const at = selected ? here.indexOf(selected) : -1;
|
||||
setSelected(here[(at + 1) % here.length]!);
|
||||
}}
|
||||
screen={
|
||||
isDrill
|
||||
? {
|
||||
@@ -222,7 +305,7 @@ export function App() {
|
||||
}
|
||||
: {
|
||||
top: CTX[ctx].toUpperCase(),
|
||||
right: layerLabel(activeLayer),
|
||||
right: layerLabel(heldKey),
|
||||
main: selected ? selected.short : plural(onLayer.length, "function"),
|
||||
chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short),
|
||||
}
|
||||
@@ -280,9 +363,13 @@ export function App() {
|
||||
key={key || "none"}
|
||||
label={layerLabel(key)}
|
||||
hint={plural(counts.get(key) ?? 0, "function")}
|
||||
selected={key === activeLayer}
|
||||
selected={key === heldKey}
|
||||
onSelect={() => {
|
||||
setLayer(key);
|
||||
// Picking a layer latches its modifiers, as if they were tapped.
|
||||
setHeld(key === "" ? [] : key.split("+"));
|
||||
downAt.current.clear();
|
||||
used.current.clear();
|
||||
pendingRelease.current.clear();
|
||||
setSelected(null);
|
||||
setSheet(null);
|
||||
}}
|
||||
|
||||
@@ -45,7 +45,12 @@
|
||||
padding: 2px 1px;
|
||||
border-radius: 3px;
|
||||
hyphens: manual;
|
||||
overflow-wrap: break-word;
|
||||
/* Lit keys show the entry's short label, which comes from the data and can be
|
||||
one long unbreakable word — SHIFT + LOOP is PINGPONG. Soft hyphens only
|
||||
cover the printed labels, so allow a break anywhere and clip what is left,
|
||||
rather than letting it spill outside the key. */
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Lit is an outline, not a fill. The SHIFT layer lights 35 of 54 controls, and
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { panelLabel } from "../domain/format";
|
||||
import type { ControlId, Entry } from "../domain/types";
|
||||
import { cx } from "./cx";
|
||||
import { tapFeedback } from "./native";
|
||||
import { CONTROL_GROUPS, FX_LEFT, FX_RIGHT, KNOBS, PAD_ROWS } from "./panelLayout";
|
||||
import styles from "./Panel.module.css";
|
||||
|
||||
@@ -28,6 +29,8 @@ export interface PanelProps {
|
||||
readonly screen: ScreenText;
|
||||
/** A control went down. */
|
||||
readonly onPress?: ((id: ControlId) => void) | undefined;
|
||||
/** That control's finger lifted. Explore's latch needs the timing per key. */
|
||||
readonly onRelease?: ((id: ControlId) => void) | undefined;
|
||||
/** The last finger lifted; every control touched during the gesture. */
|
||||
readonly onGestureEnd?: ((ids: ControlId[]) => void) | undefined;
|
||||
}
|
||||
@@ -92,6 +95,7 @@ export function Panel({
|
||||
selected,
|
||||
screen,
|
||||
onPress,
|
||||
onRelease,
|
||||
onGestureEnd,
|
||||
}: PanelProps) {
|
||||
/** pointerId to the control it is holding. */
|
||||
@@ -103,17 +107,23 @@ export function Panel({
|
||||
// Kept in refs so the window listeners below can be attached once and still
|
||||
// call the current callbacks.
|
||||
const pressRef = useRef(onPress);
|
||||
const releaseRef = useRef(onRelease);
|
||||
const endRef = useRef(onGestureEnd);
|
||||
pressRef.current = onPress;
|
||||
releaseRef.current = onRelease;
|
||||
endRef.current = onGestureEnd;
|
||||
|
||||
useEffect(() => {
|
||||
// Fingers often lift outside the key they went down on, so releases are
|
||||
// heard on the window rather than the button.
|
||||
function release(e: PointerEvent) {
|
||||
if (!active.current.has(e.pointerId)) return;
|
||||
const id = active.current.get(e.pointerId);
|
||||
if (id === undefined) return;
|
||||
active.current.delete(e.pointerId);
|
||||
setDown(new Set(active.current.values()));
|
||||
// Per-control, and before the gesture-wide callback: Explore's latch
|
||||
// decides hold-versus-tap from how long this one key was down.
|
||||
releaseRef.current?.(id);
|
||||
if (active.current.size > 0) return;
|
||||
|
||||
const ids = [...gesture.current];
|
||||
@@ -139,12 +149,15 @@ export function Panel({
|
||||
active.current.set(e.pointerId, id);
|
||||
gesture.current.add(id);
|
||||
setDown(new Set(active.current.values()));
|
||||
// Every key gets a tick, including the second and third finger of a chord.
|
||||
tapFeedback();
|
||||
pressRef.current?.(id);
|
||||
}, []);
|
||||
|
||||
/** Keyboard: press and end the gesture in one go. */
|
||||
/** Keyboard: a full press, release and gesture in one go, so taps latch. */
|
||||
const onKey = useCallback((id: ControlId) => {
|
||||
pressRef.current?.(id);
|
||||
releaseRef.current?.(id);
|
||||
endRef.current?.([id]);
|
||||
}, []);
|
||||
|
||||
|
||||
43
src/ui/native.ts
Normal file
43
src/ui/native.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The native bits Phase 1 did in native.js: haptics and the hardware back
|
||||
* button. No-ops in a browser, so the dev server behaves normally.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics";
|
||||
|
||||
const isNative = (): boolean => Capacitor.isNativePlatform();
|
||||
|
||||
/** A light tick under every key press, as the reference's buzz() did. */
|
||||
export function tapFeedback(): void {
|
||||
if (!isNative()) return;
|
||||
void Haptics.impact({ style: ImpactStyle.Light }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Shaped feedback on a graded answer: right and wrong should feel different. */
|
||||
export function gradeFeedback(correct: boolean): void {
|
||||
if (!isNative()) return;
|
||||
void Haptics.notification({
|
||||
type: correct ? NotificationType.Success : NotificationType.Error,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export function exitApp(): void {
|
||||
if (!isNative()) return;
|
||||
void CapacitorApp.exitApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardware back. Registering a listener suppresses Capacitor's default history
|
||||
* navigation, so `onBack` owns the whole stack: sheet, then tab, then exit.
|
||||
*/
|
||||
export function useBackButton(onBack: () => void): void {
|
||||
useEffect(() => {
|
||||
if (!isNative()) return;
|
||||
const handle = CapacitorApp.addListener("backButton", () => onBack());
|
||||
return () => {
|
||||
void handle.then((h) => h.remove());
|
||||
};
|
||||
}, [onBack]);
|
||||
}
|
||||
Reference in New Issue
Block a user