diff --git a/host/client_app/components/CopyCommand.test.tsx b/host/client_app/components/CopyCommand.test.tsx new file mode 100644 index 00000000..890c4432 --- /dev/null +++ b/host/client_app/components/CopyCommand.test.tsx @@ -0,0 +1,101 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +// Mock @simple-module-py/i18n so useT resolves without a real i18next +// instance — same pattern AdminSectionLink.test.tsx uses. +vi.mock('@simple-module-py/i18n', () => ({ + useT: () => ({ + t: (key: string) => + ({ 'host.landing.copy_command': 'Copy command', 'host.landing.command_copied': 'Copied' })[ + key + ] ?? key, + }), + keys: { + host: { + landing: { + copy_command: 'host.landing.copy_command', + command_copied: 'host.landing.command_copied', + }, + }, + }, +})); + +import { CopyCommand } from './CopyCommand'; + +const COMMAND = 'uvx --from simple_module_cli smpy new my-app'; + +function stubClipboard(impl: () => Promise) { + const writeText = vi.fn(impl); + Object.assign(navigator, { clipboard: { writeText } }); + return writeText; +} + +/** Click, then let the awaited `writeText` settle before asserting. */ +async function clickCopy() { + fireEvent.click(screen.getByRole('button', { name: 'Copy command' })); + await act(async () => {}); +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('CopyCommand', () => { + test('renders the command in full', () => { + stubClipboard(() => Promise.resolve()); + render(); + // The whole string, not an ellipsised prefix — the markup this replaced + // `truncate`d it, hiding 107px of it at 375px with no way to reveal it. + expect(screen.getByText(COMMAND)).toBeInTheDocument(); + }); + + // The regression this file exists for: the button shipped with an + // aria-label promising an action and no handler at all, so it was inert + // while asserting otherwise. Nothing caught it. + test('writes the command to the clipboard when clicked', async () => { + const writeText = stubClipboard(() => Promise.resolve()); + render(); + + await clickCopy(); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(COMMAND); + }); + + test('announces the copy to assistive tech', async () => { + stubClipboard(() => Promise.resolve()); + render(); + + await clickCopy(); + + expect(screen.getByText('Copied')).toBeInTheDocument(); + }); + + test('clears the announcement after the reset window', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + stubClipboard(() => Promise.resolve()); + render(); + + await clickCopy(); + expect(screen.getByText('Copied')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.queryByText('Copied')).not.toBeInTheDocument(); + }); + + test('stays quiet when the clipboard is unavailable', async () => { + // Insecure origins and permission policy both reject writeText. The + // command is on screen and selectable, so a rejection is not worth an + // error state — but it must not claim success either. + stubClipboard(() => Promise.reject(new Error('denied'))); + render(); + + await clickCopy(); + + expect(screen.queryByText('Copied')).not.toBeInTheDocument(); + }); +}); diff --git a/host/client_app/components/CopyCommand.tsx b/host/client_app/components/CopyCommand.tsx new file mode 100644 index 00000000..c007dd22 --- /dev/null +++ b/host/client_app/components/CopyCommand.tsx @@ -0,0 +1,75 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { Check, Copy } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const RESET_AFTER_MS = 2000; + +/** + * The install command the hero hands the visitor, with a copy button. + * + * Two things this fixes over the inline markup it replaces: + * + * - The button was decorative. It carried `aria-label="Copy command"` and no + * handler at all, so the one control on the page that promises an action + * did nothing — worse than absent, because the label asserts otherwise. + * - The command was `truncate`d. At 375px that hid 107px of a 370px string + * behind an ellipsis with no way to reveal it: `truncate` is + * `overflow: hidden`, not a scroll container, and the text is not + * selectable past the clip. A visitor on a phone could neither read the + * command nor copy it. It now wraps instead, so the full command is always + * on screen. + */ +export function CopyCommand({ command }: { command: string }) { + const { t } = useT(); + const [copied, setCopied] = useState(false); + const timer = useRef | null>(null); + + // Clear on unmount so the reset can't fire into a gone component, and so a + // rapid second click restarts the window rather than stacking timeouts. + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); + + const copy = useCallback(async () => { + try { + await navigator.clipboard.writeText(command); + } catch { + // Clipboard access is refused on insecure origins and by permission + // policy. Say nothing rather than throw — the command is on screen and + // selectable, which is the fallback either way. + return; + } + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), RESET_AFTER_MS); + }, [command]); + + return ( +
+ + {command} + + {/* Announced, not shown: the icon swap is the visual signal, but a + screen-reader user gets no feedback from a silent icon change. */} + + {copied ? t(keys.host.landing.command_copied) : ''} + +
+ ); +} diff --git a/host/client_app/pages/Landing.tsx b/host/client_app/pages/Landing.tsx index f89c5e86..0c01470a 100644 --- a/host/client_app/pages/Landing.tsx +++ b/host/client_app/pages/Landing.tsx @@ -7,7 +7,6 @@ import { authCta } from '@simple-module-py/ui/lib/auth-routes'; import type { SharedProps } from '@simple-module-py/ui/types'; import { BookOpen, - Copy, Database, LayoutTemplate, Package, @@ -17,6 +16,10 @@ import { Sparkles, Stethoscope, } from 'lucide-react'; +import { CopyCommand } from '../components/CopyCommand'; + +/** The one command the hero exists to hand over. */ +const INSTALL_COMMAND = 'uvx --from simple_module_cli smpy new my-app'; const QUICKSTART = `# 1. install python and js deps $ make install @@ -118,19 +121,7 @@ function Landing() { {/* Terminal CTA */} -
- $ - - uvx --from simple_module_cli smpy new my-app - - -
+ @@ -248,7 +239,7 @@ function Landing() { className="bg-white text-primary-700 hover:bg-white/90" > - {auth?.isAuthenticated ? t(keys.host.landing.cta_dashboard) : cta.label} + {auth?.isAuthenticated ? t(keys.host.landing.cta_dashboard) : t(cta.labelKey)}