Skip to content

feat: add Notes teleprompter mode - #152

Merged
EtienneLescot merged 7 commits into
getopenscreen:mainfrom
My-Denia:feat/notes-teleprompter-67
Aug 2, 2026
Merged

feat: add Notes teleprompter mode#152
EtienneLescot merged 7 commits into
getopenscreen:mainfrom
My-Denia:feat/notes-teleprompter-67

Conversation

@My-Denia

@My-Denia My-Denia commented Jul 25, 2026

Copy link
Copy Markdown

Summary

This PR adds a manual teleprompter mode to the existing Notes window.

  • Auto-scrolls Notes content with pause and resume controls.
  • Adjusts scroll speed from 10 to 100 px/s.
  • Adjusts the Notes font size from 14 to 48 px.
  • Mirrors the Notes content horizontally for glass teleprompter setups without
    mirroring the toolbar.
  • Persists speed, font size, and mirror preferences while always starting
    playback paused.
  • Keeps controls accessible at narrow window widths and adds focused unit
    and component coverage.

Speech-adaptive scrolling, speech recognition, and speed presets are optional
enhancements and are intentionally not included in this MVP.

Related issue

Fixes #67

Type of change

  • Bug fix
  • Feature
  • Enhancement
  • Documentation
  • Refactor / maintenance
  • Performance
  • Security

Release impact

  • Patch
  • Minor
  • Major / breaking change
  • No release note needed

Desktop impact

  • Windows
  • macOS
  • Linux
  • Installer / packaging
  • Not platform-specific

Screenshots / video

Not included.

Testing

  • Full unit suite at the current head: 1427 passed across 119 files (CI).
  • npx tsc --noEmit and npx tsc -p tsconfig.test.json --noEmit are both clean.
  • Biome check is clean on the changed-file set.
  • npm run i18n:check passes — all locales match en across every namespace.
  • npm run build-vite
  • git diff --check

Manual verification in the real Notes window, driving the animation with
deterministic requestAnimationFrame timestamp stepping (not a real-time
VSync end-to-end run): playback on content that fits the window keeps playing
instead of flipping back, 10 px/s accumulates sub-pixel steps without
stalling, playback stops on reaching the end, restarting from the bottom
rewinds to the top, long frame gaps are clamped, the note is read-only while
playing, mirroring flips the content but not the toolbar, speed/font/mirror
settings persist with playback always starting paused, the speed control's
decrease button disables at the 10 px/s floor, and the play control announces
its state through its label alone.

End-to-end tests, native capture checks, and full Electron packaging were not
run because this change is limited to the renderer Notes window.

Summary by CodeRabbit

  • New Features

    • Added teleprompter playback controls to the notes editor.
    • Adjust scrolling speed and font size with bounded controls.
    • Added horizontal mirroring for notes content.
    • Preserved notes and teleprompter settings between sessions, including legacy note content.
    • Added localized tooltips across supported languages.
  • Bug Fixes

    • Improved readability and scaling of formatted notes content.
  • Tests

    • Added coverage for playback, persistence, controls, keyboard navigation, mirroring, and narrow-screen accessibility.

@My-Denia
My-Denia requested a review from EtienneLescot as a code owner July 25, 2026 00:33
Copilot AI review requested due to automatic review settings July 25, 2026 00:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds teleprompter mode to the notes window with persisted speed, font size, and mirror settings; animation-driven scrolling; expanded toolbar controls; legacy note migration; presentation styling; localized labels; and unit, component, and browser accessibility tests.

Changes

Notes teleprompter

Layer / File(s) Summary
Teleprompter contracts and persistence
src/components/launch/notesTeleprompter.ts, src/components/launch/notesTeleprompter.test.ts
Adds normalized settings, safe storage helpers, legacy content conversion, and bounded frame/scroll calculations with edge-case tests.
Notes window playback and presentation
src/components/launch/NotesWindow.tsx, src/components/launch/NotesWindow.module.css, src/components/launch/NotesWindow.test.tsx
Adds persisted teleprompter state, animation-frame scrolling, automatic stopping, toolbar wiring, dynamic font sizing, mirroring, and runtime coverage.
Toolbar controls and accessibility
src/components/launch/NotesToolbar.tsx, src/components/launch/NotesToolbar.test.tsx, src/components/launch/NotesToolbar.browser.test.tsx, src/i18n/locales/*/launch.json
Adds playback, speed, font-size, and mirror controls with bounds, ARIA state, keyboard reachability, and localized tooltip labels.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NotesToolbar
  participant NotesWindow
  participant TipTapEditor
  participant requestAnimationFrame
  NotesToolbar->>NotesWindow: Toggle playback or change settings
  NotesWindow->>requestAnimationFrame: Schedule playback tick
  requestAnimationFrame->>NotesWindow: Return frame timestamp
  NotesWindow->>TipTapEditor: Update scroll position
  NotesWindow->>NotesToolbar: Render current playback state
Loading

Suggested reviewers: etiennelescot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement #67's teleprompter mode with auto-scroll, mirror, font size, and manual pause/speed controls, excluding optional speech features.
Out of Scope Changes check ✅ Passed The added tests, styles, helpers, and locale updates all support the teleprompter feature and stay within the stated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding Notes teleprompter mode.
Description check ✅ Passed The description covers the required sections and provides detailed scope, issue, release, platform, and testing information.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
src/components/launch/NotesToolbar.browser.test.tsx (2)

56-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stub editor is recreated on every render.

createEditor() runs in the render body, so each state update passes a new object identity and useEditorRevision's effect (deps: [editor]) re-runs off/on on every click. Hold it stable so the harness matches real usage.

♻️ Proposed fix
 function ToolbarHarness() {
+	const [editor] = useState(createEditor);
 	const [isPlaying, setIsPlaying] = useState(false);
 	const [speed, setSpeed] = useState(40);
 	const [fontSize, setFontSize] = useState(16);
 	const [mirrored, setMirrored] = useState(false);
 
 	return (
 		<NotesToolbar
-			editor={createEditor()}
+			editor={editor}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/launch/NotesToolbar.browser.test.tsx` around lines 56 - 77,
Update ToolbarHarness to create the editor once and preserve its identity across
state updates, using a stable hook or equivalent initialization outside the
render-time state changes. Pass that stable editor to NotesToolbar instead of
calling createEditor() directly during each render, so useEditorRevision does
not repeatedly rebind listeners after clicks.

116-129: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Positional assumptions make this brittle.

The tab loop requires DOM order to equal tab order and every control to be enabled — it only holds because the harness starts at speed 40 / font 16. Likewise controls.at(-1) assumes mirror is rendered last. Selecting by aria-label would survive reordering and bound changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/launch/NotesToolbar.browser.test.tsx` around lines 116 - 129,
The NotesToolbar focus test relies on DOM positions and ordering that change
with control state and layout. Update the test to identify controls by their
aria-labels, and verify tab/focus behavior using the expected labeled controls
rather than controls array indices; select the mirror control by its label
instead of controls.at(-1), while preserving the Pause and aria-pressed
assertions.
src/components/launch/NotesToolbar.test.tsx (2)

54-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the active states too.

createProps only ever renders isPlaying: false / mirrored: false. A case with both true would pin the Pause label and aria-pressed="true" in the jsdom suite rather than relying solely on the browser test.

As per coding guidelines, "Add a test for every new behavior in the same package as the code under test."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/launch/NotesToolbar.test.tsx` around lines 54 - 69, Add a
NotesToolbar test using createProps with both isPlaying and mirrored set to
true, and assert the active-state behavior: the Pause label and
aria-pressed="true". Keep the existing inactive-state coverage unchanged.

Source: Coding guidelines


12-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Toolbar test fixtures are copy-pasted between the two suites. The createEditor stub and the i18n label map are byte-identical in both files; when a label key or a TipTap command is added, both copies must be edited or one suite silently drifts. Extract them into a shared fixture module (e.g. notesToolbarTestFixtures.ts next to the component).

  • src/components/launch/NotesToolbar.test.tsx#L12-L52: move the label map and createEditor into the shared fixture and import them here.
  • src/components/launch/NotesToolbar.browser.test.tsx#L10-L54: import the same fixture instead of redeclaring the mock and stub.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/launch/NotesToolbar.test.tsx` around lines 12 - 52, Extract
the shared i18n label map and createEditor fixture from
src/components/launch/NotesToolbar.test.tsx lines 12-52 into a shared
notesToolbarTestFixtures module next to the component, then import and use that
fixture here. Update src/components/launch/NotesToolbar.browser.test.tsx lines
10-54 to import the same shared fixture and remove its duplicate mock and editor
stub declarations.
src/components/launch/NotesToolbar.tsx (1)

208-219: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicate toggle state signaling.

ToolbarButton always emits aria-pressed={active}, so this Play/Pause teleprompter control reads as both “Pause” and pressed. Keep a static stateless label like “Auto-scroll” with aria-pressed, or keep the label swap and drop aria-pressed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/launch/NotesToolbar.tsx` around lines 208 - 219, Update the
teleprompter ToolbarButton using active={isPlaying} and onTogglePlaying so it
does not simultaneously expose a changing Play/Pause label and aria-pressed
state; either use a static “Auto-scroll” label while retaining
active/aria-pressed, or preserve the label swap and remove the active prop.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/launch/NotesToolbar.tsx`:
- Around line 235-241: Update both readouts in NotesToolbar: remove their
aria-label and redundant aria-live attributes so the dynamic values are
announced directly, relying on the parent group labels. Add localized unit keys
to launch.json for all 13 locales, and use the existing
translation/locale-formatting mechanism to render the speed and font-size values
with translated units instead of hardcoded “px/s” and “px”.

---

Nitpick comments:
In `@src/components/launch/NotesToolbar.browser.test.tsx`:
- Around line 56-77: Update ToolbarHarness to create the editor once and
preserve its identity across state updates, using a stable hook or equivalent
initialization outside the render-time state changes. Pass that stable editor to
NotesToolbar instead of calling createEditor() directly during each render, so
useEditorRevision does not repeatedly rebind listeners after clicks.
- Around line 116-129: The NotesToolbar focus test relies on DOM positions and
ordering that change with control state and layout. Update the test to identify
controls by their aria-labels, and verify tab/focus behavior using the expected
labeled controls rather than controls array indices; select the mirror control
by its label instead of controls.at(-1), while preserving the Pause and
aria-pressed assertions.

In `@src/components/launch/NotesToolbar.test.tsx`:
- Around line 54-69: Add a NotesToolbar test using createProps with both
isPlaying and mirrored set to true, and assert the active-state behavior: the
Pause label and aria-pressed="true". Keep the existing inactive-state coverage
unchanged.
- Around line 12-52: Extract the shared i18n label map and createEditor fixture
from src/components/launch/NotesToolbar.test.tsx lines 12-52 into a shared
notesToolbarTestFixtures module next to the component, then import and use that
fixture here. Update src/components/launch/NotesToolbar.browser.test.tsx lines
10-54 to import the same shared fixture and remove its duplicate mock and editor
stub declarations.

In `@src/components/launch/NotesToolbar.tsx`:
- Around line 208-219: Update the teleprompter ToolbarButton using
active={isPlaying} and onTogglePlaying so it does not simultaneously expose a
changing Play/Pause label and aria-pressed state; either use a static
“Auto-scroll” label while retaining active/aria-pressed, or preserve the label
swap and remove the active prop.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f423f790-e66c-4f39-aa80-533f0695f1a9

📥 Commits

Reviewing files that changed from the base of the PR and between f508a21 and e785295.

📒 Files selected for processing (21)
  • src/components/launch/NotesToolbar.browser.test.tsx
  • src/components/launch/NotesToolbar.test.tsx
  • src/components/launch/NotesToolbar.tsx
  • src/components/launch/NotesWindow.module.css
  • src/components/launch/NotesWindow.test.tsx
  • src/components/launch/NotesWindow.tsx
  • src/components/launch/notesTeleprompter.test.ts
  • src/components/launch/notesTeleprompter.ts
  • src/i18n/locales/ar/launch.json
  • src/i18n/locales/en/launch.json
  • src/i18n/locales/es/launch.json
  • src/i18n/locales/fr/launch.json
  • src/i18n/locales/it/launch.json
  • src/i18n/locales/ja-JP/launch.json
  • src/i18n/locales/ko-KR/launch.json
  • src/i18n/locales/pt-BR/launch.json
  • src/i18n/locales/ru/launch.json
  • src/i18n/locales/tr/launch.json
  • src/i18n/locales/vi/launch.json
  • src/i18n/locales/zh-CN/launch.json
  • src/i18n/locales/zh-TW/launch.json

Comment thread src/components/launch/NotesToolbar.tsx Outdated
@EtienneLescot

Copy link
Copy Markdown
Collaborator

main has just been rebased onto the 1.8.0 line — a ~390-commit merge landing the v4 editor, the native Rust compositor and export path, and the Linux capture stack. Every open PR is out of date; sorry for the churn. Here's where this one stands:

Good news: NotesWindow.tsx and NotesToolbar.tsx weren't touched by the merge, so a plain rebase should do it. The only drift is src/i18n/locales/en/launch.json (+18 lines), so expect conflicts confined to the locale JSONs.

git fetch origin && git rebase origin/main

Re-run npm run i18n:check afterwards and this should be ready.

vitest 4 removed the vitest/browser re-export; tsconfig.test.json
typecheck (new CI gate on the 1.8.0 line) fails on the old path.
@My-Denia
My-Denia force-pushed the feat/notes-teleprompter-67 branch from 48b9ab6 to 60bd856 Compare August 1, 2026 05:53
@EtienneLescot

Copy link
Copy Markdown
Collaborator

You beat me to it — I had a rebase ready to push and the safety check stopped me, which is the right outcome.

Checked your merge: no leftover conflict markers, i18n:check passes, main is fully in. Nothing more needed on your side.

@My-Denia

My-Denia commented Aug 1, 2026

Copy link
Copy Markdown
Author

Thanks for checking. Sorry I didn’t leave a note after pushing.

Play was a no-op whenever there was nothing left to scroll: a note that fits
the window, or a run that had already reached the bottom, flipped the button to
Pause and back within two frames. isAtTeleprompterEnd() now reports the end only
when there is somewhere to go, and starting playback at the bottom rewinds to
the top instead of leaving the control looking inert.

Playback also tracked its position through the DOM, re-reading scrollTop every
frame. At the slowest speed a frame advances ~0.17px, which an engine that snaps
scroll offsets to whole pixels would round away, stalling the teleprompter.
resolveTeleprompterPosition() keeps the fractional position internally and hands
control back to the DOM once it drifts past a pixel, so scrolling by hand
mid-playback still moves the teleprompter rather than fighting it.

Also:

- Drop NotesToolbar.browser.test.tsx and port what jsdom can assert into
  NotesToolbar.test.tsx. vitest.config.ts excludes src/**/*.browser.test.*, and
  the repo has no vitest.browser.config.ts and no test:browser script, so the
  file never ran anywhere — CI included.
- Restore the no-scrollbar utility the toolbar lost when it was split into two
  rows; without it both rows paint a horizontal scrollbar once they overflow.
- Convert the remaining tiptap spacing to em so it tracks the teleprompter font
  size. Identical at the default 16px; heading margins stay in rem because em
  there resolves against the heading's own enlarged size.
- Stop announcing playback twice: the play button keeps its state-dependent
  label and drops aria-pressed, via a new highlighted prop that styles without
  the toggle semantics.
- Make the note read-only while playing and disable formatting with it, so a
  keystroke can no longer yank the scroll through scroll-caret-into-view.
- Move the generic units.* keys from the launch namespace to common.
- Skip the mount-time settings write, load the initial note once, and drop a
  no-op transform-origin rule.
@EtienneLescot

Copy link
Copy Markdown
Collaborator

Thanks for this — the feature is well factored, and splitting the scroll maths into a pure notesTeleprompter.ts with injectable storage made it genuinely pleasant to review. I pushed one commit (6e5b325) to this branch with the review fixes rather than sending you round for another cycle. Summary of what changed and why:

Two real bugs

  • Play did nothing when there was nothing left to scroll. With a note that fits the window (maxScrollTop === 0), or after a run had already reached the bottom, the second frame satisfied nextScrollTop >= maximumScrollTop and immediately called setIsPlaying(false) — the button flipped to Pause and back within ~2 frames, and there was no way to replay. isAtTeleprompterEnd() now reports the end only when there is somewhere to go, and starting playback at the bottom rewinds to the top.
  • Scroll position round-tripped through the DOM every frame. scrollElement.scrollTop was read back each tick, so the sub-pixel remainder depended on how the engine stores scroll offsets. At the minimum speed a frame advances ~0.17px, which an engine snapping to whole pixels would round away entirely, stalling playback. resolveTeleprompterPosition() now keeps the fractional position internally and hands control back to the DOM once it drifts past a pixel — so scrolling by hand mid-playback still moves the teleprompter instead of fighting it.

The browser test never ran

NotesToolbar.browser.test.tsx was excluded by vitest.config.ts (exclude: ["src/**/*.browser.test.{ts,tsx}"]), and this repo has no vitest.browser.config.ts and no test:browser script — not on this branch, not on main. CI runs only npm run test. It was typechecked by the Typecheck (tests) job but never executed anywhere, so the "Browser suite: 9 passed" line in the description isn't reproducible from the committed tree. I ported what jsdom can assert (tab order across the six controls, Enter toggling the play label, mirror aria-pressed) into NotesToolbar.test.tsx and deleted the file. This is not your fault — technical-documentation/testing/writing-tests.md and AGENTS.md both still document that lane as if it exists; I've filed that separately.

Smaller things

  • Restored the no-scrollbar utility the toolbar lost when it was split into two rows — without it both rows paint a horizontal scrollbar as soon as they overflow (measured at a 300px width: scrollbar now takes 0px).
  • Converted the remaining tiptap spacing (p, ul/ol, pre, blockquote, hr) to em so it tracks the font size like the glyphs already did. Verified identical at the default 16px. Heading margins deliberately stay in rem: em on a heading resolves against the heading's own enlarged size, so switching them would change the default rendering rather than scale it — there's a comment saying so.
  • The play button announced its state twice (a toggling accessible name and aria-pressed). It keeps the label and drops aria-pressed, via a new highlighted prop that styles without the toggle semantics. Your role="group" labels and unnamed <output> readouts were already right.
  • The note goes read-only while playing (setEditable(!isPlaying, false)) and formatting is disabled with it, so a keystroke can't yank the scroll via ProseMirror's scroll-caret-into-view. The pause and mirror controls stay live.
  • Moved the generic units.* keys from the launch namespace to common across all 13 locales — your translations are unchanged, just relocated.
  • Nits: settings no longer write to localStorage on mount, the initial note loads once instead of per render, and a no-op transform-origin: center rule is gone.

Verification

npm run test 1427 passed · tsc --noEmit and tsc -p tsconfig.test.json clean · biome check . 11 warnings, identical to base, none in changed files · npm run i18n:check passes (the 259 cross-locale discrepancies in the description are gone after the merge with main — worth dropping that caveat) · npm run build-vite OK.

Each new test was mutation-checked: reverting the corresponding fix makes exactly that test fail, so none of them are vacuous. I also drove the real Notes window in a browser at ?showNotes=true with manually-stepped animation frames, confirming 40 px/s over 2s → 84px, a 1000ms frame gap clamped to 4px, landing exactly on maxScrollTop, auto-stop, replay-from-top, sub-pixel accumulation at 10 px/s, and mirror flipping the content but not the toolbar.

One thing I did not change: mirrored mode leaves the editor editable, so click-to-place-caret is horizontally inverted there. That seemed inherent to the glass-teleprompter use case rather than a defect — happy to revisit if you'd rather it went read-only too.

@My-Denia

My-Denia commented Aug 1, 2026

Copy link
Copy Markdown
Author

Thanks for landing the fixes directly and for the depth of the verification — much appreciated.

I also ran a verification pass in the real Notes window (on the same Notes sources as the current head — the newer merge only touched the website), driving the animation with deterministic requestAnimationFrame timestamp stepping (not real-time VSync): short-content playback no longer flips back, 10 px/s accumulates sub-pixel steps without stalling, playback stops at the end and rewinds to the top when restarted from the bottom, long frame gaps are clamped, the note is read-only while playing, mirroring flips the content but not the toolbar, settings persist with playback always starting paused, the speed-down control disables at its floor, and the play control announces state through its label only.

On mirrored editing: caret placement and text selection are visually reversed while the mirror is on. My preference would be to treat mirrored mode as presentation-only — make the note read-only while mirrored and restore editing when the mirror is turned off, keeping the other teleprompter controls live, the same way playback already does. Would you like that as a small follow-up in this PR? I'll hold off on any code changes until you weigh in.

@EtienneLescot
EtienneLescot merged commit e383f9b into getopenscreen:main Aug 2, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Future Work]: Notes window v2 teleprompter mode

3 participants