Skip to content
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ The WebView root component is assigned to `globalThis.webViewComponent` (not exp

[src/services/projectStorage.ts](src/services/projectStorage.ts) — owns all `papi.storage` reads and writes for interlinearizer projects. Two serialization queues prevent interleaved read-modify-write races. Tests must call `resetQueuesForTesting()` between tests because module state is not cleared by `resetMocks`.

### Components

Prefer Platform.Bible's `platform-bible-react` components (`Button`, `Input`, `Textarea`, `Label`, `Switch`, `RadioGroup`, `Popover`, `Tooltip`, etc.) over hand-rolled raw HTML (`<button>`, `<input>`, `<textarea>`, `<label>`, custom dropdowns) wherever an equivalent exists — this keeps the UI visually and behaviorally consistent with the host app and gives us theming and accessibility for free. Reserve raw elements for cases where the platform component can't preserve behavior the extension depends on (e.g. `field-sizing: content` gloss inputs); when you do, add a comment explaining why. When a raw element is replaced by a platform component in tests, extend `__mocks__/platform-bible-react.tsx` to stub the newly-used component.

Size icons inside a `Button` with `size-*` (e.g. `tw:size-3`), never `h-*`/`w-*` — `buttonVariants` forces any child SVG lacking a `size-` class to `size-4`, silently overriding `h-*`/`w-*`.

### Styling

All UI uses Tailwind CSS (via `src/tailwind.css`). Every Tailwind class is prefixed `tw:` to avoid collisions with Platform.Bible's own styles (configured in `tailwind.config.ts`). For modifier variants the prefix comes first: `tw:hover:px-3`, not `hover:tw-px-3`.
Expand Down
211 changes: 205 additions & 6 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
*/

import { Children, cloneElement, forwardRef, isValidElement, useEffect, useRef } from 'react';
import type { MouseEventHandler, ReactElement, ReactNode } from 'react';
import type {
ChangeEventHandler,
CSSProperties,
KeyboardEventHandler,
MouseEventHandler,
ReactElement,
ReactNode,
} from 'react';

export interface MenuItemContainingCommand {
label: `%${string}%`;
Expand Down Expand Up @@ -242,21 +249,72 @@ export function ScrollGroupSelector({
}

/**
* Stub button that passes through `children`, `onClick`, `type`, `className`, `disabled`,
* `aria-label`, `aria-expanded`, `aria-haspopup`, `data-testid`, and `ref` to a native `<button>`
* element; `variant` and `size` are accepted but ignored.
* Throws if a `Button`-descendant SVG loses its size to `buttonVariants`: an `h-*` or `w-*`
* className without `size-`, or a numeric `size` prop without a `size-` className. Production
* silently overrides all of these (AGENTS.md § Components); jsdom can't reproduce the visual
* regression, so this fails loudly instead of letting it pass as a green test.
*
* @param node - A `Button` child (or subtree) to check.
*/
function assertNoOversizedIconClassName(node: ReactNode): void {
if (Array.isArray(node)) {
node.forEach(assertNoOversizedIconClassName);
return;
}
if (!isValidElement(node)) return;
const { className, size, children } = node.props as {
className?: unknown;
size?: unknown;
children?: ReactNode;
};
const hasSizeClass = typeof className === 'string' && /\bsize-/.test(className);
if (
typeof className === 'string' &&
(/\btw:h-\S+/.test(className) || /\btw:w-\S+/.test(className)) &&
!hasSizeClass
) {
throw new Error(
`Icon className "${className}" uses tw:h-*/tw:w-* instead of tw:size-* inside a Button — ` +
'the platform Button forces child SVG size unless the class contains "size-" (see AGENTS.md § Components).',
);
}
if (typeof size === 'number' && !hasSizeClass) {
throw new Error(
`Icon has a numeric size={${size}} prop instead of a tw:size-* className inside a Button — ` +
'the platform Button forces child SVG size unless the class contains "size-" (see AGENTS.md § Components).',
);
}
if (children) assertNoOversizedIconClassName(children);
}

/**
* Stub button that passes the attributes the extension relies on through to a native `<button>`
* element; `variant` and `size` are accepted but ignored for rendering (jsdom does not apply
* styling, so tests assert on behavior/testid/role, not the visual variant). Children still go
* through {@link assertNoOversizedIconClassName}, which throws on the one sizing regression this
* stub can catch regardless of variant/size. The mouse/keyboard handlers and `tabIndex`/`style` are
* forwarded so migrated icon buttons keep their hover-preview and focus-skipping behavior under test.
*
* @param props - Component props.
* @param props.children - Button content.
* @param props.onClick - Click handler.
* @param props.onMouseEnter - Pointer-enter handler (icon buttons use it to preview hover state).
* @param props.onMouseLeave - Pointer-leave handler (clears the previewed hover state).
* @param props.onMouseDown - Pointer-down handler (e.g. to preventDefault and keep input focus).
* @param props.type - HTML button type attribute.
* @param props.className - CSS class names.
* @param props.style - Inline styles (icon buttons use it for absolute positioning).
* @param props.title - Native tooltip text; the {@link Tooltip} stub clones its trigger child with
* this prop, so a `Button` used as a `TooltipTrigger asChild` child must forward it.
* @param props.disabled - Whether the button is disabled.
* @param props.tabIndex - Tab order; icon buttons set `-1` to skip them in keyboard navigation.
* @param props.variant - Ignored styling variant.
* @param props.size - Ignored size variant.
* @param props['aria-label'] - Accessible label.
* @param props['aria-expanded'] - Expanded state for popup triggers.
* @param props['aria-haspopup'] - Haspopup attribute.
* @param props['aria-controls'] - ID of the element the button controls (e.g. a listbox popup).
* @param props['aria-hidden'] - Hides the button from the accessibility tree when it is inert.
* @param props['data-testid'] - Test identifier.
* @param ref - Forwarded ref to the underlying button element.
* @returns A native `<button>` element with standard attributes forwarded.
Expand All @@ -265,42 +323,67 @@ export const Button = forwardRef<
HTMLButtonElement,
Readonly<{
children?: ReactNode;
onClick?: () => void;
onClick?: MouseEventHandler<HTMLButtonElement>;
onMouseEnter?: MouseEventHandler<HTMLButtonElement>;
onMouseLeave?: MouseEventHandler<HTMLButtonElement>;
onMouseDown?: MouseEventHandler<HTMLButtonElement>;
type?: 'button' | 'submit' | 'reset';
className?: string;
style?: CSSProperties;
title?: string;
disabled?: boolean;
tabIndex?: number;
variant?: 'default' | 'secondary' | 'destructive' | 'ghost' | 'outline' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
size?: 'default' | 'sm' | 'lg' | 'icon' | 'xs' | 'icon-sm' | 'icon-xs' | 'icon-lg';
'aria-label'?: string;
'aria-expanded'?: boolean;
'aria-haspopup'?: boolean | 'true' | 'false' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog';
'aria-controls'?: string;
'aria-hidden'?: boolean;
'data-testid'?: string;
}>
>(function ButtonImpl(
{
children,
onClick,
onMouseEnter,
onMouseLeave,
onMouseDown,
type,
className,
style,
title,
disabled,
tabIndex,
variant: _variant,
size: _size,
'aria-label': ariaLabel,
'aria-expanded': ariaExpanded,
'aria-haspopup': ariaHaspopup,
'aria-controls': ariaControls,
'aria-hidden': ariaHidden,
'data-testid': testId,
},
ref,
) {
assertNoOversizedIconClassName(children);
return (
<button
ref={ref}
type={type ?? 'button'}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
className={className}
style={style}
title={title}
tabIndex={tabIndex}
aria-label={ariaLabel}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHaspopup}
aria-controls={ariaControls}
aria-hidden={ariaHidden}
data-testid={testId}
disabled={disabled}
>
Expand All @@ -309,6 +392,122 @@ export const Button = forwardRef<
);
});

/**
* Stub input rendered as a native `<input>`, forwarding the attributes the extension's migrated
* form fields and inline editors rely on so tests can read and drive them by id, testid, role, or
* value.
*
* @param props - Component props.
* @param props.id - HTML `id` attribute (labels bind to it via `htmlFor`).
* @param props.type - Input type; defaults to `text`.
* @param props.value - Controlled value.
* @param props.placeholder - Placeholder text.
* @param props.className - CSS class names.
* @param props.style - Inline styles.
* @param props.disabled - Whether the input is disabled.
* @param props.onChange - Change handler.
* @param props.onKeyDown - Key-down handler (inline editors commit/cancel on Enter/Escape).
* @param props['aria-label'] - Accessible label.
* @param props['data-testid'] - Test identifier.
* @param ref - Forwarded ref to the underlying input element.
* @returns A native `<input>` element with the forwarded attributes.
*/
export const Input = forwardRef<
HTMLInputElement,
Readonly<{
id?: string;
type?: string;
value?: string;
placeholder?: string;
className?: string;
style?: CSSProperties;
disabled?: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
'aria-label'?: string;
'data-testid'?: string;
}>
>(function InputImpl(
{
id,
type,
value,
placeholder,
className,
style,
disabled,
onChange,
onKeyDown,
'aria-label': ariaLabel,
'data-testid': testId,
},
ref,
) {
return (
<input
ref={ref}
id={id}
type={type ?? 'text'}
value={value}
placeholder={placeholder}
className={className}
style={style}
disabled={disabled}
onChange={onChange}
onKeyDown={onKeyDown}
aria-label={ariaLabel}
data-testid={testId}
/>
);
});

/**
* Stub textarea rendered as a native `<textarea>`, forwarding the attributes the extension's
* migrated multi-line form fields rely on.
*
* @param props - Component props.
* @param props.id - HTML `id` attribute (labels bind to it via `htmlFor`).
* @param props.value - Controlled value.
* @param props.placeholder - Placeholder text.
* @param props.className - CSS class names.
* @param props.rows - Visible row count.
* @param props.disabled - Whether the textarea is disabled.
* @param props.onChange - Change handler.
* @param props['data-testid'] - Test identifier.
* @param ref - Forwarded ref to the underlying textarea element.
* @returns A native `<textarea>` element with the forwarded attributes.
*/
export const Textarea = forwardRef<
HTMLTextAreaElement,
Readonly<{
id?: string;
value?: string;
placeholder?: string;
className?: string;
rows?: number;
disabled?: boolean;
onChange?: ChangeEventHandler<HTMLTextAreaElement>;
'data-testid'?: string;
}>
>(function TextareaImpl(
{ id, value, placeholder, className, rows, disabled, onChange, 'data-testid': testId },
ref,
) {
return (
<textarea
ref={ref}
id={id}
value={value}
placeholder={placeholder}
className={className}
rows={rows}
disabled={disabled}
onChange={onChange}
data-testid={testId}
/>
);
});

/**
* Stub book/chapter control that displays the current reference as text and exposes a single
* "Submit reference" button so tests can simulate reference changes without the real picker UI.
Expand Down
10 changes: 6 additions & 4 deletions src/components/ArcOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PhraseAnalysisLink } from 'interlinearizer';
import { Link2Off } from 'lucide-react';
import { Button } from 'platform-bible-react';
import { memo, useState, useCallback } from 'react';
import type { PhraseMode } from '../types/phrase-mode';
import { computeSplitFreeRefs, getArcStrokeProps, type ArcPath } from '../utils/phrase-arc';
Expand Down Expand Up @@ -321,14 +322,15 @@ export function ArcOverlay({
);
const willCreateFreeTokens = arcSplitFreeRefs !== undefined;
return (
<button
<Button
key={`split-arc-${phraseId}-${d}`}
aria-label="Split phrase here"
className={`tw:absolute tw:-translate-x-1/2 tw:-translate-y-1/2 tw:inline-flex tw:items-center tw:justify-center tw:rounded tw:border tw:bg-background tw:p-px ${buttonZClass} ${buttonColorClass}${willCreateFreeTokens ? ' tw:hover:border-destructive tw:hover:text-destructive' : ''}`}
className={`tw:absolute tw:-translate-x-1/2 tw:-translate-y-1/2 tw:inline-flex tw:h-auto tw:items-center tw:justify-center tw:rounded tw:border tw:bg-background tw:p-px ${buttonZClass} ${buttonColorClass}${willCreateFreeTokens ? ' tw:hover:border-destructive tw:hover:text-destructive' : ''}`}
data-testid="split-arc-btn"
style={{ left: midX, top: midY }}
tabIndex={-1}
type="button"
variant="ghost"
onClick={() => {
// Clear the split-hover state synchronously with the click. The button is removed
// from the DOM by the resulting re-render, so no mouseLeave fires — without this
Expand All @@ -352,8 +354,8 @@ export function ArcOverlay({
else handleReshapeHoverLeave();
}}
>
<Link2Off className="tw:h-2.5 tw:w-2.5" />
</button>
<Link2Off className="tw:size-2.5" />
</Button>
);
})}
</>
Expand Down
Loading
Loading