Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
db81584
feat(labels): expose full L-chord labels at the multi-panel figure lobby
claude Jul 9, 2026
74a877b
feat(labels): add l z at the figure lobby and clarify multi-panel axi…
claude Jul 9, 2026
1ba4cc1
feat(labels): name the focused subplot when announcing labels at the …
claude Jul 9, 2026
933c187
feat(labels): figure-wide axis labels with figure→subplot precedence
claude Jul 9, 2026
a9a1190
feat(description): surface figure-wide axes in the lobby chart descri…
claude Jul 9, 2026
c61d8b9
refactor(labels): address PR review nits on figure-wide label support
claude Jul 9, 2026
839d535
test(labels): mirror X announce coverage for Y/Z; refine keybinding c…
claude Jul 9, 2026
7ff3730
test(labels): cover the terse-mode subplot-title fallback at the lobby
claude Jul 9, 2026
bb662cf
refactor(labels): dedupe X/Y axis announcement into a shared helper
claude Jul 9, 2026
dcf23e4
docs(labels): correct restoreScope guard comment after bare-'t' removal
claude Jul 9, 2026
b942187
feat(lobby): text/sound toggles, braille warning, and subplot enter/e…
claude Jul 10, 2026
2956dc6
fix(labels): align the "Z label is not available" wording with X/Y
claude Jul 10, 2026
e75ccfd
refactor(lobby): address review — stale note, braille-entry alert, co…
claude Jul 10, 2026
3550f06
feat(lobby): mode-aware text/cues, lobby review mode, braille-exit pa…
claude Jul 10, 2026
f652b3f
test(lobby): cover figure boundary cue and no-double-sound-on-exit in…
claude Jul 10, 2026
867a938
test(model): verify Figure parses figure-wide axes from maidr JSON
claude Jul 10, 2026
55ec896
Merge remote-tracking branch 'origin/main' into claude/figure-title-s…
claude Jul 10, 2026
2133cff
docs(schema): note figure-level axes has no z (per-trace only)
claude Jul 10, 2026
96fba26
feat(lobby): terse mode reads subplot titles in the figure lobby
claude Jul 10, 2026
18bb85c
feat(lobby): terse exit cue reads subplot title; Backspace exits to l…
claude Jul 10, 2026
d09f725
refactor: centralize authored-title + focused-subplot-title helpers
claude Jul 11, 2026
b8111b5
refactor(schema): narrow figure-level axes to Pick<AxisConfig, 'label'>
claude Jul 11, 2026
f562cec
docs(keybinding): explain backspace exit binding; test: pin context s…
claude Jul 11, 2026
a15e798
refactor(describe): reuse focusedSubplotTitle in AnnounceTitleCommand
claude Jul 11, 2026
3ce3997
refactor(command): bundle subplot-transition feedback into SubplotCue…
claude Jul 11, 2026
d4c1048
feat(lobby): title-only terse cues; verbose cues now include the subp…
claude Jul 12, 2026
89acbd2
test(description): cover blank figure-wide axis omitted from the d modal
claude Jul 12, 2026
cb468fb
test(review): add ReviewService lobby coverage; document braille-exit…
claude Jul 12, 2026
bc02b01
test(model): make the blank figure-axis test assert real behavior
claude Jul 12, 2026
fc5117b
refactor(text): move lobby transition wording into TextService
claude Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 80 additions & 16 deletions src/command/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,69 @@ abstract class AnnounceCommand implements Command {
* the correct scope (TRACE, BRAILLE, etc.) regardless of which scope
* was active before entering label mode.
*
* Only exits when a label scope is actually active. These announce
* commands are also bound outside label mode (e.g. 't' at subplot/figure
* level); exiting unconditionally would flip navigation into TRACE scope
* via the stale focus stack and break subplot activation.
* Only exits when a label scope (TRACE_LABEL or FIGURE_LABEL) is actually
* active. Some announce commands can be invoked from non-label scopes;
* exiting unconditionally would flip navigation into TRACE scope via the
* stale focus stack and break subplot activation.
*/
protected restoreScope(): void {
const scope = this.context.scope;
if (scope === Scope.TRACE_LABEL || scope === Scope.FIGURE_LABEL) {
this.displayService.exitLabelScope();
}
}

/**
* Resolves the populated trace state whose labels (x / y / z) should be
* announced, regardless of the current navigation level:
* - trace level: the active trace itself;
* - figure lobby (multi-panel) or subplot level: the *currently focused*
* subplot's active trace. In a facet figure every panel shares the same
* axes, so this is the figure-wide label; in a general multi-panel figure
* each panel may differ, so the announcement tracks whichever subplot the
* cursor is on (and updates as the user navigates between subplots).
*
* Returns null when no populated trace is reachable (e.g. an empty state),
* so callers can fall back to an "unavailable" announcement.
*/
protected resolveActiveTraceState(): NonEmptyTraceState | null {
const state = this.context.state;
if (state.empty) {
return null;
}
if (state.type === 'trace') {
return state;
}
if (state.type === 'subplot') {
return state.trace.empty ? null : state.trace;
}
// Figure lobby: read the active subplot's active trace.
if (state.subplot.empty || state.subplot.trace.empty) {
return null;
}
return state.subplot.trace;
}
Comment on lines +85 to +107

/**
* Prefix that names which subplot an announced label belongs to.
*
* At the multi-panel figure lobby the cursor sits on one subplot at a time
* and each panel may carry different axes, so an axis/level announcement is
* prefixed with the focused subplot's position (e.g. "Subplot 2, "). Returns
* an empty string once the user is inside a subplot (trace/subplot level) or
* in a single-panel figure, where the source is already unambiguous — and in
* terse mode, which stays minimal by design.
*/
protected labelSourcePrefix(): string {
if (this.textService.isTerse()) {
return '';
}
const state = this.context.state;
if (state.type === 'figure' && !state.empty) {
return `Subplot ${state.index}, `;
}
return '';
}
}

/**
Expand All @@ -91,13 +143,17 @@ export class AnnounceXCommand extends AnnounceCommand {

/**
* Executes the command to display the X-axis label.
*
* Works at the figure lobby too: {@link resolveActiveTraceState} reads the
* active subplot's trace so `l x` announces the shared X label in
* multi-panel/facet figures, not just when a single trace is active.
*/
public execute(): void {
const state = this.context.state;
if (state.type === 'trace' && !state.empty) {
const traceState = this.resolveActiveTraceState();
if (traceState !== null) {
const text = this.textService.isTerse()
? state.xAxis
: `X label is ${state.xAxis}`;
? traceState.xAxis
: `${this.labelSourcePrefix()}X label is ${traceState.xAxis}`;
this.textViewModel.update(text);
} else {
const text = this.textService.isTerse()
Expand Down Expand Up @@ -134,13 +190,17 @@ export class AnnounceYCommand extends AnnounceCommand {

/**
* Executes the command to display the Y-axis label.
*
* Works at the figure lobby too: {@link resolveActiveTraceState} reads the
* active subplot's trace so `l y` announces the shared Y label in
* multi-panel/facet figures, not just when a single trace is active.
*/
public execute(): void {
const state = this.context.state;
if (state.type === 'trace' && !state.empty) {
const traceState = this.resolveActiveTraceState();
if (traceState !== null) {
const text = this.textService.isTerse()
? state.yAxis
: `Y label is ${state.yAxis}`;
? traceState.yAxis
: `${this.labelSourcePrefix()}Y label is ${traceState.yAxis}`;
this.textViewModel.update(text);
} else {
const text = this.textService.isTerse()
Expand Down Expand Up @@ -181,13 +241,17 @@ export class AnnounceZCommand extends AnnounceCommand {
* Executes the command to display the z (level) information.
* Checks for valid z-axis data which is in state.text.z with label and value properties.
* Supports: candlestick (trend), heatmap (z), segmented bars (level), multi-line (group).
*
* Works at the figure lobby too: {@link resolveActiveTraceState} reads the
* active subplot's trace so `l z` announces its level/group label in
* multi-panel/facet figures, degrading to "not available" for chart types
* (e.g. box, single-line) that have no z.
*/
public execute(): void {
const state = this.context.state;
const traceState = this.resolveActiveTraceState();

// Check if we have valid z-axis data
// state.text.z is optional and may be undefined for some chart types (e.g., box, single-line)
const zData = state.type === 'trace' && !state.empty ? state.text.z : undefined;
const zData = traceState?.text.z;
const hasValidZ = zData !== undefined
&& zData.value !== undefined
&& zData.value !== null
Expand All @@ -197,7 +261,7 @@ export class AnnounceZCommand extends AnnounceCommand {
const zLabel = zData!.label;
const text = this.textService.isTerse()
? zLabel
: `Z label is ${zLabel}`;
: `${this.labelSourcePrefix()}Z label is ${zLabel}`;
this.textViewModel.update(text);
} else {
const text = this.textService.isTerse()
Expand Down
9 changes: 8 additions & 1 deletion src/service/keybinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ const FIGURE_LABEL_KEYMAP = {
DEACTIVATE_FIGURE_LABEL_SCOPE: key(`escape`, 'Exit Label Mode', { showInHelp: false }),

// Description
// Mirrors TRACE_LABEL so the figure lobby exposes the same L-chord labels
// (l x / l y / l z / l t / l s / l c) as an individual subplot.
ANNOUNCE_X: key(`x`, 'Announce X Label'),
ANNOUNCE_Y: key(`y`, 'Announce Y Label'),
ANNOUNCE_Z: key(`z`, 'Announce Z Label'),
ANNOUNCE_TITLE: key(`t`, 'Announce Plot Title'),
ANNOUNCE_SUBTITLE: key(`s`, 'Announce Subtitle'),
ANNOUNCE_CAPTION: key(`c`, 'Announce Caption'),
Expand All @@ -189,7 +194,9 @@ const SUBPLOT_KEYMAP = {
ACTIVATE_FIGURE_LABEL_SCOPE: key(`l`, 'Access Labels', { showInHelp: false }),

// Description
ANNOUNCE_TITLE: key(`t`, 'Announce Title'),
// Title/subtitle/caption/axis labels are reached through the label scope
// (l t / l x / l y / l s / l c), matching an individual subplot where a bare
// 't' toggles Text mode rather than announcing the title.
ANNOUNCE_POINT: key(`space`, 'Announce Current Subplot'),
ANNOUNCE_POSITION: key(`p`, 'Announce Position'),

Expand Down
226 changes: 226 additions & 0 deletions test/command/announce-axis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import type { Context } from '@model/context';
import type { AudioService } from '@service/audio';
import type { DisplayService } from '@service/display';
import type { TextService } from '@service/text';
import type { TextViewModel } from '@state/viewModel/textViewModel';
import type { PlotState } from '@type/state';
import { AnnounceXCommand, AnnounceYCommand, AnnounceZCommand } from '@command/describe';
import { describe, expect, jest, test } from '@jest/globals';
import { Scope } from '@type/event';

/**
* Builds a figure-level PlotState (the multi-panel "figure lobby") whose active
* subplot's active trace carries the given axis labels.
*/
function figureLobbyState(xAxis: string, yAxis: string, index = 2): PlotState {
return {
empty: false,
type: 'figure',
index,
subplot: {
empty: false,
type: 'subplot',
trace: { empty: false, type: 'trace', xAxis, yAxis },
},
} as unknown as PlotState;
}

function createMockContext(state: PlotState): Context {
return {
scope: Scope.FIGURE_LABEL,
state,
} as unknown as Context;
}

function createMockTextViewModel(): TextViewModel {
return { update: jest.fn() } as unknown as TextViewModel;
}

function createMockTextService(terse = false): TextService {
return { isTerse: () => terse } as unknown as TextService;
}

function createMockAudioService(): AudioService {
return { playWarningToneIfEnabled: jest.fn() } as unknown as AudioService;
}

function createMockDisplayService(): DisplayService {
return { exitLabelScope: jest.fn() } as unknown as DisplayService;
}

describe('AnnounceXCommand at the figure lobby', () => {
test('announces the active subplot trace X label from figure-level state', () => {
const textViewModel = createMockTextViewModel();
const audioService = createMockAudioService();
const command = new AnnounceXCommand(
createMockContext(figureLobbyState('Month', 'Sales')),
textViewModel,
audioService,
createMockTextService(),
createMockDisplayService(),
);

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('Subplot 2, X label is Month');
expect(audioService.playWarningToneIfEnabled).not.toHaveBeenCalled();
});

test('announces just the value in terse mode (no subplot prefix)', () => {
const textViewModel = createMockTextViewModel();
const command = new AnnounceXCommand(
createMockContext(figureLobbyState('Month', 'Sales')),
textViewModel,
createMockAudioService(),
createMockTextService(true),
createMockDisplayService(),
);

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('Month');
});

test('still announces the trace X label at trace level (single-panel)', () => {
const textViewModel = createMockTextViewModel();
const state = {
empty: false,
type: 'trace',
xAxis: 'Year',
yAxis: 'Count',
} as unknown as PlotState;
const command = new AnnounceXCommand(
createMockContext(state),
textViewModel,
createMockAudioService(),
createMockTextService(),
createMockDisplayService(),
);
Comment on lines +118 to +124

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('X label is Year');
});

test('falls back to "not available" when the active trace is empty', () => {
const textViewModel = createMockTextViewModel();
const audioService = createMockAudioService();
const state = {
empty: false,
type: 'figure',
subplot: {
empty: false,
type: 'subplot',
trace: { empty: true, type: 'trace' },
},
} as unknown as PlotState;
const command = new AnnounceXCommand(
createMockContext(state),
textViewModel,
audioService,
createMockTextService(),
createMockDisplayService(),
);

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('X label is not available');
expect(audioService.playWarningToneIfEnabled).toHaveBeenCalled();
});
});

describe('AnnounceYCommand at the figure lobby', () => {
test('announces the active subplot trace Y label from figure-level state', () => {
const textViewModel = createMockTextViewModel();
const audioService = createMockAudioService();
const command = new AnnounceYCommand(
createMockContext(figureLobbyState('Month', 'Sales')),
textViewModel,
audioService,
createMockTextService(),
createMockDisplayService(),
);

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('Subplot 2, Y label is Sales');
expect(audioService.playWarningToneIfEnabled).not.toHaveBeenCalled();
});

test('falls back to "not available" when the active state is empty', () => {
const textViewModel = createMockTextViewModel();
const audioService = createMockAudioService();
const state = { empty: true, type: 'figure' } as unknown as PlotState;
const command = new AnnounceYCommand(
createMockContext(state),
textViewModel,
audioService,
createMockTextService(),
createMockDisplayService(),
);

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('Y label is not available');
expect(audioService.playWarningToneIfEnabled).toHaveBeenCalled();
});
});

describe('AnnounceZCommand at the figure lobby', () => {
test('announces the active subplot trace Z label from figure-level state', () => {
const textViewModel = createMockTextViewModel();
const audioService = createMockAudioService();
const state = {
empty: false,
type: 'figure',
index: 2,
subplot: {
empty: false,
type: 'subplot',
trace: {
empty: false,
type: 'trace',
text: { z: { label: 'Trend', value: 'up' } },
},
},
} as unknown as PlotState;
const command = new AnnounceZCommand(
createMockContext(state),
textViewModel,
audioService,
createMockTextService(),
createMockDisplayService(),
);

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('Subplot 2, Z label is Trend');
expect(audioService.playWarningToneIfEnabled).not.toHaveBeenCalled();
});

test('falls back to "not available" when the active trace has no z data', () => {
const textViewModel = createMockTextViewModel();
const audioService = createMockAudioService();
const state = {
empty: false,
type: 'figure',
subplot: {
empty: false,
type: 'subplot',
trace: { empty: false, type: 'trace', text: {} },
},
} as unknown as PlotState;
const command = new AnnounceZCommand(
createMockContext(state),
textViewModel,
audioService,
createMockTextService(),
createMockDisplayService(),
);

command.execute();

expect(textViewModel.update).toHaveBeenCalledWith('Z-axis is not available');
expect(audioService.playWarningToneIfEnabled).toHaveBeenCalled();
});
});
Loading