From 3fb7c5777874164a6a680d15420a47243ec61819 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 06:51:37 +0000 Subject: [PATCH] Wire full accessibility per the OpenPhysics convention - Per-screen *ScreenSummaryContent classes with live currentDetailsContent derived from each screen model, registered by the Screen wrappers - Explicit pdomOrder wrapper Node on all four ScreenViews - accessibleName on every interactive node; a11y strings restructured into per-screen groups (a11y.registration/blinkComparator/photometry/analyzer) in en/es/fr with typed StringManager getters - ApertureNode is keyboard-operable via KeyboardDragListener (focusable) - Localize three hardcoded English strings in AnalyzerScreenView - Move the PDM zoom-selection fill into VSPColors - Add .claude/settings.json (scenerystack plugin roll-out); update CLAUDE.md Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JgoPHsQnzifs5yiESGfbQL --- .claude/settings.json | 13 ++ CLAUDE.md | 13 +- src/VSPColors.ts | 6 + src/analyzer/AnalyzerScreen.ts | 11 +- .../view/AnalyzerScreenSummaryContent.ts | 40 ++++++ src/analyzer/view/AnalyzerScreenView.ts | 81 +++++++++-- src/blink-comparator/BlinkComparatorScreen.ts | 11 +- .../BlinkComparatorScreenSummaryContent.ts | 36 +++++ .../view/BlinkComparatorScreenView.ts | 132 ++++++++++++------ src/common/view/ApertureNode.ts | 19 ++- src/i18n/StringManager.ts | 16 +++ src/i18n/strings_en.json | 51 ++++++- src/i18n/strings_es.json | 51 ++++++- src/i18n/strings_fr.json | 51 ++++++- src/photometry/PhotometryScreen.ts | 11 +- .../view/PhotometryScreenSummaryContent.ts | 42 ++++++ src/photometry/view/PhotometryScreenView.ts | 32 ++++- src/registration/RegistrationScreen.ts | 11 +- .../view/RegistrationScreenSummaryContent.ts | 44 ++++++ .../view/RegistrationScreenView.ts | 34 ++++- 20 files changed, 587 insertions(+), 118 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/analyzer/view/AnalyzerScreenSummaryContent.ts create mode 100644 src/blink-comparator/view/BlinkComparatorScreenSummaryContent.ts create mode 100644 src/photometry/view/PhotometryScreenSummaryContent.ts create mode 100644 src/registration/view/RegistrationScreenSummaryContent.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f77de87 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "openphysics": { + "source": { + "source": "github", + "repo": "OpenPhysics/Baton" + } + } + }, + "enabledPlugins": { + "scenerystack@openphysics": true + } +} diff --git a/CLAUDE.md b/CLAUDE.md index f5131bc..b26759d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,9 +68,16 @@ Comparator), it uses a SceneryStack scene-graph `scale()` — model coordinate v ## Accessibility -The sim ships with PDOM names and `ResetAllButton` instrumented with tandem. Full a11y wiring -(screen summary, `pdomOrder`, keyboard help, `accessibleName` on interactive nodes) is in progress. -Conventions: [../Baton/ACCESSIBILITY.md](../Baton/ACCESSIBILITY.md). +Follows the shared [OpenPhysics accessibility convention](https://github.com/OpenPhysics/Baton/blob/main/ACCESSIBILITY.md). +Each screen has a `ScreenSummaryContent.ts` in its `view/` folder (live +`currentDetailsContent` derived from the screen model), registered by the `*Screen.ts` wrapper via +the view's `screenSummaryContent` option. Each `*ScreenView.ts` sets an explicit traversal order +through a wrapper `Node`'s `pdomOrder` (interactive nodes first, Reset All last), and every +interactive node carries an `accessibleName`. A11y strings live under per-screen keys in the `a11y` +block of each locale JSON (`a11y.registration`, `a11y.blinkComparator`, `a11y.photometry`, +`a11y.analyzer`), exposed via `StringManager.getA11yStrings()`. `ApertureNode` is +keyboard-operable via a `KeyboardDragListener` (arrow keys; Shift for fine steps); the shared +`VSPKeyboardHelpContent` documents per-screen keys. ## Decompiling the Flash sources diff --git a/src/VSPColors.ts b/src/VSPColors.ts index 46efd17..8a0789a 100644 --- a/src/VSPColors.ts +++ b/src/VSPColors.ts @@ -266,6 +266,12 @@ const VSPColors = { projector: new Color(163, 31, 32, 0.35), }), + /** Translucent rubber-band zoom-selection window on the PDM plot. */ + pdmZoomSelectionFillProperty: new ProfileColorProperty(VSPNamespace, "pdmZoomSelectionFill", { + default: new Color(120, 170, 255, 0.22), + projector: new Color(60, 110, 200, 0.18), + }), + /** Draggable difference-tool bars on the observations plot. */ deltaBarColorProperty: new ProfileColorProperty(VSPNamespace, "deltaBar", { default: new Color("#909090"), diff --git a/src/analyzer/AnalyzerScreen.ts b/src/analyzer/AnalyzerScreen.ts index 5c6be01..9e8f5d6 100644 --- a/src/analyzer/AnalyzerScreen.ts +++ b/src/analyzer/AnalyzerScreen.ts @@ -1,29 +1,26 @@ import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; import type { ScreenOptions } from "scenerystack/sim"; -import { Screen, ScreenSummaryContent } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; import type { Tandem } from "scenerystack/tandem"; import { VSPKeyboardHelpContent } from "../common/view/VSPKeyboardHelpContent.js"; import { StringManager } from "../i18n/StringManager.js"; import type { VSPPreferencesModel } from "../preferences/VSPPreferencesModel.js"; import VSPColors from "../VSPColors.js"; import { AnalyzerModel } from "./model/AnalyzerModel.js"; +import { AnalyzerScreenSummaryContent } from "./view/AnalyzerScreenSummaryContent.js"; import { AnalyzerScreenView } from "./view/AnalyzerScreenView.js"; type AnalyzerScreenOptions = ScreenOptions & { tandem: Tandem; preferences: VSPPreferencesModel }; export class AnalyzerScreen extends Screen { public constructor(options: AnalyzerScreenOptions) { - const summaryStrings = StringManager.getInstance().getA11yStrings().screenSummary.analyzer; + const summaryStrings = StringManager.getInstance().getAnalyzerA11yStrings().screenSummary; super( () => new AnalyzerModel(options.tandem.createTandem("model")), (model) => new AnalyzerScreenView(model, options.preferences, { tandem: options.tandem.createTandem("view"), - screenSummaryContent: new ScreenSummaryContent({ - playAreaContent: summaryStrings.playAreaStringProperty, - controlAreaContent: summaryStrings.controlAreaStringProperty, - interactionHintContent: summaryStrings.interactionHintStringProperty, - }), + screenSummaryContent: new AnalyzerScreenSummaryContent(model), }), optionize()( { diff --git a/src/analyzer/view/AnalyzerScreenSummaryContent.ts b/src/analyzer/view/AnalyzerScreenSummaryContent.ts new file mode 100644 index 0000000..e11297c --- /dev/null +++ b/src/analyzer/view/AnalyzerScreenSummaryContent.ts @@ -0,0 +1,40 @@ +/** + * AnalyzerScreenSummaryContent.ts + * + * The accessible screen summary read by screen readers (SceneryStack's + * Interactive Description). `currentDetailsContent` is a LIVE `DerivedProperty` + * over the model — the number of light-curve measurements and the current trial + * period — so a non-visual user can re-read the state at any time. + */ +import { DerivedProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { AnalyzerModel } from "../model/AnalyzerModel.js"; + +export class AnalyzerScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: AnalyzerModel) { + const a11y = StringManager.getInstance().getAnalyzerA11yStrings(); + + const currentDetails = new DerivedProperty( + [ + model.measurementsProperty, + model.trialPeriodProperty, + a11y.currentDetailsWithDataPatternStringProperty, + a11y.currentDetailsNoDataPatternStringProperty, + ], + (measurements, trialPeriod, withDataPattern, noDataPattern) => + measurements.length === 0 + ? noDataPattern + : withDataPattern + .replace("{{count}}", String(measurements.length)) + .replace("{{period}}", trialPeriod.toFixed(4)), + ); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: currentDetails, + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/analyzer/view/AnalyzerScreenView.ts b/src/analyzer/view/AnalyzerScreenView.ts index 9dc9393..9559309 100644 --- a/src/analyzer/view/AnalyzerScreenView.ts +++ b/src/analyzer/view/AnalyzerScreenView.ts @@ -78,6 +78,7 @@ export class AnalyzerScreenView extends ScreenView { const tandem = options?.tandem instanceof Tandem ? options.tandem : Tandem.OPT_OUT; const strings = StringManager.getInstance().getAnalyzerViewStrings(); const unitStrings = StringManager.getInstance().getUnitStrings(); + const a11yControls = StringManager.getInstance().getAnalyzerA11yStrings().controls; const showCrosshairProperty = new BooleanProperty(true); const showDifferenceToolProperty = new BooleanProperty(false); @@ -229,11 +230,12 @@ export class AnalyzerScreenView extends ScreenView { font: SMALL_FONT, baseColor: VSPColors.buttonColorProperty, listener: () => model.clearSelections(), + accessibleName: strings.clearSelectionStringProperty, }); const crosshairCheckbox = new Checkbox( showCrosshairProperty, new Text(strings.showCrosshairsStringProperty, { font: SMALL_FONT }), - { boxWidth: 14 }, + { boxWidth: 14, accessibleName: strings.showCrosshairsStringProperty }, ); const leftColumn = new VBox({ @@ -502,22 +504,41 @@ export class AnalyzerScreenView extends ScreenView { const modeRadioGroup = new AquaRadioButtonGroup( model.lightCurveModeProperty, [ - { value: "time", createNode: () => new Text(strings.timeStringProperty, { font: LABEL_FONT }) }, - { value: "phase", createNode: () => new Text(strings.phaseStringProperty, { font: LABEL_FONT }) }, + { + value: "time", + createNode: () => new Text(strings.timeStringProperty, { font: LABEL_FONT }), + options: { accessibleName: strings.timeStringProperty }, + }, + { + value: "phase", + createNode: () => new Text(strings.phaseStringProperty, { font: LABEL_FONT }), + options: { accessibleName: strings.phaseStringProperty }, + }, ], - { orientation: "horizontal", spacing: 16, radioButtonOptions: { radius: 7 } }, + { + orientation: "horizontal", + spacing: 16, + radioButtonOptions: { radius: 7 }, + accessibleName: a11yControls.lightCurveModeStringProperty, + }, ); - const phaseOffsetControl = new NumberControl("phase offset (days)", model.phaseOffsetProperty, PHASE_OFFSET_RANGE, { - titleNodeOptions: { font: SMALL_FONT }, - numberDisplayOptions: { textOptions: { font: SMALL_FONT } }, - sliderOptions: { trackSize: new Dimension2(120, 3) }, - layoutFunction: NumberControl.createLayoutFunction1(), - }); + const phaseOffsetControl = new NumberControl( + strings.phaseOffsetStringProperty, + model.phaseOffsetProperty, + PHASE_OFFSET_RANGE, + { + titleNodeOptions: { font: SMALL_FONT }, + numberDisplayOptions: { textOptions: { font: SMALL_FONT } }, + sliderOptions: { trackSize: new Dimension2(120, 3) }, + layoutFunction: NumberControl.createLayoutFunction1(), + accessibleName: strings.phaseOffsetStringProperty, + }, + ); const differenceToolCheckbox = new Checkbox( showDifferenceToolProperty, - new Text("show difference tool", { font: LABEL_FONT }), - { boxWidth: 16 }, + new Text(strings.showDifferenceToolStringProperty, { font: LABEL_FONT }), + { boxWidth: 16, accessibleName: strings.showDifferenceToolStringProperty }, ); // Assemble observations panel (title, [y-label | chart], x-label, radios). @@ -577,7 +598,7 @@ export class AnalyzerScreenView extends ScreenView { const pdmLine = new LinePlot(pdmTransform, [], { stroke: VSPColors.lightCurveColorProperty, lineWidth: 1.5 }); const periodMarker = new Line(0, 0, 0, PDM_H, { stroke: VSPColors.pdmMarkerColorProperty, lineWidth: 2 }); const pdmZoomSelection = new Rectangle(0, 0, 0, PDM_H, { - fill: "rgba(120, 170, 255, 0.22)", + fill: VSPColors.pdmZoomSelectionFillProperty, stroke: VSPColors.pdmMarkerColorProperty, lineWidth: 1, visible: false, @@ -700,13 +721,14 @@ export class AnalyzerScreenView extends ScreenView { }, sliderOptions: { trackSize: new Dimension2(120, 3) }, layoutFunction: NumberControl.createLayoutFunction1(), + accessibleName: strings.trialPeriodAxisStringProperty, }, ); const bestPeriodReadout = new Text( - new DerivedProperty([model.pdmScanResultsProperty], (scan) => { + new DerivedProperty([model.pdmScanResultsProperty, strings.bestPeriodPatternStringProperty], (scan, pattern) => { const best = bestPeriod(scan); - return best === null ? "" : `best: ${best.toFixed(4)} d`; + return best === null ? "" : pattern.replace("{{value}}", best.toFixed(4)); }), { font: SMALL_FONT, fill: VSPColors.mutedTextColorProperty }, ); @@ -715,25 +737,30 @@ export class AnalyzerScreenView extends ScreenView { font: SMALL_FONT, baseColor: VSPColors.buttonActiveColorProperty, listener: () => model.zoomInAroundPeriod(), + accessibleName: strings.zoomInAroundPeriodStringProperty, }); const zoomOutButton = new TextPushButton(strings.zoomOutAroundPeriodStringProperty, { font: SMALL_FONT, baseColor: VSPColors.buttonActiveColorProperty, listener: () => model.zoomOutAroundPeriod(), + accessibleName: strings.zoomOutAroundPeriodStringProperty, }); const fullButton = new TextPushButton(strings.zoomToFullStringProperty, { font: SMALL_FONT, baseColor: VSPColors.buttonColorProperty, listener: () => model.zoomToFull(), + accessibleName: strings.zoomToFullStringProperty, }); const undoButton = new TextPushButton(strings.undoLastZoomStringProperty, { font: SMALL_FONT, baseColor: VSPColors.buttonColorProperty, listener: () => model.undoLastZoom(), + accessibleName: strings.undoLastZoomStringProperty, }); const snapButton = new TextPushButton(strings.snapToMinStringProperty, { font: SMALL_FONT, baseColor: VSPColors.buttonSnapColorProperty, + accessibleName: strings.snapToMinStringProperty, listener: () => { const best = bestPeriod(model.pdmScanResultsProperty.value); if (best !== null) { @@ -787,5 +814,29 @@ export class AnalyzerScreenView extends ScreenView { tandem: tandem.createTandem("resetAllButton"), }); this.addChild(resetAllButton); + + // ----------------------------------------------------------------------- + // Accessibility: keyboard / reading traversal order. ScreenView throws if + // pdomOrder is set on itself, so a wrapper Node "borrows" the interactive + // nodes — star-field controls, then plot controls, then PDM, Reset All last. + // ----------------------------------------------------------------------- + this.addChild( + new Node({ + pdomOrder: [ + clearButton, + crosshairCheckbox, + modeRadioGroup, + phaseOffsetControl, + differenceToolCheckbox, + periodControl, + zoomInButton, + zoomOutButton, + fullButton, + undoButton, + snapButton, + resetAllButton, + ], + }), + ); } } diff --git a/src/blink-comparator/BlinkComparatorScreen.ts b/src/blink-comparator/BlinkComparatorScreen.ts index b3abc1a..16d36d7 100644 --- a/src/blink-comparator/BlinkComparatorScreen.ts +++ b/src/blink-comparator/BlinkComparatorScreen.ts @@ -1,29 +1,26 @@ import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; import type { ScreenOptions } from "scenerystack/sim"; -import { Screen, ScreenSummaryContent } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; import type { Tandem } from "scenerystack/tandem"; import { VSPKeyboardHelpContent } from "../common/view/VSPKeyboardHelpContent.js"; import { StringManager } from "../i18n/StringManager.js"; import type { VSPPreferencesModel } from "../preferences/VSPPreferencesModel.js"; import VSPColors from "../VSPColors.js"; import { BlinkComparatorModel } from "./model/BlinkComparatorModel.js"; +import { BlinkComparatorScreenSummaryContent } from "./view/BlinkComparatorScreenSummaryContent.js"; import { BlinkComparatorScreenView } from "./view/BlinkComparatorScreenView.js"; type BlinkComparatorScreenOptions = ScreenOptions & { tandem: Tandem; preferences: VSPPreferencesModel }; export class BlinkComparatorScreen extends Screen { public constructor(options: BlinkComparatorScreenOptions) { - const summaryStrings = StringManager.getInstance().getA11yStrings().screenSummary.blinkComparator; + const summaryStrings = StringManager.getInstance().getBlinkComparatorA11yStrings().screenSummary; super( () => new BlinkComparatorModel(options.tandem.createTandem("model")), (model) => new BlinkComparatorScreenView(model, options.preferences, { tandem: options.tandem.createTandem("view"), - screenSummaryContent: new ScreenSummaryContent({ - playAreaContent: summaryStrings.playAreaStringProperty, - controlAreaContent: summaryStrings.controlAreaStringProperty, - interactionHintContent: summaryStrings.interactionHintStringProperty, - }), + screenSummaryContent: new BlinkComparatorScreenSummaryContent(model), }), optionize()( { diff --git a/src/blink-comparator/view/BlinkComparatorScreenSummaryContent.ts b/src/blink-comparator/view/BlinkComparatorScreenSummaryContent.ts new file mode 100644 index 0000000..da92201 --- /dev/null +++ b/src/blink-comparator/view/BlinkComparatorScreenSummaryContent.ts @@ -0,0 +1,36 @@ +/** + * BlinkComparatorScreenSummaryContent.ts + * + * The accessible screen summary read by screen readers (SceneryStack's + * Interactive Description). `currentDetailsContent` is a LIVE `DerivedProperty` + * over the model — how many observations are queued and whether blinking is + * running — so a non-visual user can re-read the state at any time. + */ +import { DerivedProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { BlinkComparatorModel } from "../model/BlinkComparatorModel.js"; + +export class BlinkComparatorScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: BlinkComparatorModel) { + const a11y = StringManager.getInstance().getBlinkComparatorA11yStrings(); + + const currentDetails = new DerivedProperty( + [ + model.blinkQueue.lengthProperty, + model.isBlinkingProperty, + a11y.currentDetailsBlinkingPatternStringProperty, + a11y.currentDetailsPausedPatternStringProperty, + ], + (count, isBlinking, blinkingPattern, pausedPattern) => + (isBlinking ? blinkingPattern : pausedPattern).replace("{{count}}", String(count)), + ); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: currentDetails, + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/blink-comparator/view/BlinkComparatorScreenView.ts b/src/blink-comparator/view/BlinkComparatorScreenView.ts index a43858d..1a39a47 100644 --- a/src/blink-comparator/view/BlinkComparatorScreenView.ts +++ b/src/blink-comparator/view/BlinkComparatorScreenView.ts @@ -73,6 +73,7 @@ export class BlinkComparatorScreenView extends ScreenView { const tandem = options?.tandem instanceof Tandem ? options.tandem : Tandem.OPT_OUT; const strings = StringManager.getInstance().getBlinkViewStrings(); const unitStrings = StringManager.getInstance().getUnitStrings(); + const a11yControls = StringManager.getInstance().getBlinkComparatorA11yStrings().controls; // Localized "{{value}} days" label for a given observation index. const makeEpochDaysProperty = (obsIndex: number): ReadOnlyProperty => { @@ -190,7 +191,7 @@ export class BlinkComparatorScreenView extends ScreenView { const crosshairCheckbox = new Checkbox( model.showCrosshairProperty, new Text(strings.showCrosshairsStringProperty, { font: LABEL_FONT }), - { boxWidth: 16 }, + { boxWidth: 16, accessibleName: strings.showCrosshairsStringProperty }, ); const fieldFooter = new HBox({ @@ -229,6 +230,7 @@ export class BlinkComparatorScreenView extends ScreenView { const addButton = new TextPushButton(strings.addToQueueStringProperty, { font: LABEL_FONT, baseColor: VSPColors.buttonAddColorProperty, + accessibleName: strings.addToQueueStringProperty, listener: () => { const selected = model.selectedObsIndexProperty.value; model.addSelectedToQueue(); @@ -242,6 +244,7 @@ export class BlinkComparatorScreenView extends ScreenView { const removeButton = new TextPushButton(strings.removeFromQueueStringProperty, { font: LABEL_FONT, baseColor: VSPColors.buttonNeutralColorProperty, + accessibleName: strings.removeFromQueueStringProperty, listener: () => { if (model.blinkQueue.length > 0) { const obsIndex = @@ -253,6 +256,52 @@ export class BlinkComparatorScreenView extends ScreenView { }, }); + const makeObservationRow = (obsIndex: number, isSelected: boolean): Node => { + const isInQueue = model.blinkQueue.includes(obsIndex); + const rowBackground = new Rectangle(0, 0, TABLE_WIDTH, TABLE_ROW_HEIGHT, { + fill: isSelected ? VSPColors.selectionFillProperty : VSPColors.tableRowFillProperty, + stroke: VSPColors.tableStrokeProperty, + lineWidth: 1, + }); + const epochProp = makeEpochDaysProperty(obsIndex); + observationRowProps.push(epochProp); + const rowChildren: Node[] = [rowBackground]; + // Show a checkmark indicator for observations already in the queue. + if (isInQueue) { + rowChildren.push( + new Text("✓", { + font: LABEL_FONT, + fill: VSPColors.queueMarkerColorProperty, + left: 2, + centerY: TABLE_ROW_HEIGHT / 2, + }), + ); + } + rowChildren.push( + new Text(epochProp, { + font: LABEL_FONT, + fill: isSelected ? VSPColors.panelTextColorProperty : VSPColors.mutedTextColorProperty, + left: isInQueue ? 16 : 8, + centerY: TABLE_ROW_HEIGHT / 2, + }), + ); + const rowName = new PatternStringProperty(a11yControls.observationPatternStringProperty, { + epoch: OBSERVATIONS[obsIndex]?.epoch.toFixed(4) ?? "", + }); + observationRowProps.push(rowName); + const row = new Node({ + cursor: "pointer", + children: rowChildren, + tagName: "button", + accessibleName: rowName, + }); + const selectRow = () => { + model.selectedObsIndexProperty.value = obsIndex; + }; + row.addInputListener({ down: selectRow, click: selectRow }); + return row; + }; + const rebuildObservationList = () => { for (const child of observationRows.children.slice()) { child.dispose(); @@ -276,45 +325,7 @@ export class BlinkComparatorScreenView extends ScreenView { if (obsIndex >= OBSERVATIONS.length) { break; } - const isSelected = obsIndex === selected; - const isInQueue = model.blinkQueue.includes(obsIndex); - const rowBackground = new Rectangle(0, 0, TABLE_WIDTH, TABLE_ROW_HEIGHT, { - fill: isSelected ? VSPColors.selectionFillProperty : VSPColors.tableRowFillProperty, - stroke: VSPColors.tableStrokeProperty, - lineWidth: 1, - }); - const epochProp = makeEpochDaysProperty(obsIndex); - observationRowProps.push(epochProp); - const rowChildren: Node[] = [rowBackground]; - // Show a checkmark indicator for observations already in the queue. - if (isInQueue) { - rowChildren.push( - new Text("✓", { - font: LABEL_FONT, - fill: VSPColors.queueMarkerColorProperty, - left: 2, - centerY: TABLE_ROW_HEIGHT / 2, - }), - ); - } - rowChildren.push( - new Text(epochProp, { - font: LABEL_FONT, - fill: isSelected ? VSPColors.panelTextColorProperty : VSPColors.mutedTextColorProperty, - left: isInQueue ? 16 : 8, - centerY: TABLE_ROW_HEIGHT / 2, - }), - ); - const row = new Node({ - cursor: "pointer", - children: rowChildren, - }); - row.addInputListener({ - down: () => { - model.selectedObsIndexProperty.value = obsIndex; - }, - }); - rows.push(row); + rows.push(makeObservationRow(obsIndex, obsIndex === selected)); } observationRows.children = rows; }; @@ -331,6 +342,7 @@ export class BlinkComparatorScreenView extends ScreenView { xMargin: 3, yMargin: 2, listener: () => scrollObservationList(-1), + accessibleName: a11yControls.scrollUpStringProperty, }); const obsScrollDownButton = new RectangularPushButton({ content: new Text("▼", { font: SMALL_FONT }), @@ -338,6 +350,7 @@ export class BlinkComparatorScreenView extends ScreenView { xMargin: 3, yMargin: 2, listener: () => scrollObservationList(1), + accessibleName: a11yControls.scrollDownStringProperty, }); const observationTable = new HBox({ @@ -392,11 +405,16 @@ export class BlinkComparatorScreenView extends ScreenView { const row = new Node({ cursor: obsIndex === undefined ? "default" : "pointer", children }); if (obsIndex !== undefined) { - row.addInputListener({ - down: () => { - model.queuePositionProperty.value = i; - }, + const rowName = new PatternStringProperty(a11yControls.queuedObservationPatternStringProperty, { + epoch: OBSERVATIONS[obsIndex]?.epoch.toFixed(4) ?? "", }); + queueRowProps.push(rowName); + row.tagName = "button"; + row.accessibleName = rowName; + const selectQueueRow = () => { + model.queuePositionProperty.value = i; + }; + row.addInputListener({ down: selectQueueRow, click: selectQueueRow }); } rows.push(row); } @@ -412,6 +430,7 @@ export class BlinkComparatorScreenView extends ScreenView { const previousButton = new TextPushButton("<", { font: LABEL_FONT, baseColor: VSPColors.buttonNeutralColorProperty, + accessibleName: a11yControls.previousStringProperty, listener: () => { if (model.blinkQueue.length > 0) { model.queuePositionProperty.value = @@ -422,6 +441,7 @@ export class BlinkComparatorScreenView extends ScreenView { const blinkButton = new TextPushButton(strings.blinkStringProperty, { font: LABEL_FONT, baseColor: VSPColors.buttonNeutralColorProperty, + accessibleName: strings.blinkStringProperty, listener: () => { model.isBlinkingProperty.value = !model.isBlinkingProperty.value; }, @@ -432,6 +452,7 @@ export class BlinkComparatorScreenView extends ScreenView { const nextButton = new TextPushButton(">", { font: LABEL_FONT, baseColor: VSPColors.buttonNeutralColorProperty, + accessibleName: a11yControls.nextStringProperty, listener: () => { if (model.blinkQueue.length > 0) { model.queuePositionProperty.value = (model.queuePositionProperty.value + 1) % model.blinkQueue.length; @@ -441,6 +462,7 @@ export class BlinkComparatorScreenView extends ScreenView { const blinkSpeedSlider = new HSlider(model.blinkIntervalMsProperty, BLINK_INTERVAL_RANGE, { tandem: tandem.createTandem("blinkSpeedSlider"), + accessibleName: a11yControls.blinkSpeedStringProperty, }); blinkSpeedSlider.addMajorTick(BLINK_INTERVAL_RANGE.min, new Text(strings.fastStringProperty, { font: SMALL_FONT })); blinkSpeedSlider.addMajorTick(BLINK_INTERVAL_RANGE.max, new Text(strings.slowStringProperty, { font: SMALL_FONT })); @@ -535,6 +557,28 @@ export class BlinkComparatorScreenView extends ScreenView { tandem: tandem.createTandem("resetAllButton"), }); this.addChild(resetAllButton); + + // ----------------------------------------------------------------------- + // Accessibility: keyboard / reading traversal order. ScreenView throws if + // pdomOrder is set on itself, so a wrapper Node "borrows" the interactive + // nodes — list/table controls first, then playback, Reset All last. + // ----------------------------------------------------------------------- + this.addChild( + new Node({ + pdomOrder: [ + observationTable, + addButton, + removeButton, + queueTable, + previousButton, + blinkButton, + nextButton, + blinkSpeedSlider, + crosshairCheckbox, + resetAllButton, + ], + }), + ); } public override step(dt: number): void { diff --git a/src/common/view/ApertureNode.ts b/src/common/view/ApertureNode.ts index 99da971..044ed24 100644 --- a/src/common/view/ApertureNode.ts +++ b/src/common/view/ApertureNode.ts @@ -7,6 +7,8 @@ * * The whole node is positioned at `centerProperty`; a {@link DragListener} * updates that property (clamped to the field bounds) while the student drags. + * A {@link KeyboardDragListener} provides the equivalent arrow-key operation + * (the node is focusable), per the OpenPhysics accessibility convention. */ import type { TReadOnlyProperty } from "scenerystack/axon"; @@ -15,7 +17,7 @@ import type { Bounds2, Vector2, Vector2Property } from "scenerystack/dot"; import { optionize } from "scenerystack/phet-core"; import { ModelViewTransform2 } from "scenerystack/phetcommon"; import type { NodeOptions, TColor } from "scenerystack/scenery"; -import { Circle, DragListener, Node, Text } from "scenerystack/scenery"; +import { Circle, DragListener, KeyboardDragListener, Node, Text } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import VSPConstants from "../../VSPConstants.js"; @@ -53,6 +55,10 @@ export class ApertureNode extends Node { cursor: "pointer", // Default to identity — VSP renders the field at 1:1 scale so model px = view px. modelViewTransform: ModelViewTransform2.createIdentity(), + // Keyboard-operable: focusable in the PDOM so the KeyboardDragListener below + // can move the aperture with the arrow keys. Callers pass `accessibleName`. + tagName: "div", + focusable: true, }, providedOptions, ); @@ -121,5 +127,16 @@ export class ApertureNode extends Node { useParentOffset: true, }), ); + + // Arrow-key dragging (Shift for fine positioning), same model-space clamping. + this.addInputListener( + new KeyboardDragListener({ + positionProperty: centerProperty, + dragBoundsProperty: new Property(options.dragBounds), + transform: mvt, + dragSpeed: 100, + shiftDragSpeed: 20, + }), + ); } } diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index 4aa4770..ac4ad6f 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -72,6 +72,22 @@ export class StringManager { return stringProperties.a11y; } + public getRegistrationA11yStrings(): typeof stringProperties.a11y.registration { + return stringProperties.a11y.registration; + } + + public getBlinkComparatorA11yStrings(): typeof stringProperties.a11y.blinkComparator { + return stringProperties.a11y.blinkComparator; + } + + public getPhotometryA11yStrings(): typeof stringProperties.a11y.photometry { + return stringProperties.a11y.photometry; + } + + public getAnalyzerA11yStrings(): typeof stringProperties.a11y.analyzer { + return stringProperties.a11y.analyzer; + } + public getPreferences(): typeof stringProperties.preferences { return stringProperties.preferences; } diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index b06ed73..9ccc667 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -126,7 +126,10 @@ "undoLastZoom": "undo last zoom", "snapToMin": "snap to min θ", "thetaAxis": "θ (dispersion)", - "trialPeriodAxis": "trial period (days)" + "trialPeriodAxis": "trial period (days)", + "phaseOffset": "phase offset (days)", + "showDifferenceTool": "show difference tool", + "bestPeriodPattern": "best: {{value}} d" } }, "messages": { @@ -137,26 +140,62 @@ "insufficientData": "Insufficient data to determine period. Add more observations." }, "a11y": { - "screenSummary": { - "registration": { + "registration": { + "screenSummary": { "playArea": "The play area shows a reference CCD image of a star field alongside a second image taken at a different epoch. Both images contain identical stars that can be aligned.", "controlArea": "Controls let you shift and rotate the second image to align it with the reference. A Reset All button returns everything to the starting state.", "interactionHint": "Drag the comparison image to align the star patterns, then use fine-tune controls for precise registration." }, - "blinkComparator": { + "currentDetailsPattern": "Star field {{number}} is on top, offset {{x}} pixels horizontally and {{y}} pixels vertically.", + "controls": { + "shownPattern": "Show star field {{number}}", + "onTopPattern": "Put star field {{number}} on top", + "xOffsetPattern": "Star field {{number}} horizontal offset", + "yOffsetPattern": "Star field {{number}} vertical offset" + } + }, + "blinkComparator": { + "screenSummary": { "playArea": "The play area alternates between two registered CCD images of the same star field. Variable stars appear to flicker when the images are blinked.", "controlArea": "Adjust the blink speed and select which pair of images to compare. Click a star to mark it as a candidate variable.", "interactionHint": "Watch for stars that change brightness between frames. Click a blinking star to identify it." }, - "photometry": { + "currentDetailsBlinkingPattern": "The blinking queue holds {{count}} observations and is blinking.", + "currentDetailsPausedPattern": "The blinking queue holds {{count}} observations and is not blinking.", + "controls": { + "scrollUp": "Scroll observations up", + "scrollDown": "Scroll observations down", + "previous": "Previous queued observation", + "next": "Next queued observation", + "blinkSpeed": "Blink speed", + "observationPattern": "Select observation at epoch {{epoch}}", + "queuedObservationPattern": "Go to queued observation at epoch {{epoch}}" + } + }, + "photometry": { + "screenSummary": { "playArea": "The play area shows a CCD image with circular apertures placed on stars. Counts within the aperture are summed to measure stellar brightness.", "controlArea": "Set aperture and sky annulus sizes. Select one star as the variable and others as comparison stars. Measured instrumental magnitudes appear in the table.", "interactionHint": "Click a star to place or move an aperture. Adjust aperture size and measure brightness for the variable and comparison stars." }, - "analyzer": { + "currentDetailsMeasuredPattern": "Observation {{epoch}} of {{total}} is displayed. The measured magnitude difference is {{delta}}.", + "currentDetailsNoMeasurementPattern": "Observation {{epoch}} of {{total}} is displayed. No magnitude difference is available yet.", + "controls": { + "epochPicker": "Observation epoch", + "aperture1": "Aperture 1", + "aperture2": "Aperture 2" + } + }, + "analyzer": { + "screenSummary": { "playArea": "The play area shows a light curve: instrumental magnitude plotted against Julian Date. Use phase-folding to reveal the variability period.", "controlArea": "Enter the trial period and adjust phase wrapping to fold the light curve. Best-fit period minimizes scatter in the folded curve.", "interactionHint": "Enter a trial period and observe how the data points align. Adjust until the folded light curve is smooth." + }, + "currentDetailsWithDataPattern": "The light curve has {{count}} measurements. The trial period is {{period}} days.", + "currentDetailsNoDataPattern": "No light curve yet. Select a variable star and a comparison star in the star field.", + "controls": { + "lightCurveMode": "Light curve mode" } } }, diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json index 9d9f428..5c55e59 100644 --- a/src/i18n/strings_es.json +++ b/src/i18n/strings_es.json @@ -126,7 +126,10 @@ "undoLastZoom": "deshacer último zoom", "snapToMin": "ajustar a mín θ", "thetaAxis": "θ (dispersión)", - "trialPeriodAxis": "período de prueba (días)" + "trialPeriodAxis": "período de prueba (días)", + "phaseOffset": "desfase (días)", + "showDifferenceTool": "mostrar herramienta de diferencia", + "bestPeriodPattern": "mejor: {{value}} d" } }, "messages": { @@ -137,26 +140,62 @@ "insufficientData": "Datos insuficientes para determinar el período. Agregue más observaciones." }, "a11y": { - "screenSummary": { - "registration": { + "registration": { + "screenSummary": { "playArea": "El área de juego muestra una imagen CCD de referencia de un campo estelar junto con una segunda imagen tomada en una época diferente.", "controlArea": "Los controles permiten desplazar y rotar la segunda imagen para alinearla con la referencia.", "interactionHint": "Arrastre la imagen de comparación para alinear los patrones de estrellas." }, - "blinkComparator": { + "currentDetailsPattern": "El campo estelar {{number}} está encima, desplazado {{x}} píxeles en horizontal y {{y}} píxeles en vertical.", + "controls": { + "shownPattern": "Mostrar el campo estelar {{number}}", + "onTopPattern": "Poner el campo estelar {{number}} encima", + "xOffsetPattern": "Desplazamiento horizontal del campo estelar {{number}}", + "yOffsetPattern": "Desplazamiento vertical del campo estelar {{number}}" + } + }, + "blinkComparator": { + "screenSummary": { "playArea": "El área de juego alterna entre dos imágenes CCD registradas del mismo campo estelar.", "controlArea": "Ajuste la velocidad de parpadeo y seleccione el par de imágenes a comparar.", "interactionHint": "Observe las estrellas que cambian de brillo entre cuadros." }, - "photometry": { + "currentDetailsBlinkingPattern": "La cola de parpadeo contiene {{count}} observaciones y está parpadeando.", + "currentDetailsPausedPattern": "La cola de parpadeo contiene {{count}} observaciones y no está parpadeando.", + "controls": { + "scrollUp": "Desplazar las observaciones hacia arriba", + "scrollDown": "Desplazar las observaciones hacia abajo", + "previous": "Observación anterior de la cola", + "next": "Observación siguiente de la cola", + "blinkSpeed": "Velocidad de parpadeo", + "observationPattern": "Seleccionar la observación de la época {{epoch}}", + "queuedObservationPattern": "Ir a la observación en cola de la época {{epoch}}" + } + }, + "photometry": { + "screenSummary": { "playArea": "El área de juego muestra una imagen CCD con aperturas circulares colocadas sobre estrellas.", "controlArea": "Establezca los tamaños de apertura y anillo de cielo.", "interactionHint": "Haga clic en una estrella para colocar o mover una apertura." }, - "analyzer": { + "currentDetailsMeasuredPattern": "Se muestra la observación {{epoch}} de {{total}}. La diferencia de magnitud medida es {{delta}}.", + "currentDetailsNoMeasurementPattern": "Se muestra la observación {{epoch}} de {{total}}. Todavía no hay diferencia de magnitud disponible.", + "controls": { + "epochPicker": "Época de observación", + "aperture1": "Apertura 1", + "aperture2": "Apertura 2" + } + }, + "analyzer": { + "screenSummary": { "playArea": "El área de juego muestra una curva de luz: magnitud instrumental graficada contra fecha juliana.", "controlArea": "Ingrese el período de prueba y ajuste el envolvimiento de fase.", "interactionHint": "Ingrese un período de prueba y observe cómo se alinean los puntos de datos." + }, + "currentDetailsWithDataPattern": "La curva de luz tiene {{count}} mediciones. El período de prueba es {{period}} días.", + "currentDetailsNoDataPattern": "Todavía no hay curva de luz. Selecciona una estrella variable y una de comparación en el campo estelar.", + "controls": { + "lightCurveMode": "Modo de curva de luz" } } }, diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index a3c92f1..f8225d3 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -126,7 +126,10 @@ "undoLastZoom": "annuler le dernier zoom", "snapToMin": "aligner sur θ min", "thetaAxis": "θ (dispersion)", - "trialPeriodAxis": "période d'essai (jours)" + "trialPeriodAxis": "période d'essai (jours)", + "phaseOffset": "décalage de phase (jours)", + "showDifferenceTool": "afficher l'outil de différence", + "bestPeriodPattern": "meilleur : {{value}} j" } }, "messages": { @@ -137,26 +140,62 @@ "insufficientData": "Données insuffisantes pour déterminer la période. Ajoutez plus d'observations." }, "a11y": { - "screenSummary": { - "registration": { + "registration": { + "screenSummary": { "playArea": "La zone de jeu affiche une image CCD de référence d'un champ d'étoiles à côté d'une deuxième image prise à une époque différente.", "controlArea": "Les commandes permettent de déplacer et de faire pivoter la deuxième image pour l'aligner sur la référence.", "interactionHint": "Faites glisser l'image de comparaison pour aligner les motifs d'étoiles." }, - "blinkComparator": { + "currentDetailsPattern": "Le champ d'étoiles {{number}} est au-dessus, décalé de {{x}} pixels horizontalement et de {{y}} pixels verticalement.", + "controls": { + "shownPattern": "Afficher le champ d'étoiles {{number}}", + "onTopPattern": "Placer le champ d'étoiles {{number}} au-dessus", + "xOffsetPattern": "Décalage horizontal du champ d'étoiles {{number}}", + "yOffsetPattern": "Décalage vertical du champ d'étoiles {{number}}" + } + }, + "blinkComparator": { + "screenSummary": { "playArea": "La zone de jeu alterne entre deux images CCD recalées du même champ d'étoiles.", "controlArea": "Réglez la vitesse de clignotement et sélectionnez la paire d'images à comparer.", "interactionHint": "Observez les étoiles qui changent de luminosité entre les images." }, - "photometry": { + "currentDetailsBlinkingPattern": "La file de clignotement contient {{count}} observations et clignote.", + "currentDetailsPausedPattern": "La file de clignotement contient {{count}} observations et ne clignote pas.", + "controls": { + "scrollUp": "Faire défiler les observations vers le haut", + "scrollDown": "Faire défiler les observations vers le bas", + "previous": "Observation précédente de la file", + "next": "Observation suivante de la file", + "blinkSpeed": "Vitesse de clignotement", + "observationPattern": "Sélectionner l'observation à l'époque {{epoch}}", + "queuedObservationPattern": "Aller à l'observation en file à l'époque {{epoch}}" + } + }, + "photometry": { + "screenSummary": { "playArea": "La zone de jeu affiche une image CCD avec des ouvertures circulaires placées sur des étoiles.", "controlArea": "Définissez les tailles d'ouverture et d'anneau de ciel.", "interactionHint": "Cliquez sur une étoile pour placer ou déplacer une ouverture." }, - "analyzer": { + "currentDetailsMeasuredPattern": "L'observation {{epoch}} sur {{total}} est affichée. La différence de magnitude mesurée est {{delta}}.", + "currentDetailsNoMeasurementPattern": "L'observation {{epoch}} sur {{total}} est affichée. Aucune différence de magnitude n'est encore disponible.", + "controls": { + "epochPicker": "Époque d'observation", + "aperture1": "Ouverture 1", + "aperture2": "Ouverture 2" + } + }, + "analyzer": { + "screenSummary": { "playArea": "La zone de jeu affiche une courbe de lumière : magnitude instrumentale en fonction de la date julienne.", "controlArea": "Entrez la période d'essai et ajustez l'enroulement de phase.", "interactionHint": "Entrez une période d'essai et observez comment les points de données s'alignent." + }, + "currentDetailsWithDataPattern": "La courbe de lumière compte {{count}} mesures. La période d'essai est de {{period}} jours.", + "currentDetailsNoDataPattern": "Pas encore de courbe de lumière. Sélectionnez une étoile variable et une étoile de comparaison dans le champ d'étoiles.", + "controls": { + "lightCurveMode": "Mode de courbe de lumière" } } }, diff --git a/src/photometry/PhotometryScreen.ts b/src/photometry/PhotometryScreen.ts index f69ec8b..973f597 100644 --- a/src/photometry/PhotometryScreen.ts +++ b/src/photometry/PhotometryScreen.ts @@ -1,29 +1,26 @@ import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; import type { ScreenOptions } from "scenerystack/sim"; -import { Screen, ScreenSummaryContent } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; import type { Tandem } from "scenerystack/tandem"; import { VSPKeyboardHelpContent } from "../common/view/VSPKeyboardHelpContent.js"; import { StringManager } from "../i18n/StringManager.js"; import type { VSPPreferencesModel } from "../preferences/VSPPreferencesModel.js"; import VSPColors from "../VSPColors.js"; import { PhotometryModel } from "./model/PhotometryModel.js"; +import { PhotometryScreenSummaryContent } from "./view/PhotometryScreenSummaryContent.js"; import { PhotometryScreenView } from "./view/PhotometryScreenView.js"; type PhotometryScreenOptions = ScreenOptions & { tandem: Tandem; preferences: VSPPreferencesModel }; export class PhotometryScreen extends Screen { public constructor(options: PhotometryScreenOptions) { - const summaryStrings = StringManager.getInstance().getA11yStrings().screenSummary.photometry; + const summaryStrings = StringManager.getInstance().getPhotometryA11yStrings().screenSummary; super( () => new PhotometryModel(options.tandem.createTandem("model")), (model) => new PhotometryScreenView(model, options.preferences, { tandem: options.tandem.createTandem("view"), - screenSummaryContent: new ScreenSummaryContent({ - playAreaContent: summaryStrings.playAreaStringProperty, - controlAreaContent: summaryStrings.controlAreaStringProperty, - interactionHintContent: summaryStrings.interactionHintStringProperty, - }), + screenSummaryContent: new PhotometryScreenSummaryContent(model), }), optionize()( { diff --git a/src/photometry/view/PhotometryScreenSummaryContent.ts b/src/photometry/view/PhotometryScreenSummaryContent.ts new file mode 100644 index 0000000..60ca76d --- /dev/null +++ b/src/photometry/view/PhotometryScreenSummaryContent.ts @@ -0,0 +1,42 @@ +/** + * PhotometryScreenSummaryContent.ts + * + * The accessible screen summary read by screen readers (SceneryStack's + * Interactive Description). `currentDetailsContent` is a LIVE `DerivedProperty` + * over the model — the displayed observation epoch and the measured magnitude + * difference — so a non-visual user can re-read the state at any time. + */ +import { DerivedProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { OBSERVATIONS } from "../../common/model/StarFieldData.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { PhotometryModel } from "../model/PhotometryModel.js"; + +export class PhotometryScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: PhotometryModel) { + const a11y = StringManager.getInstance().getPhotometryA11yStrings(); + + const currentDetails = new DerivedProperty( + [ + model.epochIndexProperty, + model.magnitudeDifferenceProperty, + a11y.currentDetailsMeasuredPatternStringProperty, + a11y.currentDetailsNoMeasurementPatternStringProperty, + ], + (epochIndex, deltaM, measuredPattern, noMeasurementPattern) => { + const pattern = deltaM === null ? noMeasurementPattern : measuredPattern; + return pattern + .replace("{{epoch}}", String(epochIndex + 1)) + .replace("{{total}}", String(OBSERVATIONS.length)) + .replace("{{delta}}", deltaM === null ? "" : deltaM.toFixed(3)); + }, + ); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: currentDetails, + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/photometry/view/PhotometryScreenView.ts b/src/photometry/view/PhotometryScreenView.ts index b169127..062d577 100644 --- a/src/photometry/view/PhotometryScreenView.ts +++ b/src/photometry/view/PhotometryScreenView.ts @@ -150,6 +150,7 @@ export class PhotometryScreenView extends ScreenView { const tandem = options?.tandem instanceof Tandem ? options.tandem : Tandem.OPT_OUT; const strings = StringManager.getInstance().getPhotometryViewStrings(); const unitStrings = StringManager.getInstance().getUnitStrings(); + const a11yControls = StringManager.getInstance().getPhotometryA11yStrings().controls; // ----------------------------------------------------------------------- // Star field + aperture overlays @@ -190,6 +191,7 @@ export class PhotometryScreenView extends ScreenView { label: "1", labelVisibleProperty: model.labelAperturesProperty, modelViewTransform: fieldMVT, + accessibleName: a11yControls.aperture1StringProperty, }, ); const aperture2 = new ApertureNode( @@ -203,6 +205,7 @@ export class PhotometryScreenView extends ScreenView { label: "2", labelVisibleProperty: model.labelAperturesProperty, modelViewTransform: fieldMVT, + accessibleName: a11yControls.aperture2StringProperty, }, ); @@ -216,6 +219,7 @@ export class PhotometryScreenView extends ScreenView { color: VSPColors.panelTextColorProperty, incrementFunction: (v) => v + 1, decrementFunction: (v) => v - 1, + accessibleName: a11yControls.epochPickerStringProperty, }); const epochDaysProperty = new PatternStringProperty(unitStrings.daysPatternStringProperty, { @@ -246,25 +250,25 @@ export class PhotometryScreenView extends ScreenView { strings.apertureDiameterStringProperty, model.apertureDiameterProperty, APERTURE_DIAMETER_RANGE, - numberControlOptions, + { ...numberControlOptions, accessibleName: strings.apertureDiameterStringProperty }, ); const skyInnerControl = new NumberControl( strings.skyInnerRadiusStringProperty, model.annulusInnerRadiusProperty, ANNULUS_INNER_RANGE, - numberControlOptions, + { ...numberControlOptions, accessibleName: strings.skyInnerRadiusStringProperty }, ); const skyOuterControl = new NumberControl( strings.skyOuterRadiusStringProperty, model.annulusOuterRadiusProperty, ANNULUS_OUTER_RANGE, - numberControlOptions, + { ...numberControlOptions, accessibleName: strings.skyOuterRadiusStringProperty }, ); const labelCheckbox = new Checkbox( model.labelAperturesProperty, new Text(strings.labelAperturesStringProperty, { font: LABEL_FONT }), - { boxWidth: 16 }, + { boxWidth: 16, accessibleName: strings.labelAperturesStringProperty }, ); const fieldControls = new VBox({ @@ -582,5 +586,25 @@ export class PhotometryScreenView extends ScreenView { tandem: tandem.createTandem("resetAllButton"), }); this.addChild(resetAllButton); + + // ----------------------------------------------------------------------- + // Accessibility: keyboard / reading traversal order. ScreenView throws if + // pdomOrder is set on itself, so a wrapper Node "borrows" the interactive + // nodes — apertures first, then the field controls, Reset All last. + // ----------------------------------------------------------------------- + this.addChild( + new Node({ + pdomOrder: [ + aperture1, + aperture2, + epochPicker, + apertureControl, + skyInnerControl, + skyOuterControl, + labelCheckbox, + resetAllButton, + ], + }), + ); } } diff --git a/src/registration/RegistrationScreen.ts b/src/registration/RegistrationScreen.ts index f4603e9..5526494 100644 --- a/src/registration/RegistrationScreen.ts +++ b/src/registration/RegistrationScreen.ts @@ -1,29 +1,26 @@ import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; import type { ScreenOptions } from "scenerystack/sim"; -import { Screen, ScreenSummaryContent } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; import type { Tandem } from "scenerystack/tandem"; import { VSPKeyboardHelpContent } from "../common/view/VSPKeyboardHelpContent.js"; import { StringManager } from "../i18n/StringManager.js"; import type { VSPPreferencesModel } from "../preferences/VSPPreferencesModel.js"; import VSPColors from "../VSPColors.js"; import { RegistrationModel } from "./model/RegistrationModel.js"; +import { RegistrationScreenSummaryContent } from "./view/RegistrationScreenSummaryContent.js"; import { RegistrationScreenView } from "./view/RegistrationScreenView.js"; type RegistrationScreenOptions = ScreenOptions & { tandem: Tandem; preferences: VSPPreferencesModel }; export class RegistrationScreen extends Screen { public constructor(options: RegistrationScreenOptions) { - const summaryStrings = StringManager.getInstance().getA11yStrings().screenSummary.registration; + const summaryStrings = StringManager.getInstance().getRegistrationA11yStrings().screenSummary; super( () => new RegistrationModel(options.preferences, options.tandem.createTandem("model")), (model) => new RegistrationScreenView(model, { tandem: options.tandem.createTandem("view"), - screenSummaryContent: new ScreenSummaryContent({ - playAreaContent: summaryStrings.playAreaStringProperty, - controlAreaContent: summaryStrings.controlAreaStringProperty, - interactionHintContent: summaryStrings.interactionHintStringProperty, - }), + screenSummaryContent: new RegistrationScreenSummaryContent(model), }), optionize()( { diff --git a/src/registration/view/RegistrationScreenSummaryContent.ts b/src/registration/view/RegistrationScreenSummaryContent.ts new file mode 100644 index 0000000..e94ded3 --- /dev/null +++ b/src/registration/view/RegistrationScreenSummaryContent.ts @@ -0,0 +1,44 @@ +/** + * RegistrationScreenSummaryContent.ts + * + * The accessible screen summary read by screen readers (SceneryStack's + * Interactive Description). `currentDetailsContent` is a LIVE `DerivedProperty` + * over the model — which star field is on top and its current X/Y offset — so a + * non-visual user can re-read the alignment state at any time. + */ +import { DerivedProperty } from "scenerystack/axon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { RegistrationModel } from "../model/RegistrationModel.js"; + +export class RegistrationScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: RegistrationModel) { + const a11y = StringManager.getInstance().getRegistrationA11yStrings(); + + const currentDetails = new DerivedProperty( + [ + model.onTopIndexProperty, + model.xOffset2Property, + model.yOffset2Property, + model.xOffset3Property, + model.yOffset3Property, + a11y.currentDetailsPatternStringProperty, + ], + (onTop, x2, y2, x3, y3, pattern) => { + const x = onTop === 2 ? x2 : x3; + const y = onTop === 2 ? y2 : y3; + return pattern + .replace("{{number}}", String(onTop)) + .replace("{{x}}", String(Math.round(x))) + .replace("{{y}}", String(Math.round(y))); + }, + ); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: currentDetails, + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + } +} diff --git a/src/registration/view/RegistrationScreenView.ts b/src/registration/view/RegistrationScreenView.ts index 4695fbc..9908dcf 100644 --- a/src/registration/view/RegistrationScreenView.ts +++ b/src/registration/view/RegistrationScreenView.ts @@ -61,6 +61,7 @@ export class RegistrationScreenView extends ScreenView { const tandem = options?.tandem instanceof Tandem ? options.tandem : Tandem.OPT_OUT; const strings = StringManager.getInstance().getRegistrationViewStrings(); + const a11yControls = StringManager.getInstance().getRegistrationA11yStrings().controls; // ----------------------------------------------------------------------- // Work Area — three overlaid star fields @@ -306,8 +307,10 @@ export class RegistrationScreenView extends ScreenView { ], }); - // Helper: one row of the starfield table + // Helper: one row of the starfield table. `number` feeds the accessible names + // of the row's controls ("Show star field 2", …). function makeStarfieldRow( + number: number, label: TReadOnlyProperty, shownProp: BooleanProperty | null, onTopValue: number | null, @@ -315,6 +318,7 @@ export class RegistrationScreenView extends ScreenView { yProp: NumberProperty | null, fill: TColor, ): Node { + const controlName = (pattern: TReadOnlyProperty) => new PatternStringProperty(pattern, { number }); const labelNode = new Text(label, { font: LABEL_FONT, fill: VSPColors.panelTextColorProperty, @@ -325,7 +329,10 @@ export class RegistrationScreenView extends ScreenView { const shownBox = shownProp ? makeTableControlNode( - new Checkbox(shownProp, new Text("", { font: LABEL_FONT }), { boxWidth: 16 }), + new Checkbox(shownProp, new Text("", { font: LABEL_FONT }), { + boxWidth: 16, + accessibleName: controlName(a11yControls.shownPatternStringProperty), + }), shownColumnX, ) : makePlaceholder(strings.fixedStringProperty, shownColumnX); @@ -335,6 +342,7 @@ export class RegistrationScreenView extends ScreenView { ? makeTableControlNode( new AquaRadioButton(model.onTopIndexProperty, onTopValue, new Text("", { font: LABEL_FONT }), { radius: 7, + accessibleName: controlName(a11yControls.onTopPatternStringProperty), }), onTopColumnX, ) @@ -348,6 +356,7 @@ export class RegistrationScreenView extends ScreenView { color: VSPColors.panelTextColorProperty, incrementFunction: (v) => v + 1, decrementFunction: (v) => v - 1, + accessibleName: controlName(a11yControls.xOffsetPatternStringProperty), }), xOffsetColumnX, ) @@ -361,6 +370,7 @@ export class RegistrationScreenView extends ScreenView { color: VSPColors.panelTextColorProperty, incrementFunction: (v) => v + 1, decrementFunction: (v) => v - 1, + accessibleName: controlName(a11yControls.yOffsetPatternStringProperty), }), yOffsetColumnX, ) @@ -381,8 +391,9 @@ export class RegistrationScreenView extends ScreenView { const starfieldLabel = (number: number) => new PatternStringProperty(strings.starfieldNumberStringProperty, { number }); - const row1 = makeStarfieldRow(starfieldLabel(1), null, null, null, null, VSPColors.tableRowFillProperty); + const row1 = makeStarfieldRow(1, starfieldLabel(1), null, null, null, null, VSPColors.tableRowFillProperty); const row2 = makeStarfieldRow( + 2, starfieldLabel(2), model.shown2Property, 2, @@ -391,6 +402,7 @@ export class RegistrationScreenView extends ScreenView { VSPColors.tableRowAltFillProperty, ); const row3 = makeStarfieldRow( + 3, starfieldLabel(3), model.shown3Property, 3, @@ -436,6 +448,7 @@ export class RegistrationScreenView extends ScreenView { listener: () => model.switchOnTopField(), baseColor: VSPColors.buttonColorProperty, minWidth: 170, + accessibleName: strings.switchOnTopStringProperty, }); const starfieldControlsContent = new VBox({ @@ -464,13 +477,13 @@ export class RegistrationScreenView extends ScreenView { const transparentCheckbox = new Checkbox( model.topFieldTransparentProperty, new Text(strings.makeTopTransparentStringProperty, { font: LABEL_FONT }), - { boxWidth: 16 }, + { boxWidth: 16, accessibleName: strings.makeTopTransparentStringProperty }, ); const invertCheckbox = new Checkbox( model.invertColorsProperty, new Text(strings.invertColorsStringProperty, { font: LABEL_FONT }), - { boxWidth: 16 }, + { boxWidth: 16, accessibleName: strings.invertColorsStringProperty }, ); // Hint text @@ -522,5 +535,16 @@ export class RegistrationScreenView extends ScreenView { tandem: tandem.createTandem("resetAllButton"), }); this.addChild(resetAllButton); + + // ----------------------------------------------------------------------- + // Accessibility: keyboard / reading traversal order. ScreenView throws if + // pdomOrder is set on itself, so a wrapper Node "borrows" the interactive + // nodes — table controls first, then the appearance options, Reset All last. + // ----------------------------------------------------------------------- + this.addChild( + new Node({ + pdomOrder: [starfieldTable, switchButton, transparentCheckbox, invertCheckbox, resetAllButton], + }), + ); } }