feat: add Russian localization and independent STT languages
CI / Typecheck, tests, lint (push) Canceled after 0s
CI / Standalone (mac-arm64) (push) Canceled after 0s
CI / Standalone (linux-x64) (push) Canceled after 0s
CI / Standalone (windows-x64) (push) Canceled after 0s

This commit is contained in:
2026-10-25 17:18:44 +03:00
parent b277ee3e0f
commit a50ca4b867
93 changed files with 3261 additions and 904 deletions
+26 -26
View File
@@ -1,4 +1,7 @@
"use client";
import { countLabel } from "@/src/core";
import { useI18n } from "@/components/i18n";
/**
* THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29):
@@ -143,6 +146,7 @@ export function ChainTableBody({
selectMode?: boolean;
onSelect?: () => void;
}) {
const { t, language } = useI18n();
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]);
const searching = query.trim() !== "";
@@ -246,7 +250,7 @@ export function ChainTableBody({
law: later in the DOM). */}
<TopBar>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
{countLabel(language, chains.length, "chain", "chains")} · {countLabel(language, nodes.length, "node", "nodes")}
</span>
<div className="relative shrink-0">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
@@ -254,8 +258,8 @@ export function ChainTableBody({
value={query}
data-testid="chain-table-search"
onChange={(e) => onQuery(e.target.value)}
placeholder="Search nodes…"
aria-label="Search nodes"
placeholder={t("Search nodes…")}
aria-label={t("Search nodes")}
spellCheck={false}
className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/>
@@ -264,7 +268,7 @@ export function ChainTableBody({
type="button"
data-testid="chain-table-close"
onClick={onClose}
aria-label="Close chains"
aria-label={t("Close chains")}
className="lh-no-drag flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-card/80 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
@@ -288,7 +292,7 @@ export function ChainTableBody({
aria-sort={sort.col === "name" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex flex-1 items-center gap-1 text-left transition-colors hover:text-foreground"
>
Name {dirIcon("name")}
{t("Name")}{" "}{dirIcon("name")}
</button>
<button
type="button"
@@ -297,7 +301,7 @@ export function ChainTableBody({
aria-sort={sort.col === "props" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex w-14 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
>
Props {dirIcon("props")}
{t("Props")}{" "}{dirIcon("props")}
</button>
<button
type="button"
@@ -306,14 +310,13 @@ export function ChainTableBody({
aria-sort={sort.col === "created" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex w-20 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
>
Created {dirIcon("created")}
{t("Created")}{" "}{dirIcon("created")}
</button>
</div>
{groups.length === 0 && (
<div data-testid="chain-table-nomatch" className="px-4 pt-3 text-xs text-muted-foreground">
No matching chains.
</div>
{t("No matching chains.")}</div>
)}
{groups.map((group) => {
@@ -348,15 +351,15 @@ export function ChainTableBody({
indeterminate={selectedCount > 0 && selectedCount < group.nodeIds.length}
onToggle={(next) => onToggleNodes(group.nodeIds, next)}
testid={`cluster-checkbox-${group.key}`}
label={`Select everything born in ${group.label ?? "unlabeled"}`}
label={t("Select everything born in {v0}", { v0: group.label ?? "unlabeled" })}
/>
<Expander open={open} onClick={() => onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={`Expand cluster ${group.label ?? "unlabeled"}`} />
<Expander open={open} onClick={() => onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={t("Expand cluster {v0}", { v0: group.label ?? "unlabeled" })} />
<span className="flex shrink-0 items-center justify-center text-muted-foreground">
{open ? <FolderOpenIcon className="size-3.5" /> : <FolderIcon className="size-3.5" />}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label ?? <span className="text-muted-foreground/60">unlabeled</span>}
{group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">notes</span>}
{group.label ?? <span className="text-muted-foreground/60">{t("unlabeled")}</span>}
{group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">{t("notes")}</span>}
</span>
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{group.nodeIds.length}
@@ -397,9 +400,9 @@ export function ChainTableBody({
indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length}
onToggle={(next) => onToggleNodes(memberIds, next)}
testid={`chain-checkbox-${chain.id}`}
label={`Select chain ${nameOf(chain.conclusion)}`}
label={t("Select chain {v0}", { v0: nameOf(chain.conclusion) })}
/>
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={`Expand chain ${nameOf(chain.conclusion)}`} />
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={t("Expand chain {v0}", { v0: nameOf(chain.conclusion) })} />
{/* The chain line: proposition names as faint-outlined
chips. THE LOGIC IS THE PAYLOAD (owner rulings,
2026-09-30): premises join with the LaTeX LAND (∧),
@@ -465,7 +468,7 @@ export function ChainTableBody({
checked={selection.has(id)}
onToggle={(next) => onToggleNodes([id], next)}
testid={`prop-checkbox-${id}`}
label={`Select node ${node.name}`}
label={t("Select node {v0}", { v0: node.name })}
/>
<span className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
{node.statement}
@@ -493,8 +496,8 @@ export function ChainTableBody({
className="pointer-events-auto flex items-center gap-3 rounded-full border bg-card px-4 py-2 shadow-lg"
>
<span data-testid="chain-selected-summary" className="text-xs font-medium">
{selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "}
{derived.length} {derived.length === 1 ? "chain" : "chains"}
{countLabel(language, selection.size, "node", "nodes")} →{" "}
{countLabel(language, derived.length, "chain", "chains")}
</span>
<button
type="button"
@@ -502,19 +505,17 @@ export function ChainTableBody({
onClick={onClear}
className="rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
{t("Clear")}</button>
{selectMode ? (
<button
type="button"
data-testid="chain-practice-done"
onClick={onSelect}
aria-label={`Done picking — ${selection.size} ${selection.size === 1 ? "proposition" : "propositions"} selected`}
aria-label={t("Done picking — {v0} {v1} selected", { v0: selection.size, v1: selection.size === 1 ? "proposition" : "propositions" })}
className="flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/80"
>
<DumbbellIcon aria-hidden="true" className="size-3.5" />
Done
</button>
{t("Done")}</button>
) : (
<>
{/* The danger fence (owner ruling): a divider quarantines
@@ -526,12 +527,11 @@ export function ChainTableBody({
type="button"
data-testid="chain-delete"
onClick={onDelete}
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "node" : "nodes"}`}
aria-label={t("Delete {v0} selected {v1}", { v0: selection.size, v1: selection.size === 1 ? "node" : "nodes" })}
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
>
<Trash2Icon aria-hidden="true" className="size-3.5" />
Delete
</button>
{t("Delete")}</button>
</>
)}
</div>