diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index b5cfe6284..6000d5ab7 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -1,8 +1,8 @@ import { Check, Languages, NotepadText, Settings } from "lucide-react"; -import { memo } from "react"; +import { createContext, memo, type ReactElement, useContext, useRef, useState } from "react"; import { formatTimePadded } from "../../utils/timeUtils"; import { Button } from "../ui/button"; -import { Tooltip } from "../ui/tooltip"; +import { TOOLTIP_GAP_PX, Tooltip } from "../ui/tooltip"; import { CameraIcon, CursorIcon, @@ -15,17 +15,77 @@ import { SourceIcon, VolumeIcon, } from "./HudIcons"; +import { computeHudTooltipClearance } from "./hudGeometry"; import styles from "./LaunchWindow.module.css"; // Every control below is a `memo` boundary on purpose. The HUD's root re-renders // once a second for the whole duration of a recording (the elapsed-time counter), -// and without these boundaries each of those ticks rebuilt ~10 Radix tooltip trees +// and without these boundaries each of those ticks rebuilt ~15 Radix tooltip trees // and ~60 host elements. Props are kept primitive (or stable refs/callbacks from // the parent) so the boundaries actually hold. const hudDisabledClasses = "disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"; +// A control that is only locked for the length of a take (the toggles and the gear) keeps its +// tooltip, so it says `aria-disabled` instead of `disabled`: a natively disabled button takes no +// pointer events at all, and its tooltip could never open. Dimmed, no hover wash, no press. +const hudLockedClasses = + "aria-disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:hover:bg-transparent aria-disabled:active:scale-100"; + +// The bar's orientation decides which side its tooltips open on. A horizontal bar has all the +// reserve of the transparent window above it, so its tooltips go up. In a vertical bar "up" is +// over the controls above the one under the pointer, the ones the user reaches for next, so the +// tooltips go beside it: the bar is centred in a window about 650px wide or more (hudGeometry.ts), +// so the 260px maximum fits on either side. +const HudVerticalContext = createContext(false); +export const HudLayoutProvider = HudVerticalContext.Provider; + +// Keeps a tooltip off the window's own edge, where its shadow would be cut. +const HUD_TOOLTIP_EDGE_PADDING = 8; + +// Radix places a tooltip against its trigger, but what the eye sees is the bar around it. The +// bar pads the button, and a vertical bar widens to fit the timer while recording, so a gap +// measured from the trigger left the tooltip overlapping the bar. The distance to the bar's +// edge is measured when the tooltip opens (hence the controlled `open`: the offset has to be +// right in the render that shows it, not one effect later) and added to the primitive's gap. +function HudTooltip({ content, children }: { content: string; children: ReactElement }) { + const vertical = useContext(HudVerticalContext); + const side = vertical ? "right" : "top"; + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const [clearance, setClearance] = useState(0); + + const handleOpenChange = (next: boolean) => { + const trigger = triggerRef.current; + const bar = trigger?.closest("[data-tray-layout]"); + if (next && trigger && bar) { + setClearance( + computeHudTooltipClearance( + trigger.getBoundingClientRect(), + bar.getBoundingClientRect(), + side, + ), + ); + } + setOpen(next); + }; + + return ( + + {children} + + ); +} + // The browser's default focus outline all but disappears on the dark bar. const hudFocusClasses = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#10b981]/70"; @@ -106,149 +166,173 @@ export const HudTrayLayoutButton = memo(function HudTrayLayoutButton({ onClick: () => void; }) { return ( - + - + ); }); export const HudSourceButton = memo(function HudSourceButton({ vertical, label, + tooltip, remembered = false, disabled, onClick, }: { vertical: boolean; + /** The source's name: what the button shows, and its accessible name. */ label: string; + /** What a click does, which the name alone does not say. */ + tooltip: string; /** The label names the last pick, which is not live: same button, rest colour. */ remembered?: boolean; disabled: boolean; onClick: () => void; }) { return ( - + + + ); }); +// The three toggles below and the cursor button share one pattern: a constant `name`, the state +// in `aria-pressed`, and one `tooltip` sentence. While a take runs they are `locked`: still +// there, still focusable and hoverable, still saying what is being recorded, and inert. + export const HudSystemAudioButton = memo(function HudSystemAudioButton({ enabled, - disabled, - label, + locked, + name, + tooltip, onClick, }: { enabled: boolean; - disabled: boolean; - label: string; + locked: boolean; + name: string; + tooltip: string; onClick: () => void; }) { return ( - + + + ); }); export const HudMicButton = memo(function HudMicButton({ enabled, - disabled, - label, + locked, + name, + tooltip, onClick, }: { enabled: boolean; - disabled: boolean; - label: string; + locked: boolean; + name: string; + tooltip: string; onClick: () => void; }) { return ( - + + + ); }); export const HudCameraButton = memo(function HudCameraButton({ enabled, - disabled, - label, + locked, + name, + tooltip, onClick, }: { enabled: boolean; - disabled: boolean; - label: string; + locked: boolean; + name: string; + tooltip: string; onClick: () => void; }) { return ( - + + + ); }); export const HudSettingsButton = memo(function HudSettingsButton({ - disabled, + locked, expanded, label, onClick, buttonRef, }: { - disabled: boolean; + locked: boolean; expanded: boolean; label: string; onClick: () => void; buttonRef: React.MutableRefObject; }) { return ( - + - + ); }); export const HudCursorButton = memo(function HudCursorButton({ editableOverlay, - disabled, - label, + locked, + name, + tooltip, onClick, }: { editableOverlay: boolean; - disabled: boolean; - label: string; + locked: boolean; + name: string; + /** One sentence per mode: each defines the mode it names. */ + tooltip: string; onClick: () => void; }) { return ( - + + + ); }); @@ -315,7 +405,9 @@ export const HudRecordButton = memo(function HudRecordButton({ onClick: () => void; }) { return ( - + // The tooltip is the only one: it carries the name, and a native `title` beside it + // stacked a second, OS-drawn tooltip on the same button. + - + ); }); @@ -367,7 +458,7 @@ export const HudStudioButton = memo(function HudStudioButton({ onClick: () => void; }) { return ( - + - + ); }); @@ -391,7 +482,7 @@ export const HudNotesButton = memo(function HudNotesButton({ onClick: () => void; }) { return ( - + - + ); }); @@ -433,7 +524,7 @@ export const HudRecordingControls = memo(function HudRecordingControls({ className={`flex items-center gap-0.5 ${vertical ? "flex-col" : ""} ${styles.electronNoDrag}`} > {canPause && ( - + - + )} - + - - + + - + ); }); @@ -489,24 +580,25 @@ export const HudLanguageButton = memo(function HudLanguageButton({ buttonRef: React.MutableRefObject; }) { return ( - + + + ); }); @@ -514,37 +606,44 @@ export const HudWindowControls = memo(function HudWindowControls({ vertical, disabled, hideLabel, + hideTooltip, closeLabel, onHide, onClose, }: { vertical: boolean; disabled: boolean; + /** The accessible name; the tooltip goes on to say how to get the bar back. */ hideLabel: string; + hideTooltip: string; closeLabel: string; onHide: () => void; onClose: () => void; }) { return (
- - + + + + + +
); }); diff --git a/src/components/launch/HudDeviceSettings.quality.test.tsx b/src/components/launch/HudDeviceSettings.quality.test.tsx index 67db4a267..126ce9314 100644 --- a/src/components/launch/HudDeviceSettings.quality.test.tsx +++ b/src/components/launch/HudDeviceSettings.quality.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { WEBCAM_QUALITY_IDS } from "../../hooks/webcamCaptureTarget"; +import { TooltipProvider } from "../ui/tooltip"; import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings"; vi.mock("../../hooks/useAudioLevelMeter", () => ({ @@ -13,7 +14,7 @@ vi.mock("../../hooks/useCameraPreviewStream", () => ({ const labels: HudDeviceSettingsLabels = { title: "Device settings", - done: "Done", + close: "Close", microphone: "Microphone", camera: "Camera", micLevel: "Input level", @@ -38,27 +39,30 @@ const labels: HudDeviceSettingsLabels = { function renderPanel(overrides: Partial[0]> = {}) { const onSelectCameraQuality = vi.fn(); render( - undefined} - {...overrides} - />, + // The close button's tooltip needs the provider the app mounts around every window. + + undefined} + {...overrides} + /> + , ); return { onSelectCameraQuality }; } diff --git a/src/components/launch/HudDeviceSettings.tsx b/src/components/launch/HudDeviceSettings.tsx index 3bf43df74..164b2f370 100644 --- a/src/components/launch/HudDeviceSettings.tsx +++ b/src/components/launch/HudDeviceSettings.tsx @@ -5,6 +5,7 @@ import type { CameraDevice } from "../../hooks/useCameraDevices"; import { useCameraPreviewStream } from "../../hooks/useCameraPreviewStream"; import type { MicrophoneDevice } from "../../hooks/useMicrophoneDevices"; import { WEBCAM_QUALITY_IDS, type WebcamQualityId } from "../../hooks/webcamCaptureTarget"; +import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; const LEVEL_SEGMENTS = 12; @@ -12,7 +13,7 @@ const LEVEL_SEGMENT_KEYS = Array.from({ length: LEVEL_SEGMENTS }, (_, i) => `seg export interface HudDeviceSettingsLabels { title: string; - done: string; + close: string; microphone: string; camera: string; micLevel: string; @@ -152,15 +153,18 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ >
{labels.title} - + {/* Beside the X, not above it: the panel is the tallest thing the window reserves + room for, so there may be little left over its header. */} + + +
{showMicrophone ? ( diff --git a/src/components/launch/HudIcons.tsx b/src/components/launch/HudIcons.tsx index a7d8b744d..f01a293ef 100644 --- a/src/components/launch/HudIcons.tsx +++ b/src/components/launch/HudIcons.tsx @@ -117,7 +117,8 @@ export function CameraIcon({ off, className }: { off: boolean; className?: strin ); } -export function CursorIcon({ className }: { className?: string }) { +/** `off` slashes the arrow, like the mic and camera, so the state is not carried by colour alone. */ +export function CursorIcon({ off = false, className }: { off?: boolean; className?: string }) { return ( ); } diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 39bd13ea6..06c83db94 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -168,22 +168,24 @@ vi.mock("@/contexts/I18nContext", () => ({ "sourceSelector.defaultSourceName": "Screen", "recording.selectSource": "Please select a source to record", "recording.systemPicker": "Your system will ask what to share", - "recording.inProgress": "Recording", + "recording.start": "Start recording", + "recording.stop": "Stop and save the recording", "tooltips.useVerticalTray": "Use vertical tray", "tooltips.useHorizontalTray": "Use horizontal tray", - "audio.enableSystemAudio": "Enable system audio", - "audio.disableSystemAudio": "Disable system audio", - "audio.enableMicrophone": "Enable microphone", - "audio.disableMicrophone": "Disable microphone", - "audio.defaultMicrophone": "Default Microphone", - "webcam.enableWebcam": "Enable webcam", - "webcam.disableWebcam": "Disable webcam", - "webcam.defaultCamera": "Default Camera", + "tooltips.changeSource": "Choose another screen or window", + "tooltips.systemAudio": "System audio: records the sound your computer plays", + "tooltips.microphone": "Microphone: records your voice", + "tooltips.camera": "Camera: records you as a separate layer you can move in the editor", + "tooltips.openNotes": "Open Notes: text to read while you record", + "tooltips.hideHUDTip": "Hide the recording bar. Show it again from the tray icon.", + "audio.systemAudio": "System audio", + "audio.microphone": "Microphone", + "webcam.camera": "Camera", "webcam.searching": "Searching...", "webcam.noneFound": "No camera found", "webcam.unavailable": "Camera unavailable", "deviceSettings.title": "Device settings", - "deviceSettings.done": "Done", + "deviceSettings.close": "Close", "deviceSettings.micLevel": "Input level", "deviceSettings.micHint": "Speak to check your microphone", "deviceSettings.noMicrophones": "No microphone found", @@ -195,13 +197,18 @@ vi.mock("@/contexts/I18nContext", () => ({ "deviceSettings.checkingForUpdates": "Checking…", "audio.inputDevice": "Input device", "webcam.cameraDevice": "Camera device", - "cursor.useEditableCursorHint": - "Use editable cursor: turns auto zoom and cursor effects back on", - "cursor.useSystemCursorHint": "Use system cursor: turns off auto zoom and cursor effects", - "tooltips.openStudio": "Open Studio", + "cursor.name": "Editable cursor", + "cursor.editableTip": + "Editable cursor: recorded separately, so you can restyle it and auto-zoom in the editor", + "cursor.systemTip": + "System cursor: your real cursor, recorded in the video. No restyling, no auto-zoom.", + "tooltips.openStudio": "Open Studio: the editor for your recordings", "tooltips.hideHUD": "Hide HUD", "tooltips.closeApp": "Close App", + "tooltips.restartRecording": "Delete this recording and start a new one", + "tooltips.cancelRecording": "Stop and delete this recording", language: "Language", + languageWithName: "Language: {{language}}", "systemLanguagePrompt.title": "Use your system language?", "systemLanguagePrompt.description": "We detected English as your system language. Do you want to switch OpenScreen to English?", @@ -229,6 +236,20 @@ function renderLaunchWindow() { ); } +/** + * Opens a control's tooltip the way the keyboard does (focus opens it at once, without the + * hover delay) and returns its text. Radix draws a visually hidden `role="tooltip"` copy for + * assistive technology, which is what is read here. + */ +async function tooltipOn(testId: string): Promise { + const control = screen.getByTestId(testId); + act(() => control.focus()); + const text = (await screen.findByRole("tooltip")).textContent; + act(() => control.blur()); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + return text; +} + function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSource"]) { window.electronAPI = { ...window.electronAPI, @@ -312,6 +333,9 @@ function emitSourceSelectorClosed() { function resetLaunchMocks() { vi.stubGlobal("ResizeObserver", StubResizeObserver); + // The bar's orientation is a saved preference: a test that switches it must not leave the + // next one starting on a vertical bar. + localStorage.clear(); recorderState.value.toggleRecording = vi.fn(); recorderState.value.cursorCaptureMode = "editable-overlay"; recorderState.value.systemAudioEnabled = false; @@ -369,7 +393,7 @@ describe("LaunchWindow record button", () => { const recordButton = await screen.findByTestId("launch-record-button"); expect(recordButton).toBeEnabled(); - expect(recordButton).toHaveAttribute("title", "Please select a source to record"); + expect(recordButton).toHaveAttribute("aria-label", "Please select a source to record"); fireEvent.click(recordButton); @@ -394,7 +418,10 @@ describe("LaunchWindow record button", () => { await waitFor(() => { expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); }); - expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute( + "aria-label", + "Start recording", + ); }); it("names the last pick in Apple's picker without treating it as selected", async () => { @@ -406,7 +433,7 @@ describe("LaunchWindow record button", () => { expect(sourceButton).toHaveAttribute("data-remembered", "true"); // Nothing is live, so Record still asks first and only records once a source is chosen. expect(screen.getByTestId("launch-record-button")).toHaveAttribute( - "title", + "aria-label", "Please select a source to record", ); fireEvent.click(screen.getByTestId("launch-record-button")); @@ -448,7 +475,10 @@ describe("LaunchWindow record button", () => { emitSelectedSourceChanged(displayOneSource); await waitFor(() => { - expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute( + "aria-label", + "Start recording", + ); }); expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); @@ -459,57 +489,295 @@ describe("LaunchWindow record button", () => { expect(screen.getByTestId("launch-system-audio-button")).toHaveAttribute( "aria-label", - "Enable system audio", + "System audio", ); expect(screen.getByTestId("launch-microphone-button")).toHaveAttribute( "aria-label", - "Enable microphone", - ); - expect(screen.getByTestId("launch-webcam-button")).toHaveAttribute( - "aria-label", - "Enable webcam", + "Microphone", ); + expect(screen.getByTestId("launch-webcam-button")).toHaveAttribute("aria-label", "Camera"); expect(screen.getByTestId("launch-cursor-mode-button")).toHaveAttribute( "aria-label", - "Use system cursor: turns off auto zoom and cursor effects", + "Editable cursor", ); expect(screen.getByTestId("launch-open-studio-button")).toHaveAttribute( "aria-label", - "Open Studio", + "Open Studio: the editor for your recordings", ); - expect(screen.getByTitle("Hide HUD")).toHaveAttribute("aria-label", "Hide HUD"); - expect(screen.getByTitle("Close App")).toHaveAttribute("aria-label", "Close App"); - }); + expect(screen.getByRole("button", { name: "Hide HUD" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close App" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Language: English" })).toBeInTheDocument(); + }); + + // The name never changes with the state: "Disable microphone, pressed" reads as the opposite + // of what is happening. The state is aria-pressed, and the sentence is the tooltip. + it("gives every toggle one constant name and carries its state in aria-pressed", async () => { + const toggles = [ + ["launch-system-audio-button", "System audio"], + ["launch-microphone-button", "Microphone"], + ["launch-webcam-button", "Camera"], + ["launch-cursor-mode-button", "Editable cursor"], + ] as const; + const view = renderLaunchWindow(); + await screen.findByTestId("launch-cursor-mode-button"); + + // Off, except the cursor, which starts editable. + for (const [testId, name] of toggles) { + const expected = testId === "launch-cursor-mode-button" ? "true" : "false"; + expect(screen.getByTestId(testId)).toHaveAttribute("aria-label", name); + expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", expected); + } - it("says what the system cursor costs, and what switching back restores", async () => { + recorderState.value.systemAudioEnabled = true; + recorderState.value.microphoneEnabled = true; + recorderState.value.webcamEnabled = true; recorderState.value.cursorCaptureMode = "system"; + view.rerender( + + + , + ); + + for (const [testId, name] of toggles) { + const expected = testId === "launch-cursor-mode-button" ? "false" : "true"; + expect(screen.getByTestId(testId)).toHaveAttribute("aria-label", name); + expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", expected); + } + }); + + it("says what each toggle records, in a tooltip that does not change with its state", async () => { + renderLaunchWindow(); + await screen.findByTestId("launch-cursor-mode-button"); + + expect(await tooltipOn("launch-system-audio-button")).toBe( + "System audio: records the sound your computer plays", + ); + expect(await tooltipOn("launch-microphone-button")).toBe("Microphone: records your voice"); + expect(await tooltipOn("launch-webcam-button")).toBe( + "Camera: records you as a separate layer you can move in the editor", + ); + }); + + // The two cursor modes are the one pair the user has to tell apart, so each tooltip defines + // the mode it names instead of describing the other one. + it("defines the editable cursor in its tooltip while it is on", async () => { renderLaunchWindow(); + await screen.findByTestId("launch-cursor-mode-button"); - // The tooltip is the only place the HUD can say it: a system-cursor take writes no - // cursor track, so auto zoom and every cursor effect are off for it. - expect(await screen.findByTestId("launch-cursor-mode-button")).toHaveAttribute( - "title", - "Use editable cursor: turns auto zoom and cursor effects back on", + expect(await tooltipOn("launch-cursor-mode-button")).toBe( + "Editable cursor: recorded separately, so you can restyle it and auto-zoom in the editor", ); }); - it("never promises the editable cursor's effects when capture falls back to the browser", async () => { - // Linux without its PipeWire helper records through the browser, which always bakes the - // system cursor in: switching to the editable cursor would restore nothing. - platformState.value = "linux"; - linuxHelperAvailable.value = false; + it("defines the system cursor in its tooltip while it is on", async () => { recorderState.value.cursorCaptureMode = "system"; renderLaunchWindow(); + await screen.findByTestId("launch-cursor-mode-button"); + + expect(await tooltipOn("launch-cursor-mode-button")).toBe( + "System cursor: your real cursor, recorded in the video. No restyling, no auto-zoom.", + ); + }); + + it("uses no native title on the controls that have a tooltip", async () => { + stubElectronAPI(vi.fn(async () => displayOneSource)); + renderLaunchWindow(); + await screen.findByTestId("launch-cursor-mode-button"); + await waitFor(() => + expect(screen.getByTestId("launch-record-button")).toHaveAttribute( + "aria-label", + "Start recording", + ), + ); + + const controls = [ + ...[ + "launch-source-selector-button", + "launch-system-audio-button", + "launch-microphone-button", + "launch-webcam-button", + "launch-device-settings-button", + "launch-cursor-mode-button", + "launch-record-button", + "launch-open-studio-button", + ].map((testId) => screen.getByTestId(testId)), + screen.getByRole("button", { name: "Hide HUD" }), + screen.getByRole("button", { name: "Close App" }), + screen.getByRole("button", { name: "Language: English" }), + ]; + for (const control of controls) { + expect(control).not.toHaveAttribute("title"); + } + }); + + it("gives the Record button one tooltip, and it says what pressing it does", async () => { + stubElectronAPI(vi.fn(async () => displayOneSource)); + renderLaunchWindow(); + const recordButton = await screen.findByTestId("launch-record-button"); + await waitFor(() => expect(recordButton).toHaveAttribute("aria-label", "Start recording")); + + act(() => recordButton.focus()); + await screen.findByRole("tooltip"); + + // One Radix tooltip and no native title stacked on it, and not the source's name. + expect(screen.getAllByRole("tooltip")).toHaveLength(1); + expect(screen.getByRole("tooltip")).toHaveTextContent("Start recording"); + expect(recordButton).not.toHaveAttribute("title"); + }); + + it("names the source button's action once a source is shown, and asks for one before", async () => { + renderLaunchWindow(); + expect(await tooltipOn("launch-source-selector-button")).toBe( + "Please select a source to record", + ); + + await waitForSourceSelectionSubscription(); + emitSelectedSourceChanged(displayOneSource); + await waitFor(() => + expect(screen.getByTestId("launch-source-selector-button")).toHaveTextContent("Display 1"), + ); + + expect(await tooltipOn("launch-source-selector-button")).toBe( + "Choose another screen or window", + ); + }); + + it("says the recording is stopped and saved, not the source's name, while recording", async () => { + recorderState.value.recording = true; + stubElectronAPI(vi.fn(async () => displayOneSource)); + renderLaunchWindow(); + + expect(await tooltipOn("launch-record-button")).toBe("Stop and save the recording"); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute( + "aria-label", + "Stop and save the recording", + ); + }); + + it("opens a horizontal bar's tooltips above it and a vertical bar's beside it", async () => { + renderLaunchWindow(); + const audio = await screen.findByTestId("launch-system-audio-button"); + act(() => audio.focus()); + await screen.findByRole("tooltip"); + expect(document.querySelector("[data-slot='tooltip-content']")).toHaveAttribute( + "data-side", + "top", + ); + act(() => audio.blur()); + + // A tooltip above a vertical bar would cover the controls above the pointer. + fireEvent.click(screen.getByTestId("launch-tray-layout-button")); + act(() => screen.getByTestId("launch-system-audio-button").focus()); await waitFor(() => - expect(screen.getByTestId("launch-cursor-mode-button")).toHaveAttribute( - "title", - "Use system cursor: turns off auto zoom and cursor effects", + expect(document.querySelector("[data-slot='tooltip-content']")).toHaveAttribute( + "data-side", + "right", ), ); }); - it("names the recording-state HUD controls for assistive technology", async () => { + // Radix measures from the small trigger, but the bar is what the eye sees: while recording, a + // vertical bar widens to fit the timer and the Pause button sits well inside its edge. The + // tooltip has to clear the bar, or it overlaps it. + it("opens a tooltip clear of the bar's edge, not just the trigger's", async () => { + recorderState.value.recording = true; + recorderState.value.canPauseRecording = true; + renderLaunchWindow(); + fireEvent.click(await screen.findByTestId("launch-tray-layout-button")); + const pause = await screen.findByTestId("launch-pause-button"); + const bar = pause.closest("[data-tray-layout]") as HTMLElement; + + const box = (x: number, width: number, height: number) => ({ + x, + y: 0, + left: x, + top: 0, + right: x + width, + bottom: height, + width, + height, + toJSON: () => ({}), + }); + // The bar is 100px wide; the button's right edge is 28px inside it. + vi.spyOn(bar, "getBoundingClientRect").mockReturnValue(box(0, 100, 300)); + vi.spyOn(pause, "getBoundingClientRect").mockReturnValue(box(38, 34, 34)); + // jsdom's viewport is zero-sized, which would make every placement overflow and flip. + const root = document.documentElement; + Object.defineProperty(root, "clientWidth", { value: 1000, configurable: true }); + Object.defineProperty(root, "clientHeight", { value: 800, configurable: true }); + + try { + act(() => pause.focus()); + await screen.findByRole("tooltip"); + + // Trigger's right edge 72 + the primitive's 8px gap + the 28px to the bar's edge. + const wrapper = document.querySelector("[data-radix-popper-content-wrapper]"); + await waitFor(() => expect(wrapper?.style.transform).toBe("translate(108px, 17px)")); + } finally { + Reflect.deleteProperty(root, "clientWidth"); + Reflect.deleteProperty(root, "clientHeight"); + } + }); + + // Chromium records the pointer itself on these platforms once the native helper is missing, + // so the toggle would change nothing. An option that changes nothing is not shown. + it("hides the cursor toggle when capture falls back to the browser", async () => { + platformState.value = "linux"; + linuxHelperAvailable.value = false; + recorderState.value.cursorCaptureMode = "system"; + renderLaunchWindow(); + + // Asked twice: by the hook that decides who owns the source, and by the HUD for this + // toggle. Wait for both answers, or "hidden" would hold only because none had come yet. + const probe = vi.mocked(window.electronAPI.isNativeLinuxCaptureAvailable); + await waitFor(() => expect(probe).toHaveBeenCalledTimes(2)); + await act(async () => { + await Promise.all(probe.mock.results.map((result) => result.value)); + }); + + expect(screen.queryByTestId("launch-cursor-mode-button")).toBeNull(); + }); + + it("shows the cursor toggle once the native helper is confirmed", async () => { + platformState.value = "linux"; + linuxHelperAvailable.value = true; + renderLaunchWindow(); + + expect(await screen.findByTestId("launch-cursor-mode-button")).toBeInTheDocument(); + }); + + it("waits for the native helper's answer before offering the cursor toggle", async () => { + platformState.value = "darwin"; + let answer: (value: { success: boolean; available: boolean }) => void = () => undefined; + window.electronAPI.isNativeMacCaptureAvailable = vi.fn( + () => + new Promise<{ success: boolean; available: boolean }>((resolve) => { + answer = resolve; + }), + ) as unknown as Window["electronAPI"]["isNativeMacCaptureAvailable"]; + + try { + renderLaunchWindow(); + + await waitFor(() => + expect(window.electronAPI.isNativeMacCaptureAvailable).toHaveBeenCalled(), + ); + expect(screen.queryByTestId("launch-cursor-mode-button")).toBeNull(); + + await act(async () => { + answer({ success: true, available: true }); + }); + expect(screen.getByTestId("launch-cursor-mode-button")).toBeInTheDocument(); + } finally { + // `stubElectronAPI` spreads the previous object, so a leftover probe would keep every + // later macOS test waiting for an answer that never comes. + Reflect.deleteProperty(window.electronAPI, "isNativeMacCaptureAvailable"); + } + }); + + // Cancel and restart have no confirmation, so the tooltip is the only place the loss can be said. + it("names the recording-state HUD controls, and says what cancel and restart delete", async () => { recorderState.value.recording = true; recorderState.value.canPauseRecording = true; renderLaunchWindow(); @@ -520,11 +788,23 @@ describe("LaunchWindow record button", () => { ); expect(screen.getByTestId("launch-restart-button")).toHaveAttribute( "aria-label", - "tooltips.restartRecording", + "Delete this recording and start a new one", ); expect(screen.getByTestId("launch-cancel-button")).toHaveAttribute( "aria-label", - "tooltips.cancelRecording", + "Stop and delete this recording", + ); + expect(await tooltipOn("launch-cancel-button")).toBe("Stop and delete this recording"); + }); + + it("says how to get the bar back in the hide button's tooltip, and keeps its name short", async () => { + renderLaunchWindow(); + const hide = await screen.findByRole("button", { name: "Hide HUD" }); + + act(() => hide.focus()); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Hide the recording bar. Show it again from the tray icon.", ); }); @@ -537,7 +817,10 @@ describe("LaunchWindow record button", () => { emitSelectedSourceChanged(displayOneSource); await waitFor(() => { - expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute( + "aria-label", + "Start recording", + ); }); expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); @@ -563,7 +846,10 @@ describe("LaunchWindow record button", () => { emitSelectedSourceChanged(displayOneSource); await waitFor(() => { - expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute( + "aria-label", + "Start recording", + ); }); expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); @@ -593,7 +879,7 @@ describe("LaunchWindow record button", () => { const recordButton = await screen.findByTestId("launch-record-button"); await waitFor(() => { - expect(recordButton).toHaveAttribute("title", "Display 1"); + expect(recordButton).toHaveAttribute("aria-label", "Start recording"); }); fireEvent.click(recordButton); @@ -619,7 +905,7 @@ describe("LaunchWindow record button", () => { const view = renderLaunchWindow(); const recordButton = await screen.findByTestId("launch-record-button"); await waitFor(() => { - expect(recordButton).toHaveAttribute("title", "Display 1"); + expect(recordButton).toHaveAttribute("aria-label", "Start recording"); }); fireEvent.click(recordButton); @@ -653,7 +939,7 @@ describe("LaunchWindow record button", () => { const recordButton = await screen.findByTestId("launch-record-button"); await waitFor(() => { - expect(recordButton).toHaveAttribute("title", "Display 1"); + expect(recordButton).toHaveAttribute("aria-label", "Start recording"); }); fireEvent.click(recordButton); @@ -670,7 +956,7 @@ describe("LaunchWindow record button", () => { const view = renderLaunchWindow(); const recordButton = await screen.findByTestId("launch-record-button"); await waitFor(() => { - expect(recordButton).toHaveAttribute("title", "Display 1"); + expect(recordButton).toHaveAttribute("aria-label", "Start recording"); }); fireEvent.click(recordButton); @@ -699,7 +985,7 @@ describe("LaunchWindow record button", () => { const view = renderLaunchWindow(); const recordButton = await screen.findByTestId("launch-record-button"); await waitFor(() => { - expect(recordButton).toHaveAttribute("title", "Display 1"); + expect(recordButton).toHaveAttribute("aria-label", "Start recording"); }); fireEvent.click(recordButton); @@ -846,7 +1132,7 @@ describe("LaunchWindow record button", () => { const recordButton = await screen.findByTestId("launch-record-button"); expect(recordButton).toBeEnabled(); await waitFor(() => { - expect(recordButton).toHaveAttribute("title", "Your system will ask what to share"); + expect(recordButton).toHaveAttribute("aria-label", "Your system will ask what to share"); }); fireEvent.click(recordButton); @@ -873,7 +1159,7 @@ describe("LaunchWindow record button", () => { expect(await screen.findByTestId("launch-source-selector-button")).toBeInTheDocument(); expect(screen.getByTestId("launch-record-button")).toHaveAttribute( - "title", + "aria-label", "Please select a source to record", ); }); @@ -911,7 +1197,7 @@ describe("LaunchWindow record button", () => { const recordButton = await screen.findByTestId("launch-record-button"); await waitFor(() => { - expect(recordButton).toHaveAttribute("title", "Recording"); + expect(recordButton).toHaveAttribute("aria-label", "Stop and save the recording"); }); }); }); @@ -1016,7 +1302,7 @@ describe("LaunchWindow overlay sizing", () => { }; sizeMock.mockClear(); - fireEvent.click(screen.getByRole("button", { name: "English" })); + fireEvent.click(screen.getByRole("button", { name: "Language: English" })); await screen.findByTestId("hud-language-menu"); await flushResizeObservers(); @@ -1064,7 +1350,7 @@ describe("LaunchWindow overlay sizing", () => { stubBox(bar, 400, 56); await flushResizeObservers(); - fireEvent.click(screen.getByRole("button", { name: "English" })); + fireEvent.click(screen.getByRole("button", { name: "Language: English" })); await screen.findByTestId("hud-language-menu"); // The anchor wraps the bar and the stack above it: with the menu open it is // taller than the bar, and that whole height must stay inside the work area. @@ -1121,7 +1407,7 @@ describe("LaunchWindow language menu", () => { it("sizes the menu from CSS instead of the overlay window's own height", async () => { renderLaunchWindow(); - fireEvent.click(await screen.findByRole("button", { name: "English" })); + fireEvent.click(await screen.findByRole("button", { name: "Language: English" })); const menu = await screen.findByTestId("hud-language-menu"); // A measured maxHeight/bottom is what used to truncate the list to whatever @@ -1150,7 +1436,7 @@ describe("LaunchWindow popover dismissal", () => { /** Opens the language menu the way a user does, and hands back its panel. */ async function openLanguageMenu() { - fireEvent.click(await screen.findByRole("button", { name: "English" })); + fireEvent.click(await screen.findByRole("button", { name: "Language: English" })); return await screen.findByTestId("hud-language-menu"); } @@ -1361,9 +1647,10 @@ describe("LaunchWindow device buttons", () => { const microphoneButton = await screen.findByTestId("launch-microphone-button"); const cursorButton = await screen.findByTestId("launch-cursor-mode-button"); - expect(systemAudioButton).toBeDisabled(); - expect(microphoneButton).toBeDisabled(); - expect(cursorButton).toBeDisabled(); + // Locked, not `disabled`: see the next test. + expect(systemAudioButton).toHaveAttribute("aria-disabled", "true"); + expect(microphoneButton).toHaveAttribute("aria-disabled", "true"); + expect(cursorButton).toHaveAttribute("aria-disabled", "true"); fireEvent.click(systemAudioButton); fireEvent.click(microphoneButton); fireEvent.click(cursorButton); @@ -1374,6 +1661,37 @@ describe("LaunchWindow device buttons", () => { expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalled(); }); + // A natively disabled button takes no pointer or focus events, so its tooltip could never + // open. During a take the toggles show what is being recorded, so they stay reachable. + it("keeps the toggles reachable with their tooltip while recording", async () => { + recorderState.value.recording = true; + recorderState.value.systemAudioEnabled = true; + renderLaunchWindow(); + await screen.findByTestId("launch-cursor-mode-button"); + + const locked = [ + ["launch-system-audio-button", "System audio: records the sound your computer plays"], + ["launch-microphone-button", "Microphone: records your voice"], + [ + "launch-webcam-button", + "Camera: records you as a separate layer you can move in the editor", + ], + [ + "launch-cursor-mode-button", + "Editable cursor: recorded separately, so you can restyle it and auto-zoom in the editor", + ], + ["launch-device-settings-button", "Device settings"], + ] as const; + for (const [testId, sentence] of locked) { + expect(screen.getByTestId(testId)).not.toBeDisabled(); + expect(await tooltipOn(testId)).toBe(sentence); + } + expect(screen.getByTestId("launch-system-audio-button")).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + it("keeps a local toggle change when preference persistence fails", async () => { const error = new Error("preference store unavailable"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); @@ -1425,14 +1743,14 @@ describe("LaunchWindow device settings", () => { vi.unstubAllGlobals(); }); - it("opens from the settings button and closes again from its own Done control", async () => { + it("opens from the settings button and closes again from its own Close control", async () => { renderLaunchWindow(); fireEvent.click(await screen.findByTestId("launch-device-settings-button")); const panel = await screen.findByTestId("hud-device-settings"); expect(panel).toBeInTheDocument(); - fireEvent.click(within(panel).getByRole("button", { name: "Done" })); + fireEvent.click(within(panel).getByRole("button", { name: "Close" })); await waitFor(() => { expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); @@ -1519,7 +1837,10 @@ describe("LaunchWindow device settings", () => { renderLaunchWindow(); - expect(await screen.findByTestId("launch-device-settings-button")).toBeDisabled(); + const gear = await screen.findByTestId("launch-device-settings-button"); + expect(gear).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(gear); + expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); }); it("shows the running version and hands the update check to the main process", async () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 3be181124..a97ab04cc 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -23,6 +23,7 @@ import { HudDragHandle, HudLanguageButton, HudLanguageMenu, + HudLayoutProvider, HudMicButton, HudNotesButton, HudNotice, @@ -87,8 +88,8 @@ export function LaunchWindow() { const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase(); // Short code shown on the button itself (matches the design's "EN"/"FR" - // treatment) — activeLanguageLabel (the full localized name) stays as the - // tooltip/aria-label text. + // treatment) — activeLanguageLabel (the language's own name) goes into the + // tooltip/aria-label sentence ("Language: Deutsch"). const languageCode = locale.split("-")[0].toUpperCase(); const { @@ -135,9 +136,10 @@ export function LaunchWindow() { const [trayLayout, setTrayLayout] = useState<"horizontal" | "vertical">( () => loadUserPreferences().trayLayout, ); + // False until the platform, and on macOS and Linux the native helper, have answered: the + // cursor toggle is only shown once it is known to change something. const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const [isLinuxHud, setIsLinuxHud] = useState(false); - const [browserForcesSystemCursor, setBrowserForcesSystemCursor] = useState(false); // The running version, and whether this copy may offer an update check at all — a // Store/Flathub/Snap/Nix install is kept current by its package manager and is offered // nothing (electron/install-channel.ts). Asked once: neither answer changes while the app @@ -242,25 +244,30 @@ export function LaunchWindow() { // you get two cursors. Verified against a real recording at the // time. The helper is what makes the control mean something, // because it owns the video and never asks WebRTC for anything. - setSupportsCursorModeToggle( - platform === "win32" || platform === "darwin" || platform === "linux", - ); + const offersCursorMode = + platform === "win32" || platform === "darwin" || platform === "linux"; setIsLinuxHud(platform === "linux"); // Without its native helper, macOS or Linux records through the browser, which // always bakes in the system cursor (`effectiveBrowserCursorMode`). Switching - // to the editable cursor then restores nothing, so the hint must not say so. + // to the editable cursor then restores nothing: an option that changes nothing + // is not shown, so the toggle waits for the helper's answer, and a failed + // answer keeps it hidden. Windows has no such fallback and no probe. const probe = platform === "darwin" ? window.electronAPI?.isNativeMacCaptureAvailable : platform === "linux" ? window.electronAPI?.isNativeLinuxCaptureAvailable : undefined; - probe?.() + if (!probe) { + setSupportsCursorModeToggle(offersCursorMode); + return; + } + probe() .then((result) => { - if (!cancelled) setBrowserForcesSystemCursor(!result.success || !result.available); + if (!cancelled) setSupportsCursorModeToggle(result.success && result.available); }) .catch(() => { - if (!cancelled) setBrowserForcesSystemCursor(true); + if (!cancelled) setSupportsCursorModeToggle(false); }); } }) @@ -1058,27 +1065,29 @@ export function LaunchWindow() { dismissSoftwareEncoderFallbackNotice(); }, [dismissSoftwareEncoderFallbackNotice]); - // On Linux the ScreenCast portal owns the choice, so there is no in-app - // selection to name and none to demand: the idle label says what pressing - // record will do, and the recording label stays neutral because the portal - // reports a KIND, never a window title. Naming a source we were never told - // is what put a window's name on a full-screen recording. + // The Record button says what pressing it does. The source's name lives on the source + // button, and naming it here as well is what made this button read "Display 1". + // + // On Linux the ScreenCast portal owns the choice, so there is no in-app selection to + // demand: the idle label says what pressing record will do, and the portal reports a + // KIND, never a window title, so no source is named at all. Naming a source we were never + // told is what put a window's name on a full-screen recording. const recordLabel = saving ? t("recording.saving") - : portalOwnsSource - ? recording - ? t("recording.inProgress") - : t("recording.systemPicker") - : hasSelectedSource || recording - ? selectedSource - : t("recording.selectSource"); + : recording + ? t("recording.stop") + : portalOwnsSource + ? t("recording.systemPicker") + : hasSelectedSource + ? t("recording.start") + : t("recording.selectSource"); // Stable identity, or the panel's memo boundary would break on every parent // render — including the once-a-second one during a recording. const deviceSettingsLabels = useMemo( () => ({ title: t("deviceSettings.title"), - done: t("deviceSettings.done"), + close: t("deviceSettings.close"), microphone: t("audio.inputDevice"), camera: t("webcam.cameraDevice"), micLevel: t("deviceSettings.micLevel"), @@ -1125,173 +1134,187 @@ export function LaunchWindow() { Everything is laid out by flexbox relative to the bar, so no popover needs a measured position and none of them can move the window. */}
-
- - - - - - - {/* No source button on Linux: `SelectSources` has no parameter + +
+ + + + + + + {/* No source button on Linux: `SelectSources` has no parameter naming a source, so nothing this picker returned could reach the capture. It raised a second portal dialog of its own — via `desktopCapturer.getSources()` — whose grant was then discarded, which is why picking a window here changed nothing. The compositor's picker is the only one that decides, and it appears when recording starts. */} - {!portalOwnsSource && ( - - )} + {!portalOwnsSource && ( + + )} - + - {/* System audio / mic / camera / cursor — each its own standalone + {/* System audio / mic / camera / cursor — each its own standalone transparent icon button (no shared group pill), matching the design exactly: rest color is muted gray, active/enabled color is the accent green. */} - - {/* The gear configures the two toggles beside it, so the three sit + + {/* The gear configures the two toggles beside it, so the three sit closer together than the bar's normal control spacing — proximity is the design's own grouping device, no extra furniture needed. */} -
- {microphoneOffered && ( - + {microphoneOffered && ( + + )} + + +
+ {supportsCursorModeToggle && ( + )} - - -
- {supportsCursorModeToggle && ( - - )} - - - - - - {!recording && ( - - )} - {recording && ( - + + - )} - {!isLinuxHud && ( - - )} + {!recording && ( + + )} - + {recording && ( + + )} - {/* Right sidebar controls */} -
- + {!isLinuxHud && ( + + )} - + {/* Right sidebar controls */} +
+ + + + + +
-
+ {(isPopoverOpen || hasNotices) && ( // column-reverse: first child sits closest to the bar. diff --git a/src/components/launch/hudGeometry.test.ts b/src/components/launch/hudGeometry.test.ts index 167ad7f13..2ebb7c0ff 100644 --- a/src/components/launch/hudGeometry.test.ts +++ b/src/components/launch/hudGeometry.test.ts @@ -3,6 +3,7 @@ import { computeHudBarMaxHeight, computeHudModalMaxHeight, computeHudPopoverMaxHeight, + computeHudTooltipClearance, computeHudWindowSize, HUD_BAR_BOTTOM, HUD_EDGE_SLACK, @@ -95,6 +96,33 @@ describe("computeHudPopoverMaxHeight", () => { }); }); +describe("computeHudTooltipClearance", () => { + it("clears the padding above a button in a horizontal bar", () => { + expect( + computeHudTooltipClearance({ top: 508, right: 300 }, { top: 500, right: 900 }, "top"), + ).toBe(8); + }); + + it("clears a vertical bar that widened to show the timer, not just the trigger", () => { + // Idle: the bar hugs its 34px buttons. Recording: the timer makes it about 28px wider than + // the Pause, Restart and Cancel buttons, which stay where they were. + const idle = computeHudTooltipClearance({ top: 0, right: 92 }, { top: 0, right: 100 }, "right"); + const recording = computeHudTooltipClearance( + { top: 0, right: 72 }, + { top: 0, right: 100 }, + "right", + ); + expect(idle).toBe(8); + expect(recording).toBe(28); + }); + + it("never pulls a tooltip toward the trigger", () => { + expect(computeHudTooltipClearance({ top: 495, right: 0 }, { top: 500, right: 0 }, "top")).toBe( + 0, + ); + }); +}); + describe("computeHudBarMaxHeight", () => { it("leaves room below the screen height for a popover", () => { const max = computeHudBarMaxHeight(SCREEN); diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index 3800fef86..ae0787225 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -149,3 +149,21 @@ export function computeHudWindowSize(input: HudSizeInput): { }, }; } + +/** + * How far past its trigger a tooltip has to sit to clear the BAR, on the side it opens. + * + * Radix places a tooltip against the small trigger, but the bar is the surface the eye sees. It + * pads the button (8px from the button's top edge to the bar's), and a vertical bar widens to + * fit the timer while recording (Pause, Restart and Cancel then sit about 28px inside its right + * edge). Both ate into the tooltip's gap, so it overlapped the bar. Measured at the moment the + * tooltip opens, it holds for either orientation and either state. + */ +export function computeHudTooltipClearance( + trigger: { top: number; right: number }, + bar: { top: number; right: number }, + side: "top" | "right", +): number { + const inset = side === "top" ? trigger.top - bar.top : bar.right - trigger.right; + return Math.max(0, Math.round(inset)); +} diff --git a/src/components/ui/tooltip.test.tsx b/src/components/ui/tooltip.test.tsx new file mode 100644 index 000000000..e2dc233c0 --- /dev/null +++ b/src/components/ui/tooltip.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TOOLTIP_GAP_PX, Tooltip, TooltipProvider } from "./tooltip"; + +class StubResizeObserver { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } +} + +beforeEach(() => { + vi.stubGlobal("ResizeObserver", StubResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +function renderTooltip(props: Partial> = {}) { + render( + + + + + , + ); + return screen.getByRole("button", { name: "Zoom" }); +} + +// Radix draws the visible tooltip and a visually hidden `role="tooltip"` copy for assistive +// technology, so the styled node is found by its slot. +function visibleTooltip() { + return document.querySelector('[data-slot="tooltip-content"]'); +} + +function popperWrapper() { + return document.querySelector("[data-radix-popper-content-wrapper]"); +} + +describe("Tooltip", () => { + it("wraps long text and follows the text's own direction", () => { + const button = renderTooltip(); + act(() => button.focus()); + + const content = visibleTooltip(); + expect(content).toHaveAttribute("dir", "auto"); + expect(content?.className).toContain("max-w-[260px]"); + // Balanced lines, so a translation never ends on a single orphan word. + expect(content?.className).toContain("text-balance"); + expect(content).toHaveTextContent("Add a zoom at the playhead"); + }); + + it("renders a shortcut as its own left-to-right chip after the text", () => { + const button = renderTooltip({ shortcut: "Ctrl + Z" }); + act(() => button.focus()); + + const chip = visibleTooltip()?.querySelector("kbd"); + expect(chip).toHaveTextContent("Ctrl + Z"); + expect(chip).toHaveAttribute("dir", "ltr"); + // The chip is a sibling of the text, not part of the translated string. + expect(chip?.previousSibling?.textContent).toBe("Add a zoom at the playhead"); + }); + + it("draws no chip when there is no shortcut", () => { + const button = renderTooltip(); + act(() => button.focus()); + + expect(visibleTooltip()?.querySelector("kbd")).toBeNull(); + }); + + // The eye reads the gap against the trigger's edge. It was 6px, which two dark surfaces make + // look like overlap; the jsdom boxes are all zero-sized, so the offset is the whole translation. + it("keeps a real gap between the trigger and the tooltip", async () => { + expect(TOOLTIP_GAP_PX).toBe(8); + const button = renderTooltip(); + act(() => button.focus()); + + await waitFor(() => expect(popperWrapper()?.style.transform).toBe("translate(0px, -8px)")); + }); + + it("lets a trigger that sits inside a padded surface ask for a larger gap", async () => { + const button = renderTooltip({ sideOffset: 8 + 28 }); + act(() => button.focus()); + + await waitFor(() => expect(popperWrapper()?.style.transform).toBe("translate(0px, -36px)")); + }); + + it("opens 400 ms after the pointer arrives, not before", () => { + vi.useFakeTimers(); + const button = renderTooltip(); + + fireEvent.pointerMove(button); + act(() => { + vi.advanceTimersByTime(399); + }); + expect(visibleTooltip()).toBeNull(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(visibleTooltip()).not.toBeNull(); + }); + + it("opens at once on keyboard focus and describes the trigger", () => { + const button = renderTooltip(); + act(() => button.focus()); + + expect(visibleTooltip()).not.toBeNull(); + expect(button).toHaveAccessibleDescription("Add a zoom at the playhead"); + }); +}); diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 718f09f2d..01b06c55e 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -3,14 +3,24 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +// Timing from technical-documentation/engineering/tooltips.md: a tooltip appears 400 ms after +// the pointer stops, and moving to a neighbouring control within 300 ms shows its tooltip at once. +const TOOLTIP_OPEN_DELAY_MS = 400; +const TOOLTIP_SKIP_DELAY_MS = 300; +// The space between the trigger and the tooltip. 6px read as touching on a dark surface: a +// tooltip is dark too, so it needs a gap the eye can see. +export const TOOLTIP_GAP_PX = 8; + function TooltipProvider({ - delayDuration = 200, + delayDuration = TOOLTIP_OPEN_DELAY_MS, + skipDelayDuration = TOOLTIP_SKIP_DELAY_MS, ...props }: React.ComponentProps) { return ( ); @@ -33,16 +43,21 @@ TooltipTrigger.displayName = "TooltipTrigger"; function TooltipContent({ className, - sideOffset = 6, + sideOffset = TOOLTIP_GAP_PX, ...props }: React.ComponentProps) { return ( void; } ->(({ children, content, side, className }, ref) => ( - - - {children} - - - {content} - - -)); +>( + ( + { + children, + content, + shortcut, + side, + sideOffset, + collisionPadding, + className, + open, + onOpenChange, + }, + ref, + ) => ( + + + {children} + + + {content} + {shortcut ? ( + + {shortcut} + + ) : null} + + + ), +); Tooltip.displayName = "Tooltip"; export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger }; diff --git a/src/i18n/__tests__/tooltipCopy.test.ts b/src/i18n/__tests__/tooltipCopy.test.ts new file mode 100644 index 000000000..105baddc5 --- /dev/null +++ b/src/i18n/__tests__/tooltipCopy.test.ts @@ -0,0 +1,77 @@ +// The two things `npm run i18n:check` cannot see in a tooltip string. +// +// It only compares key sets, so a locale that renames `{{language}}` or drops it passes, and at +// runtime shows the raw `{{raccourci}}` (or the untouched `{{language}}`). Nor does it look at +// length: a tooltip is capped at 260px wide and reads best in at most four lines. Add a key here +// when a tooltip (or the accessible name of an icon-only control) gets one. +// +// See technical-documentation/engineering/tooltips.md. + +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const LOCALES_DIR = resolve(process.cwd(), "src/i18n/locales"); +const MAX_LENGTH = 130; + +const TOOLTIP_KEYS: Record = { + launch: [ + "tooltips.hideHUD", + "tooltips.hideHUDTip", + "tooltips.closeApp", + "tooltips.restartRecording", + "tooltips.cancelRecording", + "tooltips.pauseRecording", + "tooltips.resumeRecording", + "tooltips.useVerticalTray", + "tooltips.useHorizontalTray", + "tooltips.changeSource", + "tooltips.systemAudio", + "tooltips.microphone", + "tooltips.camera", + "tooltips.openStudio", + "tooltips.openNotes", + "cursor.name", + "cursor.editableTip", + "cursor.systemTip", + "recording.selectSource", + "recording.systemPicker", + "recording.start", + "recording.stop", + "languageWithName", + "deviceSettings.title", + "deviceSettings.close", + ], +}; + +function read(locale: string, namespace: string, key: string): string { + const file = JSON.parse(readFileSync(`${LOCALES_DIR}/${locale}/${namespace}.json`, "utf8")); + const value = key.split(".").reduce((node, part) => node?.[part], file); + if (typeof value !== "string") throw new Error(`${locale}/${namespace}: ${key} is not a string`); + return value; +} + +const placeholders = (text: string) => + [...text.matchAll(/\{\{\s*\w+\s*\}\}/g)].map((m) => m[0]).sort(); +// en included: it is the source, and its own length is held to the same cap. +const locales = readdirSync(LOCALES_DIR); +const entries = Object.entries(TOOLTIP_KEYS).flatMap(([namespace, keys]) => + keys.map((key) => [namespace, key] as const), +); + +describe("tooltip strings", () => { + it.each(locales)("%s keeps the placeholders of en and stays short", (locale) => { + const placeholderDrift: string[] = []; + const tooLong: string[] = []; + for (const [namespace, key] of entries) { + const text = read(locale, namespace, key); + const source = read("en", namespace, key); + if (placeholders(text).join() !== placeholders(source).join()) { + placeholderDrift.push(`${namespace}.${key}: ${text}`); + } + if (text.length > MAX_LENGTH) tooLong.push(`${namespace}.${key} (${text.length})`); + } + expect(placeholderDrift).toEqual([]); + expect(tooLong).toEqual([]); + }); +}); diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index b1dc21aff..7af0a57d1 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "إخفاء واجهة العرض", + "hideHUDTip": "إخفاء الشريط. يمكنك إظهاره مجددًا من أيقونة علبة النظام.", "closeApp": "إغلاق التطبيق", - "restartRecording": "إعادة تشغيل التسجيل", - "cancelRecording": "إلغاء التسجيل", + "restartRecording": "حذف هذا التسجيل وبدء تسجيل جديد", + "cancelRecording": "إيقاف هذا التسجيل وحذفه", "pauseRecording": "إيقاف التسجيل مؤقتاً", "resumeRecording": "استئناف التسجيل", "useVerticalTray": "استخدام الشريط العمودي", "useHorizontalTray": "استخدام الشريط الأفقي", - "openStudio": "فتح الاستوديو", - "openNotes": "فتح الملاحظات", + "changeSource": "اختيار شاشة أو نافذة أخرى", + "systemAudio": "صوت النظام: يسجّل الصوت الذي يشغّله الكمبيوتر", + "microphone": "الميكروفون: يسجّل صوتك", + "camera": "الكاميرا: تسجّل صورتك كطبقة منفصلة يمكنك تحريكها في المحرر", + "openStudio": "فتح الاستوديو: محرر تسجيلاتك", + "openNotes": "فتح الملاحظات: نص تقرؤه أثناء التسجيل", "openNotesPlaceholder": "اكتب ملاحظاتك هنا...", "notesToolbar": { "bold": "غامق", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "تفعيل صوت النظام", - "disableSystemAudio": "تعطيل صوت النظام", - "enableMicrophone": "تفعيل الميكروفون", - "disableMicrophone": "تعطيل الميكروفون", - "defaultMicrophone": "الميكروفون الافتراضي", "systemAudio": "صوت النظام", "microphone": "الميكروفون", - "on": "قيد التشغيل", - "off": "متوقف", "inputDevice": "جهاز الإدخال" }, "webcam": { - "enableWebcam": "تفعيل كاميرا الويب", - "disableWebcam": "تعطيل كاميرا الويب", - "defaultCamera": "الكاميرا الافتراضية", "searching": "جاري البحث...", "noneFound": "لم يتم العثور على كاميرا", "unavailable": "الكاميرا غير متوفرة", @@ -68,10 +63,12 @@ "recording": { "selectSource": "يرجى تحديد مصدر للتسجيل", "systemPicker": "سيسألك النظام عمّا تريد مشاركته", - "inProgress": "جارٍ التسجيل", + "start": "بدء التسجيل", + "stop": "إيقاف التسجيل وحفظه", "saving": "جاري الحفظ..." }, "language": "اللغة", + "languageWithName": "اللغة: {{language}}", "systemLanguagePrompt": { "title": "هل تريد استخدام لغة نظامك؟", "description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟", @@ -79,8 +76,9 @@ "keepDefault": "الاحتفاظ باللغة الحالية" }, "cursor": { - "useEditableCursorHint": "استخدام مؤشر قابل للتحرير: يعيد تشغيل التكبير التلقائي وتأثيرات المؤشر", - "useSystemCursorHint": "استخدام مؤشر النظام: يوقف التكبير التلقائي وتأثيرات المؤشر" + "name": "مؤشر قابل للتحرير", + "editableTip": "مؤشر قابل للتحرير: يُسجَّل بشكل منفصل، فيمكنك تغيير شكله واستخدام التكبير التلقائي في المحرر", + "systemTip": "مؤشر النظام: مؤشرك الفعلي، يُسجَّل داخل الفيديو. بلا تغيير للشكل وبلا تكبير تلقائي." }, "softwareEncoderFallback": { "title": "تم التبديل إلى الترميز البرمجي", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "إعدادات الأجهزة", - "done": "تم", + "close": "إغلاق", "micLevel": "مستوى الإدخال", "micHint": "تحدّث لاختبار الميكروفون", "noMicrophones": "لم يتم العثور على ميكروفون", diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json index d4d3f3104..36609ab5e 100644 --- a/src/i18n/locales/cs/launch.json +++ b/src/i18n/locales/cs/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Skrýt HUD", + "hideHUDTip": "Skrýt panel. Znovu ho zobrazíte z ikony v oznamovací oblasti.", "closeApp": "Zavřít aplikaci", - "restartRecording": "Restartovat nahrávání", - "cancelRecording": "Zrušit nahrávání", + "restartRecording": "Smazat tuto nahrávku a začít novou", + "cancelRecording": "Zastavit a smazat tuto nahrávku", "pauseRecording": "Pozastavit nahrávání", "resumeRecording": "Obnovit nahrávání", "useVerticalTray": "Použít svislý panel", "useHorizontalTray": "Použít vodorovný panel", - "openStudio": "Otevřít Studio", - "openNotes": "Otevřít poznámky", + "changeSource": "Vybrat jinou obrazovku nebo okno", + "systemAudio": "Systémový zvuk: nahrává zvuk, který přehrává počítač", + "microphone": "Mikrofon: nahrává váš hlas", + "camera": "Kamera: nahrává vás jako samostatnou vrstvu, kterou můžete v editoru přesouvat", + "openStudio": "Otevřít Studio: editor vašich nahrávek", + "openNotes": "Otevřít poznámky: text ke čtení během nahrávání", "openNotesPlaceholder": "Pište poznámky zde...", "notesToolbar": { "bold": "Tučně", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Povolit systémový zvuk", - "disableSystemAudio": "Zakázat systémový zvuk", - "enableMicrophone": "Povolit mikrofon", - "disableMicrophone": "Zakázat mikrofon", - "defaultMicrophone": "Výchozí mikrofon", "systemAudio": "Systémový zvuk", "microphone": "Mikrofon", - "on": "Zapnuto", - "off": "Vypnuto", "inputDevice": "Vstupní zařízení" }, "webcam": { - "enableWebcam": "Povolit webkameru", - "disableWebcam": "Zakázat webkameru", - "defaultCamera": "Výchozí kamera", "searching": "Hledání...", "noneFound": "Kamera nenalezena", "unavailable": "Kamera nedostupná", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Použít upravitelný kurzor: znovu zapne automatické přiblížení a efekty kurzoru", - "useSystemCursorHint": "Použít systémový kurzor: vypne automatické přiblížení a efekty kurzoru" + "name": "Upravitelný kurzor", + "editableTip": "Upravitelný kurzor: nahrává se zvlášť, takže v editoru můžete změnit jeho styl a použít automatické přiblížení", + "systemTip": "Systémový kurzor: váš skutečný kurzor, zaznamenaný ve videu. Bez změny stylu a bez automatického přiblížení." }, "sourceSelector": { "loading": "Načítání zdrojů...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Vyberte prosím zdroj pro nahrávání", "systemPicker": "Systém se zeptá, co sdílet", - "inProgress": "Nahrávání", + "start": "Spustit nahrávání", + "stop": "Zastavit a uložit nahrávku", "saving": "Ukládání..." }, "language": "Jazyk", + "languageWithName": "Jazyk: {{language}}", "systemLanguagePrompt": { "title": "Použít jazyk systému?", "description": "Detekovali jsme {{language}} jako jazyk vašeho systému. Chcete přepnout OpenScreen na {{language}}?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Nastavení zařízení", - "done": "Hotovo", + "close": "Zavřít", "micLevel": "Úroveň vstupu", "micHint": "Mluvte pro kontrolu mikrofonu", "noMicrophones": "Mikrofon nenalezen", diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index 8d199afaa..d3f39c6b0 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "HUD ausblenden", + "hideHUDTip": "Aufnahmeleiste ausblenden. Über das Symbol im Infobereich wieder einblenden.", "closeApp": "App schließen", - "restartRecording": "Aufnahme neu starten", - "cancelRecording": "Aufnahme abbrechen", + "restartRecording": "Diese Aufnahme löschen und eine neue starten", + "cancelRecording": "Stoppen und diese Aufnahme löschen", "pauseRecording": "Aufnahme pausieren", "resumeRecording": "Aufnahme fortsetzen", "useVerticalTray": "Vertikale Leiste verwenden", "useHorizontalTray": "Horizontale Leiste verwenden", - "openStudio": "Studio öffnen", - "openNotes": "Notizen öffnen", + "changeSource": "Anderen Bildschirm oder anderes Fenster wählen", + "systemAudio": "Systemton: nimmt den Ton auf, den dein Computer wiedergibt", + "microphone": "Mikrofon: nimmt deine Stimme auf", + "camera": "Kamera: nimmt dich als getrennte Ebene auf, die du im Editor verschieben kannst", + "openStudio": "Studio öffnen: der Editor für deine Aufnahmen", + "openNotes": "Notizen öffnen: Text zum Ablesen während der Aufnahme", "openNotesPlaceholder": "Hier Notizen schreiben…", "notesToolbar": { "bold": "Fett", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Systemton aktivieren", - "disableSystemAudio": "Systemton deaktivieren", - "enableMicrophone": "Mikrofon aktivieren", - "disableMicrophone": "Mikrofon deaktivieren", - "defaultMicrophone": "Standardmikrofon", "systemAudio": "Systemton", "microphone": "Mikrofon", - "on": "An", - "off": "Aus", "inputDevice": "Eingabegerät" }, "webcam": { - "enableWebcam": "Webcam aktivieren", - "disableWebcam": "Webcam deaktivieren", - "defaultCamera": "Standardkamera", "searching": "Suche läuft…", "noneFound": "Keine Kamera gefunden", "unavailable": "Kamera nicht verfügbar", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Bearbeitbaren Cursor verwenden: schaltet Auto-Zoom und Cursor-Effekte wieder ein", - "useSystemCursorHint": "System-Cursor verwenden: schaltet Auto-Zoom und Cursor-Effekte aus" + "name": "Bearbeitbarer Cursor", + "editableTip": "Bearbeitbarer Cursor: wird getrennt aufgenommen, damit du im Editor sein Aussehen ändern und Auto-Zoom nutzen kannst", + "systemTip": "System-Cursor: dein echter Cursor, im Video aufgenommen. Aussehen nicht änderbar, kein Auto-Zoom." }, "sourceSelector": { "loading": "Quellen werden geladen…", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Bitte wähle eine Quelle für die Aufnahme", "systemPicker": "Dein System fragt gleich, was du teilen möchtest", - "inProgress": "Aufnahme läuft", + "start": "Aufnahme starten", + "stop": "Aufnahme stoppen und speichern", "saving": "Wird gespeichert…" }, "language": "Sprache", + "languageWithName": "Sprache: {{language}}", "systemLanguagePrompt": { "title": "Systemsprache verwenden?", "description": "{{language}} wurde als deine Systemsprache erkannt. Soll OpenScreen auf {{language}} umgestellt werden?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Geräteeinstellungen", - "done": "Fertig", + "close": "Schließen", "micLevel": "Eingangspegel", "micHint": "Sprich, um dein Mikrofon zu prüfen", "noMicrophones": "Kein Mikrofon gefunden", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index e02d96f8e..ecc9d67bb 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Hide recording bar", + "hideHUDTip": "Hide the recording bar. Show it again from the tray icon.", "closeApp": "Quit OpenScreen", - "restartRecording": "Restart recording", - "cancelRecording": "Cancel recording", + "restartRecording": "Delete this recording and start a new one", + "cancelRecording": "Stop and delete this recording", "pauseRecording": "Pause recording", "resumeRecording": "Resume recording", "useVerticalTray": "Switch to vertical bar", "useHorizontalTray": "Switch to horizontal bar", - "openStudio": "Open Studio", - "openNotes": "Open Notes", + "changeSource": "Choose another screen or window", + "systemAudio": "System audio: records the sound your computer plays", + "microphone": "Microphone: records your voice", + "camera": "Camera: records you as a separate layer you can move in the editor", + "openStudio": "Open Studio: the editor for your recordings", + "openNotes": "Open Notes: text to read while you record", "openNotesPlaceholder": "Take notes here...", "notesToolbar": { "bold": "Bold", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Enable system audio", - "disableSystemAudio": "Disable system audio", - "enableMicrophone": "Enable microphone", - "disableMicrophone": "Disable microphone", - "defaultMicrophone": "Default Microphone", "systemAudio": "System audio", "microphone": "Microphone", - "on": "On", - "off": "Off", "inputDevice": "Input device" }, "webcam": { - "enableWebcam": "Enable webcam", - "disableWebcam": "Disable webcam", - "defaultCamera": "Default Camera", "searching": "Searching...", "noneFound": "No camera found", "unavailable": "Camera unavailable", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Use editable cursor: turns auto zoom and cursor effects back on", - "useSystemCursorHint": "Use system cursor: turns off auto zoom and cursor effects" + "name": "Editable cursor", + "editableTip": "Editable cursor: recorded separately, so you can restyle it and auto-zoom in the editor", + "systemTip": "System cursor: your real cursor, recorded in the video. No restyling, no auto-zoom." }, "sourceSelector": { "loading": "Loading screens and windows...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Choose a screen or window to record", "systemPicker": "Your system will ask what to share", - "inProgress": "Recording", + "start": "Start recording", + "stop": "Stop and save the recording", "saving": "Saving..." }, "language": "Language", + "languageWithName": "Language: {{language}}", "systemLanguagePrompt": { "title": "Use your system language?", "description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Device settings", - "done": "Done", + "close": "Close", "micLevel": "Input level", "micHint": "Speak to check your microphone", "noMicrophones": "No microphone found", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 34965561c..beaefb3e4 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Ocultar HUD", + "hideHUDTip": "Ocultar la barra. Vuelve a mostrarla desde el icono de la bandeja del sistema.", "closeApp": "Cerrar aplicación", - "restartRecording": "Reiniciar grabación", - "cancelRecording": "Cancelar grabación", + "restartRecording": "Eliminar esta grabación y empezar una nueva", + "cancelRecording": "Detener y eliminar esta grabación", "pauseRecording": "Pausar grabación", "resumeRecording": "Reanudar grabación", "useVerticalTray": "Usar bandeja vertical", "useHorizontalTray": "Usar bandeja horizontal", - "openStudio": "Abrir Studio", - "openNotes": "Abrir notas", + "changeSource": "Elegir otra pantalla o ventana", + "systemAudio": "Audio del sistema: graba el sonido que reproduce tu equipo", + "microphone": "Micrófono: graba tu voz", + "camera": "Cámara: te graba en una capa aparte que puedes mover en el editor", + "openStudio": "Abrir Studio: el editor de tus grabaciones", + "openNotes": "Abrir notas: texto para leer mientras grabas", "openNotesPlaceholder": "Escribe tus notas aquí...", "notesToolbar": { "bold": "Negrita", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Activar audio del sistema", - "disableSystemAudio": "Desactivar audio del sistema", - "enableMicrophone": "Activar micrófono", - "disableMicrophone": "Desactivar micrófono", - "defaultMicrophone": "Micrófono predeterminado", "systemAudio": "Audio del sistema", "microphone": "Micrófono", - "on": "Activado", - "off": "Desactivado", "inputDevice": "Dispositivo de entrada" }, "webcam": { - "enableWebcam": "Activar cámara web", - "disableWebcam": "Desactivar cámara web", - "defaultCamera": "Cámara predeterminada", "searching": "Buscando...", "noneFound": "No se encontró cámara", "unavailable": "Cámara no disponible", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Usar cursor editable: vuelve a activar el zoom automático y los efectos del cursor", - "useSystemCursorHint": "Usar cursor del sistema: desactiva el zoom automático y los efectos del cursor" + "name": "Cursor editable", + "editableTip": "Cursor editable: se graba por separado, así puedes cambiar su estilo y usar el zoom automático en el editor", + "systemTip": "Cursor del sistema: tu cursor real, grabado en el video. Sin cambios de estilo ni zoom automático." }, "sourceSelector": { "loading": "Cargando fuentes...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Por favor selecciona una fuente para grabar", "systemPicker": "El sistema te preguntará qué compartir", - "inProgress": "Grabando", + "start": "Iniciar grabación", + "stop": "Detener y guardar la grabación", "saving": "Guardando..." }, "language": "Idioma", + "languageWithName": "Idioma: {{language}}", "systemLanguagePrompt": { "title": "¿Usar el idioma del sistema?", "description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Ajustes de dispositivos", - "done": "Listo", + "close": "Cerrar", "micLevel": "Nivel de entrada", "micHint": "Habla para probar tu micrófono", "noMicrophones": "No se encontró ningún micrófono", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 3a8d35690..6b1914483 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Masquer le HUD", + "hideHUDTip": "Masquer la barre. Réaffichez-la depuis l'icône de la zone de notification.", "closeApp": "Fermer l'application", - "restartRecording": "Redémarrer l'enregistrement", - "cancelRecording": "Annuler l'enregistrement", + "restartRecording": "Supprimer cet enregistrement et en démarrer un nouveau", + "cancelRecording": "Arrêter et supprimer cet enregistrement", "pauseRecording": "Mettre en pause l'enregistrement", "resumeRecording": "Reprendre l'enregistrement", "useVerticalTray": "Utiliser la barre verticale", "useHorizontalTray": "Utiliser la barre horizontale", - "openStudio": "Ouvrir le Studio", - "openNotes": "Ouvrir les notes", + "changeSource": "Choisir un autre écran ou une autre fenêtre", + "systemAudio": "Audio système : enregistre le son que joue votre ordinateur", + "microphone": "Microphone : enregistre votre voix", + "camera": "Caméra : vous enregistre sur un calque séparé, déplaçable dans l'éditeur", + "openStudio": "Ouvrir le Studio : l'éditeur de vos enregistrements", + "openNotes": "Ouvrir les notes : texte à lire pendant l'enregistrement", "openNotesPlaceholder": "Prenez des notes ici...", "notesToolbar": { "bold": "Gras", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Activer l'audio système", - "disableSystemAudio": "Désactiver l'audio système", - "enableMicrophone": "Activer le microphone", - "disableMicrophone": "Désactiver le microphone", - "defaultMicrophone": "Microphone par défaut", "systemAudio": "Audio système", "microphone": "Microphone", - "on": "Activé", - "off": "Désactivé", "inputDevice": "Périphérique d'entrée" }, "webcam": { - "enableWebcam": "Activer la webcam", - "disableWebcam": "Désactiver la webcam", - "defaultCamera": "Caméra par défaut", "searching": "Recherche en cours...", "noneFound": "Aucune caméra trouvée", "unavailable": "Caméra non disponible", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Utiliser le curseur éditable : réactive le zoom automatique et les effets de curseur", - "useSystemCursorHint": "Utiliser le curseur système : désactive le zoom automatique et les effets de curseur" + "name": "Curseur éditable", + "editableTip": "Curseur éditable : enregistré séparément, pour changer son style et utiliser le zoom automatique dans l'éditeur", + "systemTip": "Curseur système : votre vrai curseur, enregistré dans la vidéo. Sans changement de style ni zoom automatique." }, "sourceSelector": { "loading": "Chargement des sources...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Veuillez sélectionner une source à enregistrer", "systemPicker": "Le système vous demandera quoi partager", - "inProgress": "Enregistrement en cours", + "start": "Démarrer l'enregistrement", + "stop": "Arrêter et sauvegarder l'enregistrement", "saving": "Sauvegarde..." }, "language": "Langue", + "languageWithName": "Langue : {{language}}", "systemLanguagePrompt": { "title": "Utiliser la langue de votre système ?", "description": "Nous avons détecté {{language}} comme langue système. Voulez-vous passer OpenScreen en {{language}} ?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Paramètres des périphériques", - "done": "Terminé", + "close": "Fermer", "micLevel": "Niveau d'entrée", "micHint": "Parlez pour tester votre microphone", "noMicrophones": "Aucun microphone détecté", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index fae376c8d..e569380c5 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Nascondi", + "hideHUDTip": "Nascondi la barra. Puoi mostrarla di nuovo dall'icona nell'area di notifica.", "closeApp": "Chiudi app", - "restartRecording": "Riavvia registrazione", - "cancelRecording": "Annulla registrazione", + "restartRecording": "Elimina questa registrazione e iniziane una nuova", + "cancelRecording": "Interrompi ed elimina questa registrazione", "pauseRecording": "Metti in pausa registrazione", "resumeRecording": "Riprendi registrazione", "useVerticalTray": "Usa barra verticale", "useHorizontalTray": "Usa barra orizzontale", - "openStudio": "Apri Studio", - "openNotes": "Apri note", + "changeSource": "Scegli un altro schermo o un'altra finestra", + "systemAudio": "Audio di sistema: registra il suono riprodotto dal computer", + "microphone": "Microfono: registra la tua voce", + "camera": "Fotocamera: ti registra come livello separato che puoi spostare nell'editor", + "openStudio": "Apri Studio: l'editor per le tue registrazioni", + "openNotes": "Apri note: testo da leggere durante la registrazione", "openNotesPlaceholder": "Scrivi le tue note qui...", "notesToolbar": { "bold": "Grassetto", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Abilita audio di sistema", - "disableSystemAudio": "Disabilita audio di sistema", - "enableMicrophone": "Abilita microfono", - "disableMicrophone": "Disabilita microfono", - "defaultMicrophone": "Microfono predefinito", "systemAudio": "Audio di sistema", "microphone": "Microfono", - "on": "Attivo", - "off": "Disattivo", "inputDevice": "Dispositivo di input" }, "webcam": { - "enableWebcam": "Abilita webcam", - "disableWebcam": "Disabilita webcam", - "defaultCamera": "Fotocamera predefinita", "searching": "Ricerca in corso...", "noneFound": "Nessuna fotocamera trovata", "unavailable": "Fotocamera non disponibile", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Usa cursore modificabile: riattiva lo zoom automatico e gli effetti del cursore", - "useSystemCursorHint": "Usa cursore di sistema: disattiva lo zoom automatico e gli effetti del cursore" + "name": "Cursore modificabile", + "editableTip": "Cursore modificabile: registrato separatamente, così puoi cambiarne lo stile e usare lo zoom automatico nell'editor", + "systemTip": "Cursore di sistema: il tuo cursore reale, registrato nel video. Nessun cambio di stile, nessuno zoom automatico." }, "sourceSelector": { "loading": "Caricamento sorgenti...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Seleziona una sorgente da registrare", "systemPicker": "Il sistema ti chiederà cosa condividere", - "inProgress": "Registrazione in corso", + "start": "Avvia registrazione", + "stop": "Interrompi e salva la registrazione", "saving": "Salvataggio..." }, "language": "Lingua", + "languageWithName": "Lingua: {{language}}", "systemLanguagePrompt": { "title": "Usare la lingua del sistema?", "description": "Abbiamo rilevato {{language}} come lingua del sistema. Vuoi passare OpenScreen a {{language}}?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Impostazioni dispositivi", - "done": "Fatto", + "close": "Chiudi", "micLevel": "Livello di ingresso", "micHint": "Parla per testare il microfono", "noMicrophones": "Nessun microfono trovato", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 0429770d8..ece603fed 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "HUDを隠す", + "hideHUDTip": "録画バーを隠します。通知領域のアイコンから再表示できます。", "closeApp": "アプリを閉じる", - "restartRecording": "録画を再開", - "cancelRecording": "録画をキャンセル", + "restartRecording": "この録画を削除して、新しく録画を開始", + "cancelRecording": "停止してこの録画を削除", "pauseRecording": "録画を一時停止", "resumeRecording": "録画を再開", "useVerticalTray": "縦型トレイを使用", "useHorizontalTray": "横型トレイを使用", - "openStudio": "スタジオを開く", - "openNotes": "ノートを開く", + "changeSource": "別の画面またはウィンドウを選択", + "systemAudio": "システム音声:コンピューターが再生する音を録音します", + "microphone": "マイク:声を録音します", + "camera": "カメラ:自分の映像を別レイヤーとして録画し、エディターで動かせます", + "openStudio": "スタジオを開く:録画を編集するエディター", + "openNotes": "ノートを開く:録画中に読むテキスト", "openNotesPlaceholder": "ここにメモを入力...", "notesToolbar": { "bold": "太字", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "システム音声を有効にする", - "disableSystemAudio": "システム音声を無効にする", - "enableMicrophone": "マイクを有効にする", - "disableMicrophone": "マイクを無効にする", - "defaultMicrophone": "デフォルトのマイク", "systemAudio": "システム音声", "microphone": "マイク", - "on": "オン", - "off": "オフ", "inputDevice": "入力デバイス" }, "webcam": { - "enableWebcam": "ウェブカメラを有効にする", - "disableWebcam": "ウェブカメラを無効にする", - "defaultCamera": "デフォルトのカメラ", "searching": "検索中...", "noneFound": "カメラが見つかりません", "unavailable": "カメラが利用できません", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "編集可能なカーソルを使う:自動ズームとカーソル効果が再びオンになります", - "useSystemCursorHint": "システムカーソルを使う:自動ズームとカーソル効果がオフになります" + "name": "編集可能なカーソル", + "editableTip": "編集可能なカーソル:別途記録されるため、エディターでスタイルを変更でき、自動ズームも使えます", + "systemTip": "システムカーソル:実際のカーソルが動画に録画されます。スタイル変更や自動ズームは使えません。" }, "sourceSelector": { "loading": "ソースを読み込み中...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "録画するソースを選択してください", "systemPicker": "共有する対象はシステムが確認します", - "inProgress": "録画中", + "start": "録画を開始", + "stop": "録画を停止して保存", "saving": "保存中..." }, "language": "言語", + "languageWithName": "言語:{{language}}", "systemLanguagePrompt": { "title": "システム言語を使用しますか?", "description": "システム言語として{{language}}が検出されました。OpenScreenを{{language}}に切り替えますか?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "デバイス設定", - "done": "完了", + "close": "閉じる", "micLevel": "入力レベル", "micHint": "マイクをテストするには話してください", "noMicrophones": "マイクが見つかりません", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 70f0886dc..287937dc4 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "HUD 숨기기", + "hideHUDTip": "녹화 바를 숨깁니다. 알림 영역 아이콘에서 다시 표시할 수 있습니다.", "closeApp": "앱 닫기", - "restartRecording": "녹화 다시 시작", - "cancelRecording": "녹화 취소", + "restartRecording": "이 녹화를 삭제하고 새로 시작", + "cancelRecording": "중지하고 이 녹화 삭제", "pauseRecording": "녹화 일시정지", "resumeRecording": "녹화 재개", "useVerticalTray": "세로 트레이 사용", "useHorizontalTray": "가로 트레이 사용", - "openStudio": "스튜디오 열기", - "openNotes": "노트 열기", + "changeSource": "다른 화면 또는 창 선택", + "systemAudio": "시스템 오디오: 컴퓨터에서 재생되는 소리를 녹음합니다", + "microphone": "마이크: 내 목소리를 녹음합니다", + "camera": "카메라: 내 모습을 별도 레이어로 녹화하며 편집기에서 옮길 수 있습니다", + "openStudio": "스튜디오 열기: 녹화를 위한 편집기", + "openNotes": "노트 열기: 녹화 중 읽을 텍스트", "openNotesPlaceholder": "여기에 메모를 입력하세요...", "notesToolbar": { "bold": "굵게", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "시스템 오디오 활성화", - "disableSystemAudio": "시스템 오디오 비활성화", - "enableMicrophone": "마이크 활성화", - "disableMicrophone": "마이크 비활성화", - "defaultMicrophone": "기본 마이크", "systemAudio": "시스템 오디오", "microphone": "마이크", - "on": "켜짐", - "off": "꺼짐", "inputDevice": "입력 장치" }, "webcam": { - "enableWebcam": "웹캠 활성화", - "disableWebcam": "웹캠 비활성화", - "defaultCamera": "기본 카메라", "searching": "검색 중...", "noneFound": "카메라를 찾을 수 없음", "unavailable": "카메라를 사용할 수 없음", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "편집 가능한 커서 사용: 자동 줌과 커서 효과가 다시 켜집니다", - "useSystemCursorHint": "시스템 커서 사용: 자동 줌과 커서 효과가 꺼집니다" + "name": "편집 가능한 커서", + "editableTip": "편집 가능한 커서: 따로 기록되어 편집기에서 스타일을 바꾸고 자동 줌을 쓸 수 있습니다", + "systemTip": "시스템 커서: 실제 커서가 영상에 녹화됩니다. 스타일 변경과 자동 줌은 쓸 수 없습니다." }, "sourceSelector": { "loading": "소스 불러오는 중...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "녹화할 소스를 선택해 주세요", "systemPicker": "공유할 대상을 시스템이 묻습니다", - "inProgress": "녹화 중", + "start": "녹화 시작", + "stop": "녹화 중지 및 저장", "saving": "저장 중..." }, "language": "언어", + "languageWithName": "언어: {{language}}", "systemLanguagePrompt": { "title": "시스템 언어를 사용하시겠습니까?", "description": "시스템 언어가 {{language}}(으)로 감지되었습니다. OpenScreen을 {{language}}(으)로 전환하시겠습니까?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "장치 설정", - "done": "완료", + "close": "닫기", "micLevel": "입력 레벨", "micHint": "마이크를 테스트하려면 말해 보세요", "noMicrophones": "마이크를 찾을 수 없습니다", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 0ed727214..9939f55a3 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Ocultar HUD", + "hideHUDTip": "Ocultar a barra. Mostre-a de novo pelo ícone da bandeja do sistema.", "closeApp": "Fechar App", - "restartRecording": "Reiniciar gravação", - "cancelRecording": "Cancelar gravação", + "restartRecording": "Excluir esta gravação e iniciar uma nova", + "cancelRecording": "Parar e excluir esta gravação", "pauseRecording": "Pausar gravação", "resumeRecording": "Retomar gravação", "useVerticalTray": "Usar bandeja vertical", "useHorizontalTray": "Usar bandeja horizontal", - "openStudio": "Abrir Studio", - "openNotes": "Abrir notas", + "changeSource": "Escolher outra tela ou janela", + "systemAudio": "Áudio do sistema: grava o som reproduzido pelo computador", + "microphone": "Microfone: grava sua voz", + "camera": "Câmera: grava você em uma camada separada que pode ser movida no editor", + "openStudio": "Abrir Studio: o editor das suas gravações", + "openNotes": "Abrir notas: texto para ler durante a gravação", "openNotesPlaceholder": "Escreva suas notas aqui...", "notesToolbar": { "bold": "Negrito", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Ativar áudio do sistema", - "disableSystemAudio": "Desativar áudio do sistema", - "enableMicrophone": "Ativar microfone", - "disableMicrophone": "Desativar microfone", - "defaultMicrophone": "Microfone Padrão", "systemAudio": "Áudio do sistema", "microphone": "Microfone", - "on": "Ativado", - "off": "Desativado", "inputDevice": "Dispositivo de entrada" }, "webcam": { - "enableWebcam": "Ativar webcam", - "disableWebcam": "Desativar webcam", - "defaultCamera": "Câmera Padrão", "searching": "Procurando...", "noneFound": "Nenhuma câmera encontrada", "unavailable": "Câmera indisponível", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Usar cursor editável: reativa o zoom automático e os efeitos do cursor", - "useSystemCursorHint": "Usar cursor do sistema: desativa o zoom automático e os efeitos do cursor" + "name": "Cursor editável", + "editableTip": "Cursor editável: gravado separadamente, para você mudar o estilo e usar o zoom automático no editor", + "systemTip": "Cursor do sistema: seu cursor real, gravado no vídeo. Sem mudança de estilo, sem zoom automático." }, "sourceSelector": { "loading": "Carregando fontes...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Por favor, selecione uma fonte para gravar", "systemPicker": "O sistema perguntará o que compartilhar", - "inProgress": "Gravando", + "start": "Iniciar gravação", + "stop": "Parar e salvar a gravação", "saving": "Salvando..." }, "language": "Idioma", + "languageWithName": "Idioma: {{language}}", "systemLanguagePrompt": { "title": "Usar o idioma do seu sistema?", "description": "Detectamos {{language}} como o idioma do seu sistema. Deseja mudar o OpenScreen para {{language}}?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Configurações de dispositivos", - "done": "Concluído", + "close": "Fechar", "micLevel": "Nível de entrada", "micHint": "Fale para testar seu microfone", "noMicrophones": "Nenhum microfone encontrado", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 67ebf3da8..e01a29885 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Скрыть HUD", + "hideHUDTip": "Скрыть панель. Вернуть её можно через значок в области уведомлений.", "closeApp": "Закрыть приложение", - "restartRecording": "Перезапустить запись", - "cancelRecording": "Отменить запись", + "restartRecording": "Удалить эту запись и начать новую", + "cancelRecording": "Остановить и удалить эту запись", "pauseRecording": "Приостановить запись", "resumeRecording": "Возобновить запись", "useVerticalTray": "Использовать вертикальную панель", "useHorizontalTray": "Использовать горизонтальную панель", - "openStudio": "Открыть студию", - "openNotes": "Открыть заметки", + "changeSource": "Выбрать другой экран или окно", + "systemAudio": "Системный звук: записывает звук, который воспроизводит компьютер", + "microphone": "Микрофон: записывает ваш голос", + "camera": "Камера: записывает вас отдельным слоем, который можно двигать в редакторе", + "openStudio": "Открыть студию: редактор ваших записей", + "openNotes": "Открыть заметки: текст для чтения во время записи", "openNotesPlaceholder": "Пишите заметки здесь...", "notesToolbar": { "bold": "Жирный", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Включить системное аудио", - "disableSystemAudio": "Отключить системное аудио", - "enableMicrophone": "Включить микрофон", - "disableMicrophone": "Отключить микрофон", - "defaultMicrophone": "Микрофон по умолчанию", "systemAudio": "Системный звук", "microphone": "Микрофон", - "on": "Вкл", - "off": "Выкл", "inputDevice": "Устройство ввода" }, "webcam": { - "enableWebcam": "Включить веб-камеру", - "disableWebcam": "Отключить веб-камеру", - "defaultCamera": "Камера по умолчанию", "searching": "Поиск...", "noneFound": "Камера не найдена", "unavailable": "Камера недоступна", @@ -68,10 +63,12 @@ "recording": { "selectSource": "Пожалуйста, выберите источник для записи", "systemPicker": "Система спросит, чем поделиться", - "inProgress": "Идёт запись", + "start": "Начать запись", + "stop": "Остановить и сохранить запись", "saving": "Сохранение..." }, "language": "Язык", + "languageWithName": "Язык: {{language}}", "systemLanguagePrompt": { "title": "Использовать системный язык?", "description": "Мы обнаружили {{language}} как системный язык. Хотите переключить OpenScreen на {{language}}?", @@ -79,8 +76,9 @@ "keepDefault": "Оставить текущий язык" }, "cursor": { - "useEditableCursorHint": "Использовать редактируемый курсор: снова включает автозум и эффекты курсора", - "useSystemCursorHint": "Использовать системный курсор: отключает автозум и эффекты курсора" + "name": "Редактируемый курсор", + "editableTip": "Редактируемый курсор: записывается отдельно, поэтому в редакторе можно менять его стиль и использовать автозум", + "systemTip": "Системный курсор: ваш настоящий курсор, записанный в видео. Без смены стиля и без автозума." }, "softwareEncoderFallback": { "title": "Выполнен переход на программное кодирование", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Настройки устройств", - "done": "Готово", + "close": "Закрыть", "micLevel": "Уровень входа", "micHint": "Скажите что-нибудь, чтобы проверить микрофон", "noMicrophones": "Микрофон не найден", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 7df10beb9..36478ef7c 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Kontrol panelini gizle", + "hideHUDTip": "Araç çubuğunu gizle. Bildirim alanındaki simgeden yeniden gösterebilirsiniz.", "closeApp": "Uygulamayı kapat", - "restartRecording": "Kaydı yeniden başlat", - "cancelRecording": "Kaydı iptal et", + "restartRecording": "Bu kaydı sil ve yenisini başlat", + "cancelRecording": "Durdur ve bu kaydı sil", "pauseRecording": "Kaydı duraklat", "resumeRecording": "Kayda devam et", "useVerticalTray": "Dikey araç çubuğunu kullan", "useHorizontalTray": "Yatay araç çubuğunu kullan", - "openStudio": "Stüdyoyu aç", - "openNotes": "Notları aç", + "changeSource": "Başka bir ekran veya pencere seç", + "systemAudio": "Sistem sesi: bilgisayarın çaldığı sesi kaydeder", + "microphone": "Mikrofon: sesinizi kaydeder", + "camera": "Kamera: sizi düzenleyicide taşıyabileceğiniz ayrı bir katman olarak kaydeder", + "openStudio": "Stüdyoyu aç: kayıtlarınızın düzenleyicisi", + "openNotes": "Notları aç: kayıt sırasında okuyacağınız metin", "openNotesPlaceholder": "Notlarınızı buraya yazın...", "notesToolbar": { "bold": "Kalın", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Sistem sesini etkinleştir", - "disableSystemAudio": "Sistem sesini devre dışı bırak", - "enableMicrophone": "Mikrofonu etkinleştir", - "disableMicrophone": "Mikrofonu devre dışı bırak", - "defaultMicrophone": "Varsayılan Mikrofon", "systemAudio": "Sistem sesi", "microphone": "Mikrofon", - "on": "Açık", - "off": "Kapalı", "inputDevice": "Giriş cihazı" }, "webcam": { - "enableWebcam": "Kamerayı etkinleştir", - "disableWebcam": "Kamerayı devre dışı bırak", - "defaultCamera": "Varsayılan Kamera", "searching": "Aranıyor...", "noneFound": "Kamera bulunamadı", "unavailable": "Kamera kullanılamıyor", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "Düzenlenebilir imleci kullan: otomatik yakınlaştırmayı ve imleç efektlerini yeniden açar", - "useSystemCursorHint": "Sistem imlecini kullan: otomatik yakınlaştırmayı ve imleç efektlerini kapatır" + "name": "Düzenlenebilir imleç", + "editableTip": "Düzenlenebilir imleç: ayrı kaydedilir; düzenleyicide stilini değiştirebilir, otomatik yakınlaştırma kullanabilirsiniz", + "systemTip": "Sistem imleci: gerçek imleciniz, videoya kaydedilir. Stil değişikliği ve otomatik yakınlaştırma yok." }, "sourceSelector": { "loading": "Kaynaklar yükleniyor...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "Lütfen kayıt için bir kaynak seçin", "systemPicker": "Sisteminiz neyi paylaşacağınızı soracak", - "inProgress": "Kaydediliyor", + "start": "Kaydı başlat", + "stop": "Kaydı durdur ve kaydet", "saving": "Kaydediliyor..." }, "language": "Dil", + "languageWithName": "Dil: {{language}}", "systemLanguagePrompt": { "title": "Sistem dilinizi kullanmak ister misiniz?", "description": "Sistem diliniz {{language}} olarak algılandı. OpenScreen i {{language}} diline geçirmek ister misiniz?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Cihaz ayarları", - "done": "Tamam", + "close": "Kapat", "micLevel": "Giriş seviyesi", "micHint": "Mikrofonunuzu test etmek için konuşun", "noMicrophones": "Mikrofon bulunamadı", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index a76f0db84..1f3b54349 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "Ẩn HUD", + "hideHUDTip": "Ẩn thanh ghi hình. Hiện lại từ biểu tượng ở khay hệ thống.", "closeApp": "Đóng ứng dụng", - "restartRecording": "Khởi động lại ghi hình", - "cancelRecording": "Hủy ghi hình", + "restartRecording": "Xóa bản ghi hình này và bắt đầu bản mới", + "cancelRecording": "Dừng và xóa bản ghi hình này", "pauseRecording": "Tạm dừng ghi hình", "resumeRecording": "Tiếp tục ghi hình", "useVerticalTray": "Dùng khay dọc", "useHorizontalTray": "Dùng khay ngang", - "openStudio": "Mở Studio", - "openNotes": "Mở ghi chú", + "changeSource": "Chọn màn hình hoặc cửa sổ khác", + "systemAudio": "Âm thanh hệ thống: ghi âm thanh máy tính phát ra", + "microphone": "Micro: ghi giọng nói của bạn", + "camera": "Máy ảnh: ghi bạn thành một lớp riêng có thể di chuyển trong trình chỉnh sửa", + "openStudio": "Mở Studio: trình chỉnh sửa cho các bản ghi hình của bạn", + "openNotes": "Mở ghi chú: văn bản để đọc khi ghi hình", "openNotesPlaceholder": "Ghi chú của bạn tại đây...", "notesToolbar": { "bold": "In đậm", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "Bật âm thanh hệ thống", - "disableSystemAudio": "Tắt âm thanh hệ thống", - "enableMicrophone": "Bật micro", - "disableMicrophone": "Tắt micro", - "defaultMicrophone": "Micro Mặc định", "systemAudio": "Âm thanh hệ thống", "microphone": "Micro", - "on": "Bật", - "off": "Tắt", "inputDevice": "Thiết bị đầu vào" }, "webcam": { - "enableWebcam": "Bật webcam", - "disableWebcam": "Tắt webcam", - "defaultCamera": "Máy ảnh Mặc định", "searching": "Đang tìm kiếm...", "noneFound": "Không tìm thấy máy ảnh", "unavailable": "Máy ảnh không khả dụng", @@ -68,10 +63,12 @@ "recording": { "selectSource": "Vui lòng chọn một nguồn để ghi", "systemPicker": "Hệ thống sẽ hỏi bạn muốn chia sẻ gì", - "inProgress": "Đang ghi", + "start": "Bắt đầu ghi hình", + "stop": "Dừng và lưu bản ghi hình", "saving": "Đang lưu..." }, "language": "Ngôn ngữ", + "languageWithName": "Ngôn ngữ: {{language}}", "systemLanguagePrompt": { "title": "Sử dụng ngôn ngữ hệ thống của bạn?", "description": "Chúng tôi phát hiện {{language}} là ngôn ngữ hệ thống của bạn. Bạn có muốn chuyển OpenScreen sang {{language}} không?", @@ -79,8 +76,9 @@ "keepDefault": "Giữ ngôn ngữ hiện tại" }, "cursor": { - "useEditableCursorHint": "Dùng con trỏ có thể chỉnh sửa: bật lại thu phóng tự động và hiệu ứng con trỏ", - "useSystemCursorHint": "Dùng con trỏ hệ thống: tắt thu phóng tự động và hiệu ứng con trỏ" + "name": "Con trỏ có thể chỉnh sửa", + "editableTip": "Con trỏ có thể chỉnh sửa: được ghi riêng, nên bạn có thể đổi kiểu và dùng thu phóng tự động trong trình chỉnh sửa", + "systemTip": "Con trỏ hệ thống: con trỏ thật của bạn, được ghi vào video. Không đổi kiểu, không thu phóng tự động." }, "softwareEncoderFallback": { "title": "Đã chuyển sang mã hóa bằng phần mềm", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "Cài đặt thiết bị", - "done": "Xong", + "close": "Đóng", "micLevel": "Mức đầu vào", "micHint": "Hãy nói để kiểm tra micrô", "noMicrophones": "Không tìm thấy micrô", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 7a0b26cb9..d9bebade8 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "隐藏控制面板", + "hideHUDTip": "隐藏控制面板。可从托盘图标重新显示。", "closeApp": "关闭应用", - "restartRecording": "重新开始录制", - "cancelRecording": "取消录制", + "restartRecording": "删除此录制并重新开始", + "cancelRecording": "停止并删除此录制", "pauseRecording": "暂停录制", "resumeRecording": "继续录制", "useVerticalTray": "使用竖向托盘", "useHorizontalTray": "使用横向托盘", - "openStudio": "打开工作室", - "openNotes": "打开笔记", + "changeSource": "选择其他屏幕或窗口", + "systemAudio": "系统音频:录制电脑播放的声音", + "microphone": "麦克风:录制语音", + "camera": "摄像头:录制为单独图层,可在编辑器中移动", + "openStudio": "打开工作室:录制内容的编辑器", + "openNotes": "打开笔记:录制时阅读的文字", "openNotesPlaceholder": "在此输入笔记...", "notesToolbar": { "bold": "加粗", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "启用系统音频", - "disableSystemAudio": "禁用系统音频", - "enableMicrophone": "启用麦克风", - "disableMicrophone": "禁用麦克风", - "defaultMicrophone": "默认麦克风", "systemAudio": "系统音频", "microphone": "麦克风", - "on": "开", - "off": "关", "inputDevice": "输入设备" }, "webcam": { - "enableWebcam": "启用摄像头", - "disableWebcam": "禁用摄像头", - "defaultCamera": "默认摄像头", "searching": "正在搜索...", "noneFound": "未找到摄像头", "unavailable": "摄像头不可用", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "使用可编辑光标:将重新开启自动缩放和光标效果", - "useSystemCursorHint": "使用系统光标:将关闭自动缩放和光标效果" + "name": "可编辑光标", + "editableTip": "可编辑光标:单独录制,可在编辑器中修改样式并使用自动缩放", + "systemTip": "系统光标:真实光标,直接录进视频。无法修改样式,没有自动缩放。" }, "sourceSelector": { "loading": "正在加载源...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "请选择要录制的源", "systemPicker": "系统将询问要共享的内容", - "inProgress": "正在录制", + "start": "开始录制", + "stop": "停止并保存录制", "saving": "正在保存..." }, "language": "语言", + "languageWithName": "语言:{{language}}", "systemLanguagePrompt": { "title": "使用系统语言吗?", "description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "设备设置", - "done": "完成", + "close": "关闭", "micLevel": "输入音量", "micHint": "说话以测试麦克风", "noMicrophones": "未找到麦克风", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 423d585d6..844059adf 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -1,15 +1,20 @@ { "tooltips": { "hideHUD": "隱藏控制面板", + "hideHUDTip": "隱藏控制面板。可從系統匣圖示重新顯示。", "closeApp": "關閉應用程式", - "restartRecording": "重新開始錄製", - "cancelRecording": "取消錄製", + "restartRecording": "刪除此錄製並重新開始", + "cancelRecording": "停止並刪除此錄製", "pauseRecording": "暫停錄製", "resumeRecording": "繼續錄製", "useVerticalTray": "使用直向托盤", "useHorizontalTray": "使用橫向托盤", - "openStudio": "開啟工作室", - "openNotes": "開啟筆記", + "changeSource": "選擇其他螢幕或視窗", + "systemAudio": "系統音訊:錄製電腦播放的聲音", + "microphone": "麥克風:錄製語音", + "camera": "攝影機:錄製為獨立圖層,可在編輯器中移動", + "openStudio": "開啟工作室:錄製內容的編輯器", + "openNotes": "開啟筆記:錄製時閱讀的文字", "openNotesPlaceholder": "在此輸入筆記...", "notesToolbar": { "bold": "粗體", @@ -31,21 +36,11 @@ } }, "audio": { - "enableSystemAudio": "啟用系統音訊", - "disableSystemAudio": "停用系統音訊", - "enableMicrophone": "啟用麥克風", - "disableMicrophone": "停用麥克風", - "defaultMicrophone": "預設麥克風", "systemAudio": "系統音訊", "microphone": "麥克風", - "on": "開", - "off": "關", "inputDevice": "輸入裝置" }, "webcam": { - "enableWebcam": "啟用攝影機", - "disableWebcam": "停用攝影機", - "defaultCamera": "預設攝影機", "searching": "正在搜尋...", "noneFound": "未找到攝影機", "unavailable": "攝影機不可用", @@ -57,8 +52,9 @@ "quality2160p": "4K" }, "cursor": { - "useEditableCursorHint": "使用可編輯游標:將重新開啟自動縮放與游標效果", - "useSystemCursorHint": "使用系統游標:將關閉自動縮放與游標效果" + "name": "可編輯游標", + "editableTip": "可編輯游標:單獨錄製,可在編輯器中修改樣式並使用自動縮放", + "systemTip": "系統游標:真實游標,直接錄進影片。無法修改樣式,沒有自動縮放。" }, "sourceSelector": { "loading": "正在載入來源...", @@ -72,10 +68,12 @@ "recording": { "selectSource": "請選擇要錄製的來源", "systemPicker": "系統將詢問要分享的內容", - "inProgress": "正在錄製", + "start": "開始錄製", + "stop": "停止並儲存錄製", "saving": "正在儲存..." }, "language": "語言", + "languageWithName": "語言:{{language}}", "systemLanguagePrompt": { "title": "要使用系統語言嗎?", "description": "偵測到系統語言為 {{language}}。要將 OpenScreen 切換為 {{language}} 嗎?", @@ -90,7 +88,7 @@ }, "deviceSettings": { "title": "裝置設定", - "done": "完成", + "close": "關閉", "micLevel": "輸入音量", "micHint": "說話以測試麥克風", "noMicrophones": "找不到麥克風", diff --git a/technical-documentation/README.md b/technical-documentation/README.md index 5e528085e..6f66db8b2 100644 --- a/technical-documentation/README.md +++ b/technical-documentation/README.md @@ -39,6 +39,7 @@ who reads and writes the project document. | [build-and-packaging.md](engineering/build-and-packaging.md) | Build commands, native artifacts, per-platform packaging | | [ci-workflows.md](engineering/ci-workflows.md) | The GitHub Actions tiers and how artifacts flow between them | | [release-and-secrets.md](engineering/release-and-secrets.md) | Cutting and promoting a release; the secrets it needs | +| [tooltips.md](engineering/tooltips.md) | Writing and building tooltips and accessible names: the rules, the toggle pattern, the shared terms | ## Testing diff --git a/technical-documentation/engineering/tooltips.md b/technical-documentation/engineering/tooltips.md new file mode 100644 index 000000000..48cd28c0a --- /dev/null +++ b/technical-documentation/engineering/tooltips.md @@ -0,0 +1,139 @@ +# Tooltip guidelines + +How to write and build a hover tooltip or an accessible name in OpenScreen. It covers every window: HUD, Record mode and editor. Terms are fixed in [section 9](#9-terms). The rules are checked against the sources listed at the end. + +## 1. The rules + +1. **A tooltip is extra.** Anything the user needs to act (a requirement, a warning, an error) is visible, never only in a tooltip. Tooltips do not work on touch, and users do not look for them. +2. **Text on the control means no tooltip,** unless the tooltip adds an effect the label cannot carry. **An icon-only control always has one,** and it names the control. +3. **Never repeat the visible label.** A tooltip that says what the button already says is noise. +4. **Say what happens, not what the control is.** Actions start with an imperative verb ("Add a zoom at the playhead"). Toggles use the third person ("Microphone: records your voice"). +5. **A destructive control without a confirmation states the loss** ("Stop and delete this recording"). +6. **Toggles follow the pattern in section 3.** +7. **A shortcut is a chip, never text in the string** (section 4). +8. **Use the `Tooltip` primitive** (`src/components/ui/tooltip.tsx`). It opens on hover and on keyboard focus, closes on Escape, lets the pointer move onto it, and stays until dismissed. Do not build another one. Native `title` is only for revealing truncated text (a file name, a full path) on plain text, or on a status dot whose state is already read out. +9. **Text only.** No link, button, icon or formatting inside. A screen reader flattens formatting and the content cannot take focus. +10. **The trigger is the interactive element.** A tooltip on a `span` or a label is invisible to keyboard and screen reader users. For a switch beside a label, the trigger is the switch. +11. **Inoperative controls are hidden** (section 6). +12. **Keep it short** (section 5). A long explanation belongs in the pane help ("?"), not in a tooltip. + +## 2. What each control gets + +| Control | Accessible name | Tooltip | +|---|---|---| +| Button, tab or menu item with visible text | The visible text | None | +| Icon-only action button | Short verb phrase | The same string | +| Icon-only toggle | Constant noun, plus `aria-pressed` | "Name: effect", same in both states | +| Toggle with two named modes (editable or system cursor) | Constant noun, plus `aria-pressed` | One string per mode, defining the mode | +| Button whose icon and meaning swap (Play/Pause, Pause/Resume) | The current action | The same string, no `aria-pressed` | +| Switch beside a label | The row label | Only when the label is jargon (Click impact), on the switch | +| Segmented option with visible text | The visible text | None. Icon-only option: its name | +| Swatch, thumbnail, cursor cell | Its name | The same string | +| Truncated text | Not needed | Native `title` with the full text | +| Drag handle, divider | None | None | + +## 3. The toggle pattern + +* **Name:** a noun that never changes with the state. No "Enable", "Disable" or "Toggle" in it, and no sentence. A name that changes together with `aria-pressed` contradicts itself: a screen reader says "Disable microphone, pressed", which sounds like the opposite of the state. +* **State:** `aria-pressed`. On screen, colour is never the only cue: change the icon (a slash) or its shape as well. +* **Tooltip:** one string, `Name: effect`, the same in both states. Tooltips are read as fixed text and a change of wording between hovers goes unnoticed. Never write "Click to turn off". +* **Exception, two named modes.** When the states are two modes the user has to tell apart, write one string per mode. Each starts with the mode name and defines it. The accessible name stays constant. + +| | Before | After | +|---|---|---| +| Accessible name (HUD cursor) | the whole tooltip sentence, 60 characters, different in each state | "Editable cursor", constant, plus `aria-pressed` | +| Tooltip while editable | "Use system cursor: turns off auto zoom and cursor effects" (describes the other state) | "Editable cursor: recorded separately, so you can restyle it and auto-zoom in the editor" | +| Tooltip while system | "Use editable cursor: turns auto zoom and cursor effects back on" | "System cursor: your real cursor, recorded in the video. No restyling, no auto-zoom." | + +If the icon has to swap and the wording has to follow (Play, Pause), it is an action button, not a toggle: change the name and the tooltip together and do not set `aria-pressed`. + +## 4. Showing a shortcut + +* Read the live binding with `formatBinding` or `formatFixedShortcut` and pass it to the `Tooltip` `shortcut` prop. It renders a `` chip after the text. One chip, the first binding. No chip when the action has no binding. +* Never write "(Z)" in a string. Seven actions can be remapped, so a baked-in key lies after a remap, sits in 15 locales, and breaks the order of mixed-direction text in Arabic. +* A tooltip is not the only place a shortcut appears. The shortcuts dialog and the menus stay the reference. +* Every control whose action has a binding shows it: Undo, Redo, Play and Pause, the Add buttons, Send. + +## 5. Caps + +| | Limit | +|---|---| +| English text | 80 characters. 95 for the strings that define the two cursor modes | +| Sentences | 2, one idea each. Punctuation at the end only when there are two | +| Layout | `max-w-[260px]`, `dir="auto"`, balanced lines. 3 lines in English, 4 in French, Italian and Spanish | +| Gap | 8px from the trigger. Where the trigger sits inside a padded surface (a button in the HUD bar), the gap is measured from that surface's edge | +| Case | Sentence case. Form for an icon-only control: `Name: effect` | +| First appearance | 400 ms after the pointer stops, 300 ms to move between neighbours. One value, set on the shared provider | +| Disappearance | Never on a timer. Pointer leaves, focus leaves, or Escape | +| Placement | On the side with room. Never over the next control the user reaches for. In the HUD: above a horizontal bar, beside a vertical one | + +## 6. Disabled and inoperative controls + +* **Inoperative here: hide it.** Add Full Camera in a project with no camera, the cursor toggle when the app records through the browser and cannot separate the cursor, the Auto-zoom row when the cursor is not editable. No greyed control, no sentence explaining why. +* **Temporarily locked but still informative: keep it.** The HUD toggles during a recording show what is being recorded. Use `aria-disabled="true"` instead of `disabled`, keep it focusable and hoverable, and keep the same tooltip. Dim it. Say nothing more. +* A tooltip on a natively `disabled` button never opens. Do not rely on one. + +## 7. Do and don't + +| Where | Don't | Do | +|---|---|---| +| Mode tabs | title "Media" on the text "Media" | no tooltip | +| HUD record | "Display 1" (the source name) | "Start recording", then "Stop and save the recording" | +| HUD cancel | "Cancel recording" (deletes it, no confirmation) | "Stop and delete this recording" | +| Timeline | "Add zoom (Z)" | "Add a zoom at the playhead" and a chip | +| Timeline | "Auto-Focus on for all zooms, click to switch all to manual", changing with `aria-pressed` | name "Auto-Focus for all zooms", one tooltip | +| Timeline | "Clear timeline" | "Remove all zooms, trims, speeds, annotations and Full Camera. Clips stay." | +| HUD hide | "Hide recording bar" | add how to get it back: "Show it again from the tray icon." | +| Chat | "Compact context" | "Summarize earlier messages to use less context" | +| Segmented rows | every option has a title equal to its text | title only for an icon-only option | + +## 8. Writing and translating + +* **Literal keys.** Write `t("timeline.buttons.addZoom")`. Not `t(on ? "a" : "b")` and not a key held in a map: `npm run i18n:check` only resolves literal calls, and a missing key shows as raw text. +* **Every key in all 15 locales in the same PR.** Then run `npm run i18n:check`. A tooltip key is not shared with a heading: they change separately. +* **Placeholders** are named (`{{language}}`) and copied unchanged into every locale. Pass a value that may be in another direction (a device name) as its own node or between Unicode isolates. +* **No shortcut and no key name in a string.** The one exception is a modifier passed as `{{modifier}}` (Alt or Option). +* **No idioms, no phrasal verbs, no wordplay.** Use plain verbs: start, stop, remove, show, hide, choose, add. Write "Delete this recording and start a new one", not "Redo it from scratch". One idea per sentence, active voice. +* **No em dash and no middle dot as a connector.** Use a colon or two sentences. +* **One term per concept:** see [section 9](#9-terms). "Camera", not "webcam". "Recording", not "take". Reuse the locale's existing term for a term already translated. +* **Do not build a tooltip from pieces.** One string per tooltip, so word order and punctuation stay right in every language. +* **Length.** French, Italian, Spanish, Portuguese and Russian run up to 55 % longer than English. CJK runs shorter. The caps in section 5 leave room. +* **Machine translation.** Fine for these short strings. Say in the PR that non-English locales were translated by machine, and keep the placeholder and length guard test (`src/i18n/__tests__/tooltipCopy.test.ts`) green. Add each new tooltip key to its list. +* **Tests.** Assert the tooltip through its content and the accessible description, not through `title`. The HUD is click-through, so Playwright cannot hover it: check it by hand with real mouse moves (see [AGENTS.md](../../AGENTS.md) and the [manual checklist](../testing/manual-e2e-checklist.md)). + +## 9. Terms + +One term per concept, across the HUD, Record mode and the editor. Where a locale already translates a term, reuse its word instead of inventing a synonym. + +| Concept | Use | Do not use | Note | +|---|---|---|---| +| Cursor recorded as data, restyled in the editor | **Editable cursor** | Cursor highlight, smart cursor | HUD toggle and the Record mode row. The editor pane and its rail entry stay **Cursor**: the pane only exists when a cursor was recorded. | +| The real OS pointer, drawn into the video pixels | **System cursor** | native cursor, OS cursor, baked-in cursor | Always the counterpart of "editable cursor". Define both in place in the tooltip. | +| The webcam feature | **Camera** | Webcam, cam | HUD, Record mode and editor. "Full Camera" stays a proper name. | +| What the computer plays | **System audio** | computer audio, desktop audio | Gloss it once in the tooltip: "the sound your computer plays". | +| The voice input | **Microphone** | mic | Never abbreviate in English. | +| One recording session and its file | **Recording** | take, clip (a clip is a piece on the timeline), capture | "Take" is a code word (`discardRecordingId`). It does not appear in any UI string. | +| The moving line on the timeline | **playhead** | current time marker, cursor | Also the reference for "add at the playhead". Never call it "cursor": the cursor is the mouse pointer. | +| The editing window | **Studio** as the name of the HUD button, **the editor** in sentences | "Editor" as a button name | The HUD tooltip defines the name once: "Open Studio: the editor for your recordings". | +| A region on the timeline | zoom, trim, speed change, annotation, Full Camera | segment, block, clip (for a region) | Same words as the region pills ("Zoom 1", "Trim 1"). | +| Toggle position | on / off | enabled / disabled, active | Off is a state, not a verb: never "turn off" or "switch on". | + +A new or rewritten string never says "webcam". The same concept keeps the same word in `launch.json`, `editor.json` and `settings.json`: if a term changes in one file, it changes in all three. + +## Sources + +* Nielsen Norman Group, Tooltip Guidelines: https://www.nngroup.com/articles/tooltip-guidelines/ +* W3C, WCAG 2.1 Understanding 1.4.13 Content on Hover or Focus: https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html +* W3C, WCAG 2.1 Understanding 1.4.1 Use of Color: https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html +* W3C, WCAG 2.1 Understanding 2.5.3 Label in Name: https://www.w3.org/WAI/WCAG21/Understanding/label-in-name.html +* W3C, WAI-ARIA Authoring Practices, Tooltip pattern: https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ +* W3C, WAI-ARIA Authoring Practices, Button pattern (toggle): https://www.w3.org/WAI/ARIA/apg/patterns/button/ +* Sarah Higley, Tooltips in the time of WCAG 2.1: https://sarahmhigley.com/writing/tooltips-in-wcag-21/ +* Radix UI, Tooltip: https://www.radix-ui.com/primitives/docs/components/tooltip +* Microsoft, Tooltips and Infotips (Win32 UX guide): https://learn.microsoft.com/en-us/windows/win32/uxguide/ctrl-tooltips-and-infotips +* Microsoft, Fluent 2 Tooltip usage: https://fluent2.microsoft.design/components/web/react/core/tooltip/usage +* Apple, accessibility help text versus label (AppKit controls): https://developer.apple.com/library/mac/documentation/Accessibility/Conceptual/AccessibilityMacOSX/EnhancingtheAccessibilityofStandardAppKitControls.html +* Google, Material plain and rich tooltips (Compose): https://developer.android.com/develop/ui/compose/components/tooltip +* GitHub Primer, Tooltip guidelines: https://primer.style/product/components/tooltip/guidelines/ +* Atlassian Design, Tooltip usage: https://atlassian.design/components/tooltip/usage/ +* Smart Interface Design Patterns, Disabled buttons: https://smart-interface-design-patterns.com/articles/disabled-buttons/ diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 03fecfa32..019d516b1 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -103,11 +103,12 @@ gh run download -R getopenscreen/openscreen -n openscreen-windows - [ ] Activate the language button by its visible language code and confirm a menu of locale choices opens. - [ ] Press `Esc` with the language menu open and confirm it closes without changing the locale. - [ ] Activate the camera toggle `[data-testid="launch-webcam-button"]` and the microphone toggle `[data-testid="launch-microphone-button"]` (absent on macOS 13 and 14) and confirm each turns on with one click. On a Mac with no camera attached, confirm the webcam toggle stays off and reports that camera access is blocked or the camera is unavailable; recording remains available with the webcam off. -- [ ] Open the gear `[data-testid="launch-device-settings-button"]` (*Device settings*) and confirm it lists the input devices with a level meter that moves when you speak, and the cameras with a live preview; pick another device, close with *Done*, and confirm the toggle now records from it. +- [ ] Open the gear `[data-testid="launch-device-settings-button"]` (*Device settings*) and confirm it lists the input devices with a level meter that moves when you speak, and the cameras with a live preview; pick another device, close with *Close* (the X), and confirm the toggle now records from it. - [ ] **v2.0.0** — With a camera attached and no saved choice, open the gear and confirm a *Camera quality* row sits under the camera list and above the preview, with three radio choices, `camera-quality-1080p`, `camera-quality-1440p` and `camera-quality-2160p`, labelled 1080p, 1440p and 4K, and that 4K is the one checked. - [ ] **v2.0.0** — Pick 1080p, quit and relaunch the app, reopen the gear, and confirm 1080p is still checked. `recording-settings.json` in `userData` carries `"camQuality": "1080p"`. - [ ] **v2.0.0** — Quit, hand-edit `camQuality` in that file to `"720p"`, relaunch, and confirm the gear shows 4K checked rather than a row with nothing checked. - [ ] **v2.0.0** — With no camera attached, or the camera disabled in the OS, confirm the gear reports "No camera found" and shows no *Camera quality* row. +- [ ] Hover each HUD control with real mouse moves, on the horizontal bar and again on the vertical one, while idle and while recording. Confirm each shows exactly one tooltip that is readable and not clipped by the window (above a horizontal bar, beside a vertical one, the Record button included), that the system audio, microphone, camera, cursor and gear buttons still show theirs while recording, and that the cursor button is absent when capture falls back to the browser (macOS or Linux without their native helper). The HUD is click-through, so Playwright cannot do this: see [tooltips.md](../engineering/tooltips.md). - [ ] Activate *Hide recording bar* and confirm the HUD hides without quitting the app. - [ ] Refocus the app from its system-tray icon and confirm the HUD returns to the foreground. - [ ] Activate *Quit OpenScreen* while idle and confirm the app exits cleanly, tray icon included.