Phase 2: the info card, selection, and what 35 lit keys taught

InfoCard fills the box that was empty: the layer hint and instruction
when nothing is picked, or the selected combo as chips with its name,
description and manual page. App carries the selection, and pressing a
control that holds several functions cycles through them, as the
reference does.

Lit is now an outline rather than a fill, and solid orange means
selected. This deviates from the mockups on purpose. They only ever
showed three lit keys; the SHIFT layer lights 35 of 54 controls, and
solid orange across all of them is a wall instead of a map. I checked
that by rendering the SHIFT layer rather than assuming, which meant
temporarily defaulting to it, capturing, and restoring the source.

That capture also caught a defect nothing else would have: SHIFT + VALUE
is MARK FN inside a 30px circle, and border-radius alone does not clip,
so the label sat outside the knob. Round keys now hide overflow and
shrink when lit.

Verified in light, dark, and on the SHIFT layer at 360x735. Typecheck
clean, 112 tests, www/ untouched so the Phase 1 APK still installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 01:22:45 +03:00
parent 4961500fa7
commit c7c827ecf8
6 changed files with 228 additions and 29 deletions

View File

@@ -218,6 +218,15 @@ missing from the panel. The bank row is pinned at six keys ending in SHIFT
because `Panel.module.css` widens it with `:last-child:nth-child(6)`, which stops
matching silently if that row ever changes length.
**Lit is an outline; the fill means selected.** This deviates from the mockups,
which only ever showed three lit keys at once. The SHIFT layer lights 35 of 54
controls, and solid orange across all of them is a wall rather than a map. Orange
outline says "something is here"; solid orange says "this is the one you tapped".
**Round keys clip and shrink when lit.** `SHIFT + VALUE` is `MARK FN` inside a
30px circle. `border-radius: 50%` alone does not clip, so the label escaped the
circle entirely; the knobs need `overflow: hidden` and a smaller size when lit.
**Two strings are load-bearing for migration** and must not be changed casually:
`Entry.id` (below) and `STORAGE_KEY`, still `sp404mk2-layers-v1`. Together they
mean the boxes already on the phone are picked up by the React app rather than

View File

@@ -1,16 +1,22 @@
/**
* App shell: pickers, the panel, and the tab bar.
* Explore: pick a mode and a layer, then read what the panel lights up.
*
* Angular: useState is roughly a component field plus change detection. Setting
* it re-runs this function, so derived values below are recomputed each render;
* it re-runs this function, so derived values are recomputed each render;
* useMemo is the explicit opt-out when that stops being cheap.
*/
import { useMemo, useState } from "react";
import { ENTRIES } from "./domain/entries";
import { controlName } from "./domain/format";
import { CTX } from "./domain/labels";
import { entriesByTarget, entriesForLayer, layerCounts, orderLayerKeys } from "./domain/layers";
import type { ContextCode } from "./domain/types";
import { CTX, CTXHINT } from "./domain/labels";
import {
entriesByTarget,
entriesForLayer,
layerCounts,
orderLayerKeys,
} from "./domain/layers";
import type { ContextCode, ControlId, Entry } from "./domain/types";
import { InfoCard } from "./ui/InfoCard";
import { Panel } from "./ui/Panel";
import styles from "./App.module.css";
@@ -31,6 +37,7 @@ const layerLabel = (key: string): string =>
export function App() {
const [ctx, setCtx] = useState<ContextCode>("S");
const [layer, setLayer] = useState("");
const [selected, setSelected] = useState<Entry | null>(null);
const [tab, setTab] = useState<(typeof TABS)[number]>("Explore");
const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
@@ -47,27 +54,38 @@ export function App() {
const held = activeLayer === "" ? [] : activeLayer.split("+");
/** A control can carry several functions; pressing it again cycles them. */
function press(id: ControlId) {
const here = lit.get(id);
if (!here || here.length === 0) {
setSelected(null);
return;
}
const at = selected ? here.indexOf(selected) : -1;
setSelected(here[(at + 1) % here.length]!);
}
function chooseCtx() {
const order = Object.keys(CTX) as ContextCode[];
setCtx(order[(order.indexOf(ctx) + 1) % order.length]!);
setSelected(null);
}
function chooseLayer() {
const at = layers.indexOf(activeLayer);
setLayer(layers[(at + 1) % layers.length]!);
setSelected(null);
}
return (
<div className={styles.app}>
<div className={styles.strip}>
<button
className={styles.picker}
onClick={() => {
const order = Object.keys(CTX) as ContextCode[];
setCtx(order[(order.indexOf(ctx) + 1) % order.length]!);
}}
>
<button className={styles.picker} onClick={chooseCtx}>
<b>{CTX[ctx].toUpperCase()}</b>
<Caret />
</button>
<button
className={styles.picker}
onClick={() => {
const i = layers.indexOf(activeLayer);
setLayer(layers[(i + 1) % layers.length]!);
}}
>
<button className={styles.picker} onClick={chooseLayer}>
<b>{layerLabel(activeLayer)}</b>
<span className={styles.count}>{onLayer.length}</span>
</button>
@@ -76,15 +94,22 @@ export function App() {
<Panel
lit={lit}
held={held}
selected={selected?.tset[0] ?? null}
onPress={press}
screen={{
top: CTX[ctx].toUpperCase(),
right: activeLayer === "" ? "NO MODIFIER" : layerLabel(activeLayer),
main: `${onLayer.length} function${onLayer.length === 1 ? "" : "s"}`,
chips: onLayer.slice(0, 3).map((e) => e.short),
right: layerLabel(activeLayer),
main: selected ? selected.short : `${onLayer.length} functions`,
chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short),
}}
/>
<div className={styles.info} />
<InfoCard
entry={selected}
ctx={ctx}
hint={CTXHINT[ctx]}
count={onLayer.length}
/>
<nav className={styles.nav}>
{TABS.map((name) => (

View File

@@ -0,0 +1,81 @@
/* Fixed at 84px, so everything inside is sized to fit without scrolling. */
.card {
height: var(--info-h);
flex: 0 0 auto;
background: var(--card);
border: var(--outline);
box-shadow: var(--shadow-sm);
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 3px;
overflow: hidden;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-family: var(--mono);
font-size: 8.5px;
letter-spacing: 0.1em;
color: var(--muted);
}
.ellipsis {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page {
flex: 0 0 auto;
}
.combo {
display: flex;
align-items: center;
gap: 3px;
min-width: 0;
overflow: hidden;
}
.chip {
font-family: var(--mono);
font-size: 8.5px;
font-weight: 700;
letter-spacing: 0.04em;
background: var(--ink);
color: var(--page);
padding: 1px 4px;
white-space: nowrap;
}
.plus {
font-style: normal;
margin: 0 2px;
color: var(--muted);
}
.title {
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.1;
letter-spacing: 0.01em;
}
.body {
margin: 0;
font-size: 11.5px;
line-height: 1.25;
color: var(--muted);
/* Descriptions run long; two lines is what the card can hold. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

49
src/ui/InfoCard.tsx Normal file
View File

@@ -0,0 +1,49 @@
/** The card under the panel: the layer's hint, or the selected combo. */
import { comboParts, contextNames } from "../domain/format";
import type { ContextCode, Entry } from "../domain/types";
import styles from "./InfoCard.module.css";
export interface InfoCardProps {
readonly entry: Entry | null;
readonly ctx: ContextCode;
readonly hint: string;
readonly count: number;
}
export function InfoCard({ entry, hint, count }: InfoCardProps) {
if (!entry) {
return (
<div className={styles.card}>
<div className={styles.meta}>
<span className={styles.ellipsis}>{hint.toUpperCase()}</span>
<span className={styles.page}>
{count} FN{count === 1 ? "" : "S"}
</span>
</div>
<p className={styles.body}>
Tap a lit control to read it. Tap a modifier to hold its layer.
</p>
</div>
);
}
return (
<div className={styles.card}>
<div className={styles.meta}>
<span className={styles.combo}>
{comboParts(entry).map((part, i) => (
<span key={part}>
{i > 0 ? <i className={styles.plus}>+</i> : null}
<b className={styles.chip}>{part}</b>
</span>
))}
</span>
<span className={styles.page}>P.{entry.page}</span>
</div>
<h2 className={styles.title}>{entry.name}</h2>
<p className={styles.body}>
{entry.desc || contextNames(entry.ctx).join(", ")}
</p>
</div>
);
}

View File

@@ -37,8 +37,17 @@
overflow-wrap: break-word;
}
/* Lit is an outline, not a fill. The SHIFT layer lights 35 of 54 controls, and
35 solid orange keys is a wall; solid is reserved for the selected one. */
.lit {
border-color: var(--accent);
color: var(--accent);
font-weight: 700;
}
.sel {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
font-weight: 700;
}
@@ -77,6 +86,20 @@
color: var(--pad-ink);
}
/* A round key cannot hold a lit function's label at full size: SHIFT + VALUE is
"MARK FN", which spilled outside the circle. Clip it, and shrink when lit. */
.knob,
.valueKnob {
overflow: hidden;
}
.knob.lit,
.knob.sel,
.valueKnob.lit,
.valueKnob.sel {
font-size: 6px;
}
/* ---- fx and the screen ---- */
.screenRow {

View File

@@ -20,31 +20,43 @@ export interface PanelProps {
/** Controls carrying a function on the current layer, and which entries. */
readonly lit: ReadonlyMap<ControlId, Entry[]>;
readonly held: readonly ControlId[];
readonly selected?: ControlId | null;
readonly screen: ScreenText;
readonly onPress?: (id: ControlId) => void;
readonly onPress?: ((id: ControlId) => void) | undefined;
}
interface KeyProps extends Pick<PanelProps, "lit" | "held" | "onPress"> {
interface KeyProps {
readonly id: ControlId;
/** Comes from a CSS module, so possibly undefined. See cx. */
readonly className: string | undefined;
readonly lit: ReadonlyMap<ControlId, Entry[]>;
readonly held: readonly ControlId[];
readonly selected: ControlId | null;
readonly onPress?: ((id: ControlId) => void) | undefined;
}
/** Pads print their number, not "PAD 7" — until a layer lights them up. */
const padNumber = (id: ControlId): string | undefined => /^p(\d+)$/.exec(id)?.[1];
function Key({ id, className, lit, held, onPress }: KeyProps) {
function Key({ id, className, lit, held, selected, onPress }: KeyProps) {
const entries = lit.get(id);
const isHeld = held.includes(id);
const isLit = entries !== undefined && entries.length > 0;
const isSel = isLit && id === selected;
const number = padNumber(id);
return (
<button
type="button"
className={cx(className, isHeld && styles.held, !isHeld && isLit && styles.lit)}
className={cx(
className,
isHeld && styles.held,
!isHeld && isSel && styles.sel,
!isHeld && isLit && !isSel && styles.lit,
)}
data-control={id}
aria-label={panelLabel(id)}
aria-pressed={isHeld || isSel}
onClick={onPress ? () => onPress(id) : undefined}
>
{isLit ? (
@@ -61,8 +73,8 @@ function Key({ id, className, lit, held, onPress }: KeyProps) {
);
}
export function Panel({ lit, held, screen, onPress }: PanelProps) {
const key = { lit, held, ...(onPress ? { onPress } : {}) };
export function Panel({ lit, held, selected, screen, onPress }: PanelProps) {
const key = { lit, held, selected: selected ?? null, onPress };
return (
<div className={styles.panel}>