From fe9940d9461e1b5819c151e494dbc206a10d4e2a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 27 Aug 2026 07:27:54 +0200 Subject: [PATCH] fix(landing): make the install command readable and the copy button real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by driving the page in a browser at 375px and 1440px. The hero hands the visitor one command, and on a phone it did neither of the two things it exists to do. **The command was cut off.** `` is `overflow: hidden` with an ellipsis, not a scroll container, so at 375px 107px of the 370px string sat behind the ellipsis with no way to reach it — not by scrolling, not by selecting. A visitor on a phone could not read `uvx --from simple_module_cli smpy new my-app`. It now wraps, so the whole command is on screen at every width (two lines at 375px, one at 1440px). **The copy button did nothing.** It carried `aria-label="Copy command"` and no handler at all — not a broken handler, no `onClick` property on the element. That is worse than having no button, because the label asserts an action to assistive tech that the control cannot perform. It now writes to the clipboard, swaps to a check for two seconds, and announces through an `aria-live` region, since a silent icon swap tells a screen-reader user nothing. A rejected `writeText` (insecure origin, permission policy) stays quiet rather than claiming success — the command is on screen and selectable, which is the fallback either way. Extracted to `host/client_app/components/CopyCommand.tsx` rather than inlined: Landing.tsx was at 271 of its 300-line budget, and the widget now holds state. It lives outside `pages/` deliberately — `pages.ts` globs `./pages/**/*.tsx` and registers every match as an Inertia page, so a component under there would have become an SM003 orphan. Two more untranslated-text bugs found while in here: - `authCta()` returned a hardcoded English `'Sign up'`/`'Sign in'`, which the landing CTA rendered without `t()`. Every non-English visitor got English there. `make ci-check-untranslated` cannot see this — a string reaching the screen through a returned object is exactly the taint analysis blind spot that check documents. It now returns a catalog key, so omitting the translation is a type error rather than a silent one. Verified with `es` enabled: the CTA reads "Iniciar sesión". - `` was hardcoded `"en"` and nothing ever updated it, so every translated page told assistive tech to pronounce it with English phonetics. Now reads `request.state.locale`, which LocaleMiddleware has already resolved before any route runs. App-wide rather than landing-only, but one line and the same defect class. Five tests cover the widget, including the inert-button regression directly. Confirmed they catch it: reverting `onClick` fails three of them, restoring it passes all five. `host/client_app/tsconfig.json` now excludes `*.test.tsx`, matching packages/ui — these specs rely on jest-dom matcher augmentation registered through vitest.setup.ts, not the compiler's `types`, so a plain `tsc -p` reports every `toBeInTheDocument` as missing. This is the first spec under host/client_app, which is why the gap had not surfaced. Verified: make lint clean, 2152 passed / 2 skipped, 127 JS passed (was 122), make doctor 0 errors (1 pre-existing unrelated SM003). Browser- checked at 375px and 1440px, in English and Spanish, with the copy button exercised and the announcement observed setting and clearing. Claude-Session: https://claude.ai/code/session_01JJtbN97VhtDr28Fuy5JKEF --- .../components/CopyCommand.test.tsx | 101 ++++++++++++++++++ host/client_app/components/CopyCommand.tsx | 75 +++++++++++++ host/client_app/pages/Landing.tsx | 21 ++-- host/client_app/tsconfig.json | 8 +- host/locales/en.json | 1 + host/locales/es.json | 1 + host/templates/index.html | 8 +- packages/i18n/src/generated-resources.ts | 1 + packages/i18n/src/keys.generated.ts | 1 + packages/ui/src/lib/auth-routes.ts | 18 +++- 10 files changed, 215 insertions(+), 20 deletions(-) create mode 100644 host/client_app/components/CopyCommand.test.tsx create mode 100644 host/client_app/components/CopyCommand.tsx 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)}