Files
paseo/packages/app/src/components/ui/control-geometry.ts
T
Mohamed Boudra 74a7e5803b Choose voice providers and local speech models from a Voice settings page (#7295)
* feat(voice): add a Voice settings page with provider choice and a local model catalog

Each host gets a Voice page under host settings. Dictation and voice mode
each pick a provider (Local or OpenAI) and a model. Provider settings sit in
one group of collapsible rows: local speech-to-text and text-to-speech list
every catalog model with its size, languages and description before download,
and OpenAI takes a write-only API key and an optional base URL.

The local catalog grows from Parakeet/Kokoro to Whisper, SenseVoice, Dolphin,
GigaAM, Parakeet Japanese, multilingual Kokoro, Kitten and Piper voices. Each
entry declares its sherpa engine family, so the recognizer and TTS build the
matching model config and the downloader verifies the files that family needs.
Kitten v0.8 is left out: it crashes the pinned sherpa-onnx 1.12.28.

Voice mode is now opt-in. The default lived only in code (true), never in
config.json, so hosts that never toggled it find it off after upgrading.

OpenAI speech-to-text resolved one model for both features, with voice mode's
choice taking precedence, so dictation silently used voice mode's model. The
resolved config now carries a model per feature; STT_MODEL still overrides both.

Rows revealed under a collapsible settings row are a nested density level set
by the container: tighter padding, smaller text, and xs controls, with
controls taking their size from the row instead of each caller.

* test(app): pre-bundle the JSX dev runtime for browser tests

Compiling test JSX with the automatic runtime imports react/jsx-dev-runtime. Vite discovered it mid-run on a cold cache and reloaded the page, which failed the first browser test file to import it.

* refactor(voice): keep speech provider settings at each use site

Store credentials and endpoint options beside the speech feature that consumes them so saved page values cannot be shadowed by a separate provider block. Normalize existing config on load and mirror the inline shape on config reads and patches.

This is the config, resolver and wire phase; the provider sheet follows separately.

* fix(voice): preserve saved keys when resaving provider settings

Forms send the selected provider on each save but cannot read back its API key. Merge patches for the existing provider so these saves retain credentials; replace the site only when the provider changes.

* feat(voice): configure each speech use site from a provider sheet

The Voice page now lists Speech-to-text, Listening and Speaking as one-line
summaries. Tapping one opens a sheet with the provider choice and only that
provider's settings: the local catalog (size, languages and description
before download, download state per model) or the OpenAI key, base URL,
model and language or voice.

The sheet's form model sends only the fields the user changed, so re-saving
never clears a saved key. Fields an env var decides are read-only with a
"Set by" hint; the API key and base URL stay editable because a saved value
wins over the env fallback. Save failures show a toast and keep the sheet
open with the input.

E2E daemons pin speech providers through env vars to avoid model downloads,
so the browser spec covers the locked-provider path, the key fallback and
the failed save; provider switching is covered by the form model tests.

* refactor(voice): press settings and catalog rows through PressHighlight

Settings rows that lead somewhere and local model rows now use the standard
row press primitive, so native shows the finger-down flash the rest of the
app's rows have. Web hover and press styling are unchanged.

* fix(voice): preserve STT language for local providers

Language belongs to every STT use site. Preserve legacy local language settings through config loading and saving, resolve them without provider gating, and expose them in the provider form.
2026-10-26 14:19:02 +07:00

353 lines
9.9 KiB
TypeScript

import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "xs" | "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
hovered?: boolean;
focused?: boolean;
pressed?: boolean;
open?: boolean;
active?: boolean;
disabled?: boolean;
}
export interface ControlInteractionStyleMap {
controlRest: StyleProp<ViewStyle>;
controlHover: StyleProp<ViewStyle>;
controlActive: StyleProp<ViewStyle>;
controlDisabled?: StyleProp<ViewStyle>;
}
const TIGHT_CONTROL_HEIGHT = 28;
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
export const HEADER_CONTROL_HEIGHT = 26;
const SEGMENTED_TIGHT_INSET = 2;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
const SWITCH_TRACK_HEIGHT = 20;
const SWITCH_THUMB_SIZE = 16;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
/**
* The three control heights every button, field, and segmented control is built from.
* Exported so a row that hosts one of those controls can size itself from the same
* numbers instead of guessing a height the control then outgrows.
*/
export const CONTROL_HEIGHTS = {
tight: TIGHT_CONTROL_HEIGHT,
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
};
export const buttonControlHeight: Record<ButtonControlSize, number> = {
xs: CONTROL_HEIGHTS.tight,
sm: CONTROL_HEIGHTS.compact,
md: CONTROL_HEIGHTS.field,
lg: CONTROL_HEIGHTS.field,
};
export const buttonIconSize: Record<ButtonControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
lg: ICON_SIZE.lg,
};
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
export const switchGeometry = {
trackWidth: SWITCH_TRACK_WIDTH,
trackHeight: SWITCH_TRACK_HEIGHT,
thumbSize: SWITCH_THUMB_SIZE,
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
};
function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
function fieldVerticalPadding(
controlHeight: number,
lineHeight: number,
borderWidth: number,
): number {
return (controlHeight - lineHeight - borderWidth * 2) / 2;
}
/**
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
* The icon and the first text sit in one centered lead row; everything below is indented
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
* description use one font size; only the container gap separates them.
*/
function createAlertGeometry(theme: Theme) {
function alertSize(input: {
size: ButtonControlSize;
fontSize: number;
paddingVertical: number;
paddingHorizontal: number;
borderRadius: number;
iconGap: number;
textGap: number;
}) {
const iconSize = buttonIconSize[input.size];
return {
container: {
paddingVertical: input.paddingVertical,
paddingHorizontal: input.paddingHorizontal,
borderRadius: input.borderRadius,
gap: input.textGap,
},
lead: { gap: input.iconGap },
iconSlot: { width: iconSize },
indent: { marginLeft: iconSize + input.iconGap },
text: { fontSize: input.fontSize },
};
}
return {
xs: alertSize({
size: "xs",
fontSize: theme.fontSize.sm,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
iconGap: theme.spacing[2],
textGap: 0,
}),
sm: alertSize({
size: "sm",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[3],
textGap: theme.spacing[0.5],
}),
md: alertSize({
size: "md",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[3],
textGap: theme.spacing[0.5],
}),
lg: alertSize({
size: "lg",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[8],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[4],
textGap: theme.spacing[1],
}),
} satisfies Record<ButtonControlSize, unknown>;
}
export function getControlInteractionPhase(
state: ControlInteractionState,
): ControlInteractionPhase {
if (state.disabled) {
return "rest";
}
if (state.active || state.focused || state.open || state.pressed) {
return "active";
}
if (state.hovered) {
return "hover";
}
return "rest";
}
export function resolveControlInteractionStyles(
styles: ControlInteractionStyleMap,
state: ControlInteractionState,
): StyleProp<ViewStyle> {
const phase = getControlInteractionPhase(state);
return [
styles.controlRest,
phase === "hover" ? styles.controlHover : null,
phase === "active" ? styles.controlActive : null,
state.disabled ? styles.controlDisabled : null,
];
}
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlXs = {
minHeight: CONTROL_HEIGHTS.tight,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.tight,
fieldTextXsLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.compact,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: CONTROL_HEIGHTS.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.field,
fieldTextMdLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextXs = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextXsLineHeight,
};
const fieldTextSm = {
fontSize: theme.fontSize.base,
lineHeight: fieldTextSmLineHeight,
};
const fieldTextMd = {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const alert = createAlertGeometry(theme);
const switchControl = {
minHeight: CONTROL_HEIGHTS.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
} satisfies { minHeight: number; justifyContent: "center" };
return {
buttonXs: {
minHeight: buttonControlHeight.xs,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
},
buttonSm: {
minHeight: buttonControlHeight.sm,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
},
buttonMd: {
minHeight: buttonControlHeight.md,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius["2xl"],
},
buttonLg: {
minHeight: buttonControlHeight.lg,
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius["2xl"],
},
buttonText: {
fontSize: theme.fontSize.base,
},
buttonTextXs: {
fontSize: theme.fontSize.sm,
},
alert,
formTextInputXs: {
...fieldControlXs,
...fieldTextXs,
},
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
},
formTextInputMd: {
...fieldControlMd,
...fieldTextMd,
},
formTextInput: {
...fieldControlMd,
...fieldTextMd,
},
fieldControlXs,
fieldControlSm,
fieldControlMd,
fieldTextXs,
fieldTextSm,
fieldTextMd,
controlRest: {
borderWidth: controlBorderWidth,
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
},
controlHover: {
borderColor: theme.colors.borderAccent,
},
controlActive: {
borderColor: theme.colors.borderAccent,
outlineColor: theme.colors.accent,
outlineOffset: CONTROL_FOCUS_RING_OFFSET,
outlineStyle: "solid" as const,
outlineWidth: CONTROL_FOCUS_RING_WIDTH,
},
controlFocusRingColor: {
outlineColor: theme.colors.accent,
},
controlDisabled: {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerXs: {
minHeight: CONTROL_HEIGHTS.tight,
padding: 0,
},
segmentedContainerSm: {
minHeight: CONTROL_HEIGHTS.compact,
padding: 0,
},
segmentedContainerMd: {
minHeight: CONTROL_HEIGHTS.field,
padding: 0,
},
segmentedSegmentXs: {
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
},
segmentedSegmentSm: {
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
},
segmentedSegmentMd: {
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius["2xl"],
},
segmentedLabelXs: {
fontSize: theme.fontSize.sm,
},
segmentedLabelSm: {
fontSize: theme.fontSize.base,
},
segmentedLabelMd: {
fontSize: theme.fontSize.base,
},
};
}