Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Keys, Present, Timer and the gallery: a key refused at launch shows as refused, Reset layout keeps My device, the Duration menu shows the choice, the gallery ignores the developer's own voice settings

Open
#381 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
15/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
macos, swift
Domain
desktop

Research direction

This issue bundles four separate fixes. Start with the Keyboard item: registerShortcuts() in Sources/LocalVoice/main.swift (around :408-417) and the Keyboard coach refresh path, then the Timer item in makeTimerMenu in Sources/StageKit/StageKitController.swift (around :324) and the page toggle in Views.swift (:305). Done means the named tests pass (TimerTransportTests, DemoModeTests.testViewportGeometryAndProfiles, SurfaceGallery acceptance), and scripts/check-surfaces.py passes with an updated docs/surfaces.json. Split the work into separate pull requests before starting.

Written by the indexing model from the issue text.

Description

bug

Outcome: Keys, Present, Timer and the gallery: a key refused at launch shows as refused, Reset layout keeps My device, the Duration menu shows the choice, the gallery ignores the developer's own voice settings.

Verified by an independent skeptic against the release train head d7cea35 (round four of the 15–16 October pass: energy, leaks, first run, keyboard, concurrency, settings propagation, public docs).

Settings › Keyboard and Home's Your keys miss a key macOS refused at launch, and Practice then calls it "Ready to use anywhere"

What a person sees: When another app already holds a Voice key at launch (for example ⌥V), Home's Your keys still teaches it and Settings › Keyboard shows it with no problem. Practice then ends with 'Three complete presses. Ready to use anywhere.', and only after that does the row turn into '⌥V is in use'. Dictate's Activation hint already says the key isn't available, so two surfaces disagree about one setting until the panel opens or a door calls navigate().

Evidence (d7cea35): main.swift:207 keyboard = KeyboardCoachModel(entries: shortcutEntries(), …) runs before Voice registration; voiceShortcutEntries reads model.shortcutFailures, which is still empty (:748-751). The first registerShortcuts() is at :376. registerShortcuts (:408-417) sets model.shortcutFailures and readback's failure but never refreshes keyboard entries. Refreshes happen only at :211 (suspend false, when practice or recording ends), :553 (panel open) and :734 (navigate()). Launch shows the window directly (:377); the sidebar and Home's Open Keyboard… (HomeTiles.swift:552-554) set model.page without a refresh. Home treats a key as usable when it is enabled with no error (HomeTiles.swift:517). beginPractice's guard passes on the stale entry (KeyboardCoach.swift:214-216), and completion says 'Three complete presses. Ready to use anywhere.' (:304). Contract: docs/desktop.md:105 'Shortcuts that are off or in conflict are left off the map and the list.'

Change: In AppDelegate.registerShortcuts() (Sources/LocalVoice/main.swift:408-417), after readback?.setShortcutFailure(model.shortcutFailures[5]), add keyboard?.replaceEntries(shortcutEntries()) with no extra condition. The guard !shortcutsSuspended at :409 already stops it while recording or practice runs. Remove the now-duplicate self.keyboard.replaceEntries(self.shortcutEntries()) from the suspend(false) branch at :211, leaving self.stage.setShortcutsSuspended(false); self.registerShortcuts(). Add a one-line comment explaining why: the coach's catalogue must carry what macOS refused, including at launch.

For the check, add a gallery acceptance step in SurfaceGallery.swift, next to the existing shell setup (:426-428):

  • Set shell.keyboard = keyboard.
  • Put every Voice shortcut on unique ⌃⌥⇧F13…F20 keys, or turn it off, as InputChecks does, so no real default keys are taken.
  • Hold Dictate's key with a separate VoiceHotkeys().register(...).
  • Call shell.registerShortcuts().
  • Unregister both shell.hotkeys and the competing VoiceHotkeys in a defer.

Acceptance: The new SurfaceGallery acceptance step holds Dictate's ⌃⌥⇧F13 with a competing VoiceHotkeys, then calls shell.registerShortcuts() once. It then asserts all of the following:

  • keyboard.entries for "voice.1" has an error containing "is in use".
  • HomeKeys.usable on that entry is false, and HomeKeys.next(keyboard.entries, learned: []) leaves out voice.1.
  • After keyboard.reveal("voice.1") and keyboard.beginPractice(), keyboard.isInteracting is false.
  • After the competing key is released and registerShortcuts() runs again, voice.1's error is nil.

The step unregisters every key it registered, and the existing --check-core and gallery runs stay green.

Value: 2 of 5 · Grammar: Quality · Risk: Low. It is a single model refresh on a path that already runs while the coach is idle, and the suspend guard rules out replacing entries mid-interaction. The main risk is in the check, not the fix: it must not register real default keys (⌥V/⌥W/⌥C/⌥Q) or it will fail or take the keys while Workbench is open. Using the F13–F20 keys with an explicit unregister avoids that. main.swift is a hot file on the train, so land it as a one-line edit.

Present: Reset layout swaps a saved My device frame for the generic phone, unlike a new scene

What a person sees: A presenter who saved an iPad or tablet with 'Save as my device' gets it on every new scene. Pressing Reset layout to re-centre a scene silently changes its device shape to the generic phone, with rounded phone corners and border on the shared screen. They then have to find Device shape › My device again.

Evidence (d7cea35): DemoScenesView.swift:149-153: Reset layout sets backgroundX/Y 0.5, zoom 1, phoneX/Y 0.5, phoneHeight 0.88 and reset.viewport = .phone. New scenes use scene.viewport = myDevice ?? .phone (DemoScenes.swift:675, :688, :725). saveMyDevice is at DemoScenes.swift:506 ('My device saved. Use it in any scene.'). On stage, only the aspect follows the live source; border and corners come from scene.viewport (ViewportGeometry, DeviceViewport.swift:30-40). The phone preset (border 0.009, corners 0.10) differs from tablet (0.012, 0.035) (DeviceViewport.swift:6-11). The reset literals duplicate DemoScene's defaults (DemoScenes.swift:11-19).

Change: In DemoScenes (Sources/StageKit/DemoScenes.swift), add a computed var newSceneViewport: DeviceViewport { myDevice ?? .phone }. Optionally, replace the three scene.viewport = myDevice ?? .phone lines (:675, :688, :725) with it; that edit is a one-token change at each line and nothing more.

Also in DemoScenes, add func resetLayout(). It does:
guard var scene = selected else { return }; let fresh = DemoScene(background: scene.background); scene.backgroundX = fresh.backgroundX; scene.backgroundY = fresh.backgroundY; scene.zoom = fresh.zoom; scene.phoneX = fresh.phoneX; scene.phoneY = fresh.phoneY; scene.phoneHeight = fresh.phoneHeight; scene.viewport = newSceneViewport; update(scene)

It must leave the logo, hand, persona, showsPhone, ambience and name untouched, exactly as today.

In DemoScenesView.swift:149-153, the Reset layout button calls model.resetLayout() instead of building the scene inline. Keep the button's label and its link style. Make no other UI change.

Acceptance: Extend DemoModeTests.testViewportGeometryAndProfiles, which is already registered at TestMain.swift:864, so no registry edit is needed. After reopened has myDevice == .tablet and a newly added scene is selected, do the following:

  1. Mutate that scene: set viewport to .phone with corners 0.2, phoneX 0.12, phoneY 0.3, phoneHeight 0.5, zoom 2 and backgroundX 0.1. Call update with it.
  2. Call reopened.resetLayout().
  3. Assert that selected?.viewport == .tablet, and that phoneX, phoneY, phoneHeight, zoom, backgroundX and backgroundY equal those of DemoScene(background: "x").
  4. Assert that the logo, hand and persona are unchanged.
  5. Assert that a reload with DemoScenes(root:systemIntegrationEnabled:false) returns the same values.

Add a second case on a fresh root with no my-device.json, where resetLayout() yields viewport == .phone. Finally, grep -n "reset.viewport = .phone" Sources/StageKit/DemoScenesView.swift must return nothing. The StageKitLegacy suite passes under scripts/test.sh in CI.

Value: 2 of 5 · Grammar: Quality · Risk: Low. The behaviour change is limited to Reset layout. It now resets to the person's saved My device rather than the generic phone, and with no saved device it behaves exactly as before. update() still validates and persists through the existing path, so a failed write surfaces the same notice. The remaining risk is scope creep: if a builder also reroutes the three creation paths through a new layout helper, that touches the image-copy and SceneSync code and its cleanup-on-failure ordering (DemoS

Timer: the Duration menu shows the duration actually set, and the chime option has one name

What a person sees: If the person chooses 3 min on the Timer page, or types 20, the Timer menu opened from Home's live row or the panel's Options checks no Duration item, so it cannot show what is set. The chime switch has a different name in each place.

Evidence (d7cea35): Views.swift:300-301: a free minutes field and presets 1, 3, 5, 10 and 15. StageKitController.swift:324-329: the menu's Duration presets are 1, 5, 10, 15, 30 and 60, each checked only on timerMinutes == Double(minutes). The same menu (makeTimerMenu, :306) opens from WorkbenchHome.swift:774 and WorkbenchQuickPanel.swift:280 (optionsOnly). The switch is named 'Play a chime when time is up' (Views.swift:305) and 'Chime When Finished' (StageKitController.swift:333). docs/workbench.md:207: the Timer has 'one name and one word set on every surface … Duration and Position… are its options'.

Change: 1. In StageKitController.makeTimerMenu (Sources/StageKit/StageKitController.swift:324), leave the literal [1, 5, 10, 15, 30, 60].map { minutes in StageMenuAction("\(minutes) min", …) } loop unchanged so check-surfaces keeps its six IDs.

  • When the saved timerMinutes is not one of those presets, insert one more checked item at its sorted position in the Duration submenu.
  • Label it with the value and ' min': whole numbers without a decimal (e.g. '3 min', '20 min'), fractions as typed (e.g. '2.5 min'), using the same rule the page field shows.
  • Choosing it changes nothing.
  • Exactly one Duration item is ever checked.
  1. Give the chime one name in literal strings, not a shared constant:
    • the page toggle at Views.swift:305 becomes 'Chime when time is up';
    • the menu item at StageKitController.swift:333 becomes 'Chime When Time Is Up' (Title Case, like the menu's other items, as 'Stop timer' and 'Stop Timer' already pair).
      This matches the legacy QuickControls.swift:250 wording, so every surface agrees.
  2. Run python3 scripts/check-surfaces.py --update. Then classify the new runtime Duration entry and the renamed chime entry (old ID ...StageMenuAction.chime-when-finished) as timer options in docs/surfaces.json. Do not edit the historical verification README.
  3. Leave both preset lists as they are. Add no new items beyond the one readback item.

Acceptance: Add these checks inside an existing, registered TimerTransportTests test that already builds a StageKitController.

Duration, with nothing started:

  • timerMinutes = 3: the Duration submenu of makeTimerMenu(optionsOnly: true) has exactly one checked item, titled '3 min', between '1 min' and '5 min'.
  • timerMinutes = 20: exactly one checked item, '20 min', between '15 min' and '30 min'.
  • timerMinutes = 2.5: exactly one checked item, '2.5 min'.
  • timerMinutes = 5: seven items are not shown. The six presets remain and only '5 min' is checked.
  • makeTimerMenu() gives the same results.

Chime: the menu contains a checked 'Chime When Time Is Up' item and no 'Chime When Finished'.

Also: python3 scripts/check-surfaces.py passes with the updated docs/surfaces.json, and python3 scripts/test-check-surfaces.py passes.

Value: 2 of 5 · Grammar: Quality · Risk: Low.

  • The registry changes, so docs/surfaces.json must be updated and classified, or check-surfaces fails CI. It is a known trap, but routine.
  • Floating-point equality on minutes: presets are whole numbers stored exactly as Double, so an exact compare is safe. Format fractions so the page field and the menu read the same.
  • Renaming the page toggle changes no stored preference key (timerChime is unchanged).
  • No behaviour changes beyond the readback item and the label text.
The surface gallery reads the developer's own React to my voice choice and ring colour

What a person sees: On the maintainer's Mac, the gallery's Persona renders can show React to my voice switched on, with his ring colour, while CI renders it off. Reviewers then compare renders that differ for reasons unrelated to the change. Any future pass that flips the ring through the real access would write to his production Persona preferences.

Evidence (d7cea35): PersonaVoice.swift:437-444: savedChoice, saveChoice, savedColor and saveColor use Workbench.stageDefaults directly. Workbench.swift:30-33, :40: stageDefaults = UserDefaults(suiteName: suiteDomain + ".stage"), and suiteDomain is 'com.ethdawg.workbench' unless a debug Info.plist fixture root is set (:21-25), which an unbundled gallery binary lacks. AppCoordinator.swift:23: personaVoice: usesAppStorage ? .system : nil; :150-153: usesAppStorage is true without an archive URL or WORKBENCH_STAGE_DATA_DIR, and nothing in Sources or scripts sets that variable. SurfaceGallery.swift:355 builds StageKitController(…, defaults: stageDefaults), whose doc says the defaults replace the presentation preferences for isolated fixtures (StageKitController.swift:104-106). The voice ring is the exception. SurfaceGallery.swift:261-262: cfprefsd maps named domains to the person's real ones. Persona.swift:489-490 reads both at init, and the Persona page draws them.

Change: 1. Sources/StageKit/PersonaVoice.swift: replace static var system with static func system(defaults: UserDefaults) -> PersonaVoiceAccess. Its savedChoice, saveChoice, savedColor and saveColor use defaults, with the same keys (persona.voiceRing, persona.voiceColor) and the same validation.
2. Sources/StageKit/Settings.swift:257: change SettingsStore's private let defaults to let defaults (internal), so the coordinator can reuse the store it was built with.
3. Sources/StageKit/AppCoordinator.swift:23: personaVoice: usesAppStorage ? .system(defaults: settings.defaults) : nil. In production that store is Workbench.stageDefaults: StageKitController.swift:107-112 is the only AppCoordinator construction in Sources, and main.swift:131 passes no defaults. Saved choices stay in the same suite.
4. Sources/StageKit/PersonaVoiceNativeCheck.swift:96: PersonaVoiceAccess.system(defaults: Workbench.stageDefaults). The check uses only .permission.

No change to DemoScenes or StageKitController. The StageKitController.swift:104 comment about defaults replacing the presentation preferences becomes true for the ring as well.

Acceptance: 1. Unit test in Tests/StageKitLegacy/PersonaVoiceTests.swift, using a UUID-named scratch suite that is removed afterwards:

  • PersonaVoiceAccess.system(defaults: scratch): saveChoice(true) and saveColor(c) land in that suite (read back with scratch.bool and scratch.array), and savedChoice and savedColor return them.
  • PersonaLibrary(root: tempDir, sessionHUDEnabled: false, voice: .system(defaults: scratch)), seeded with ring = true and a distinctive colour, starts with voiceRing == true and that voiceColor.
  • Do not construct StageKitController(defaults:) or a storage-backed AppCoordinator in the test. Both reach the real Application Support.
  1. On the maintainer's Mac, where defaults read com.ethdawg.workbench.stage persona.voiceRing is 1, run LocalVoice --render-surfaces (or --desktop-only) before and after the change:
    • page-personas-default/narrow (light and dark) before: "Microphone not allowed yet · Allow Microphone…" with swatches.
    • After: the switch off with no status line or swatches, matching a CI render.
    • defaults read com.ethdawg.workbench.stage persona.voiceRing still returns 1 after the run.
  2. `rg 'Workbench.stageDefaults' Sources/StageKi

Value: 2 of 5 · Grammar: Codebase · Risk: Low.

  • Production keeps the same suite domain: SettingsStore's store is Workbench.stageDefaults, or the fixture suite in QA bundles.
  • Tests that build AppCoordinator with an archiveURL still get no voice access.
  • Changing system from a var to a func breaks compilation at its two call sites (AppCoordinator:23 and PersonaVoiceNativeCheck:96), which keeps the change honest.
  • The main hazard is writing the unit test at the StageKitController level, which would touch real Application Support and

Refs #76, Refs #174, Refs #276


Build-ready record from round four of the overnight lead pass, verified by an independent skeptic per item. Closes only after native acceptance.

Dominant language
Swift
Stars
0
Forks
1
Avg merge
8h 5m
Merged PRs (30d)
215

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from Ship-Work/workbench

All issues in Ship-Work/workbench

Similar issues

More Swift issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.