diff --git a/composable-os/src/components/LanguageSelector.test.tsx b/composable-os/src/components/LanguageSelector.test.tsx
index 18fc225..8ee765c 100644
--- a/composable-os/src/components/LanguageSelector.test.tsx
+++ b/composable-os/src/components/LanguageSelector.test.tsx
@@ -24,7 +24,7 @@ describe("LanguageSelector", () => {
it("draws nothing while there is only one language to choose", () => {
// Un desplegable de una opcion es ruido: el selector aparece cuando hay
// algo que elegir, sin tocar nada mas.
- const { container } = render();
+ const { container } = render();
expect(container).toBeEmptyDOMElement();
});
diff --git a/composable-os/src/i18n/catalogues.test.ts b/composable-os/src/i18n/catalogues.test.ts
index 6f14e50..24a2e0a 100644
--- a/composable-os/src/i18n/catalogues.test.ts
+++ b/composable-os/src/i18n/catalogues.test.ts
@@ -31,7 +31,7 @@ const BASE = "es";
* idioma se ofrece solo (`releasedLocales`): no hay un interruptor aparte.
*/
const TRANSLATED: Record = {
- en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales", "settings"],
+ en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales", "settings", "workflows"],
};
/**
@@ -58,6 +58,7 @@ const IDENTICAL_TO_BASE = [
"portalBuilder:sessions.email",
"sales:quotes.total", "sales:quotes.pdf", "sales:orders.total", "sales:detail.pdf", "sales:detail.lines.sku",
"sales:detail.totals.subtotal", "sales:detail.totals.total",
+ "workflows:title", "workflows:kpi.total", "workflows:actionType.log",
"settings:tabs.general", "settings:architecture.nodes.gateway.label", "settings:architecture.nodes.gateway.sublabel",
"settings:architecture.nodes.crm.label", "settings:architecture.nodes.eventbus.label",
"settings:architecture.nodes.postgres.label", "settings:architecture.cards.gateway.title",
diff --git a/composable-os/src/i18n/english-workflows.test.tsx b/composable-os/src/i18n/english-workflows.test.tsx
new file mode 100644
index 0000000..b7c9223
--- /dev/null
+++ b/composable-os/src/i18n/english-workflows.test.tsx
@@ -0,0 +1,191 @@
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
+import i18next, { type i18n as I18n } from "i18next";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { buildI18nOptions, resources } from "@/i18n";
+import { formatDate } from "@/i18n/format";
+import Workflows from "@/pages/Workflows";
+import { renderPageWithI18n } from "@/test/i18n";
+
+/**
+ * `workflows` en el ingles *real* (tarea 12 del plan de i18n): la lista, el historial de
+ * ejecuciones y el dialogo de alta con el catalogo `en` que se envia, no el
+ * pseudo-localizado de `Workflows.test.tsx`.
+ */
+
+const svc = vi.hoisted(() => ({
+ getAll: vi.fn(), create: vi.fn(), toggle: vi.fn(), delete: vi.fn(), getRuns: vi.fn(),
+}));
+const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
+
+vi.mock("@/services/workflows", () => ({ workflowsService: svc }));
+vi.mock("sonner", () => ({ toast }));
+
+async function english(): Promise {
+ const instance = i18next.createInstance();
+ await instance.init(buildI18nOptions(resources, "en"));
+ return instance;
+}
+
+const workflow = (over: Record = {}) => ({
+ id: "w1", name: "Lead intake", description: "Alerts the team", trigger_type: "event",
+ trigger_config: { event_type: "LeadCaptured" }, conditions: [{ field: "a", operator: "eq", value: 1 }],
+ actions: [
+ { type: "log", config: { message: "hello" } },
+ { type: "log", config: {} },
+ { type: "log", config: {} },
+ ],
+ enabled: true, run_count: 1, last_triggered_at: "2026-09-30T12:00:00Z",
+ created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z", ...over,
+});
+
+const runs = [
+ {
+ id: "r1", workflow_id: "w1", status: "completed", trigger_event_type: "LeadCaptured", trigger_event_id: null,
+ steps_result: [{ action_type: "log", status: "completed", detail: "", duration_ms: 12 }],
+ error: null, created_at: "2026-09-30T12:00:00Z",
+ },
+ {
+ id: "r2", workflow_id: "w1", status: "failed", trigger_event_type: null, trigger_event_id: null,
+ steps_result: [], error: "boom", created_at: "2026-09-30T13:00:00Z",
+ },
+];
+
+const renderPage = (instance: I18n) => renderPageWithI18n(, instance);
+const viewRuns = async () => fireEvent.click((await screen.findAllByRole("button", { name: "View runs" }))[0]);
+
+beforeEach(() => {
+ Object.values(svc).forEach((fn) => fn.mockReset());
+ Object.values(toast).forEach((fn) => fn.mockReset());
+ svc.getAll.mockResolvedValue([
+ workflow(),
+ workflow({ id: "w2", name: "Closed sales", description: null, conditions: [{}, {}], actions: [{ type: "log", config: {} }], run_count: 5, enabled: false, last_triggered_at: null }),
+ ]);
+ svc.getRuns.mockResolvedValue(runs);
+ svc.create.mockResolvedValue({});
+ svc.delete.mockResolvedValue(undefined);
+});
+
+describe("Workflows in English", () => {
+ it("renders the header, the KPIs and the cards", async () => {
+ renderPage(await english());
+
+ expect(screen.getByRole("heading", { name: "Workflows" })).toBeInTheDocument();
+ expect(screen.getByText("Event-driven automation — triggers, conditions and actions.")).toBeInTheDocument();
+ expect(await screen.findByText("Lead intake")).toBeInTheDocument();
+ for (const text of ["Total", "Active", "Runs"]) {
+ expect(screen.getByText(text)).toBeInTheDocument();
+ }
+ expect(screen.getAllByRole("button", { name: /New workflow/ })[0]).toBeInTheDocument();
+ });
+
+ it("agrees plurals with the number, including one", async () => {
+ renderPage(await english());
+ await screen.findByText("Lead intake");
+
+ expect(screen.getByText("1 run")).toBeInTheDocument();
+ expect(screen.getByText("5 runs")).toBeInTheDocument();
+ expect(screen.getByText("1 condition")).toBeInTheDocument();
+ expect(screen.getByText("2 conditions")).toBeInTheDocument();
+ expect(screen.getByText("+1 more")).toBeInTheDocument();
+ });
+
+ it("shows the trigger and action types, and the last run as a formatted date", async () => {
+ renderPage(await english());
+ await screen.findByText("Lead intake");
+
+ expect(screen.getAllByText("event").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("log").length).toBeGreaterThan(0);
+ expect(screen.queryByText(/2026-09-30/)).not.toBeInTheDocument();
+ expect(screen.getByText(formatDate("2026-09-30T12:00:00Z", "en", "short"))).toBeInTheDocument();
+ });
+
+ it("explains an empty list and offers to create the first workflow", async () => {
+ svc.getAll.mockResolvedValue([]);
+ renderPage(await english());
+
+ expect(await screen.findByText("No workflows yet")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Create the first one/ })).toBeInTheDocument();
+ });
+
+ it("names the icon-only buttons and the switches", async () => {
+ renderPage(await english());
+ await screen.findByText("Lead intake");
+
+ expect(screen.getAllByRole("button", { name: "View runs" })).toHaveLength(2);
+ expect(screen.getAllByRole("button", { name: "Delete workflow" })).toHaveLength(2);
+ expect(screen.getByRole("switch", { name: "Turn Lead intake on or off" })).toBeInTheDocument();
+ });
+
+ it("shows the run history with English statuses and the raw backend error", async () => {
+ renderPage(await english());
+ await viewRuns();
+
+ expect(await screen.findByText("Run history")).toBeInTheDocument();
+ const dialog = screen.getByRole("dialog");
+ expect(await within(dialog).findByText("failed")).toBeInTheDocument();
+ expect(within(dialog).getAllByText("completed").length).toBeGreaterThan(0);
+ expect(within(dialog).getByText("Event: LeadCaptured")).toBeInTheDocument();
+ expect(within(dialog).getByText("12ms")).toBeInTheDocument();
+ expect(within(dialog).getByText("boom")).toBeInTheDocument();
+ });
+
+ it("falls back to the raw value for a status the catalogue does not know", async () => {
+ svc.getRuns.mockResolvedValue([{ ...runs[0], status: "queued_for_retry", steps_result: [] }]);
+ renderPage(await english());
+ await viewRuns();
+
+ expect(await screen.findByText("queued_for_retry")).toBeInTheDocument();
+ });
+
+ it("shows the empty run history", async () => {
+ svc.getRuns.mockResolvedValue([]);
+ renderPage(await english());
+ await viewRuns();
+
+ expect(await screen.findByText("No runs yet")).toBeInTheDocument();
+ });
+
+ it("creates a workflow, sending the English default action message", async () => {
+ renderPage(await english());
+ await screen.findByText("Lead intake");
+
+ fireEvent.click(screen.getAllByRole("button", { name: /New workflow/ })[0]);
+ expect(await screen.findByText("Trigger event")).toBeInTheDocument();
+ expect(screen.getByText("Description (optional)")).toBeInTheDocument();
+ expect(screen.getByText(/^Available events: LeadCaptured, /)).toBeInTheDocument();
+ fireEvent.change(screen.getByPlaceholderText("E.g. Notify new lead"), { target: { value: "My flow" } });
+ fireEvent.change(screen.getByPlaceholderText("E.g. LeadCaptured, QuoteAccepted"), { target: { value: "QuoteAccepted" } });
+ fireEvent.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => expect(svc.create).toHaveBeenCalled());
+ const payload = svc.create.mock.calls[0][0];
+ expect(payload.name).toBe("My flow");
+ expect(payload.actions).toEqual([{ type: "log", config: { message: "Workflow executed" } }]);
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Workflow created"));
+ });
+
+ it("sends the message the user typed instead of the default", async () => {
+ renderPage(await english());
+ await screen.findByText("Lead intake");
+
+ fireEvent.click(screen.getAllByRole("button", { name: /New workflow/ })[0]);
+ fireEvent.change(await screen.findByPlaceholderText("E.g. Notify new lead"), { target: { value: "My flow" } });
+ fireEvent.change(screen.getByPlaceholderText("E.g. LeadCaptured, QuoteAccepted"), { target: { value: "QuoteAccepted" } });
+ fireEvent.change(screen.getByPlaceholderText("Use {field} for event values"), { target: { value: "Closed {field}" } });
+ fireEvent.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => expect(svc.create).toHaveBeenCalled());
+ expect(svc.create.mock.calls[0][0].actions).toEqual([{ type: "log", config: { message: "Closed {field}" } }]);
+ });
+
+ it("deletes a workflow and confirms it", async () => {
+ renderPage(await english());
+ await screen.findByText("Lead intake");
+
+ fireEvent.click(screen.getAllByRole("button", { name: "Delete workflow" })[0]);
+
+ await waitFor(() => expect(svc.delete).toHaveBeenCalled());
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Workflow deleted"));
+ });
+});
diff --git a/composable-os/src/i18n/english.test.tsx b/composable-os/src/i18n/english.test.tsx
index 493116c..36dc66c 100644
--- a/composable-os/src/i18n/english.test.tsx
+++ b/composable-os/src/i18n/english.test.tsx
@@ -93,11 +93,13 @@ describe("the real English catalogue", () => {
});
it("falls back to Spanish for a namespace English does not have yet", async () => {
- // Es la razon por la que `en` no se ofrece hasta estar completo: sin esto un
+ // Es la razon por la que un idioma no se ofrece hasta estar completo: sin esto un
// usuario veria la pantalla mezclada. Se fija para que nadie lo descubra en
- // produccion.
- const instance = await english();
- // `workflows` sigue pendiente: cuando se traduzca, este test pasa a otro dominio.
+ // produccion. `en` ya esta completo, asi que se le quita un espacio a mano.
+ const { workflows: _pending, ...partial } = resources.en;
+ const instance = i18next.createInstance();
+ await instance.init(buildI18nOptions({ ...resources, en: partial }, "en"));
+
expect(instance.t("workflows:new")).toBe("Nuevo Workflow");
});
});
diff --git a/composable-os/src/i18n/i18n.test.ts b/composable-os/src/i18n/i18n.test.ts
index f2725e7..5768bc0 100644
--- a/composable-os/src/i18n/i18n.test.ts
+++ b/composable-os/src/i18n/i18n.test.ts
@@ -16,19 +16,25 @@ afterEach(async () => {
});
describe("catalogues", () => {
- it("offers Spanish only while English is incomplete", () => {
- // Si esto cambia, el ultimo dominio de `en` ya esta traducido y varios tests
- // pasan a ser otro caso: hay que revisarlos, no solo actualizar el numero.
- expect(SUPPORTED_LOCALES).toEqual(["es"]);
- expect(UNRELEASED_LOCALES).toEqual(["en"]);
+ it("offers every language that has all the namespaces of Spanish", () => {
+ // `en` completo (tarea 12, ultimo dominio) entra solo: no hay un interruptor aparte.
+ expect([...SUPPORTED_LOCALES].sort()).toEqual(["en", "es"]);
+ expect(UNRELEASED_LOCALES).toEqual([]);
});
- it("keeps an unreleased language out of reach: setLocale and the browser language ignore it", async () => {
- expect(resources.en).toBeDefined();
- expect(await setLocale("en")).toBe("es");
+ it("keeps a language without a shipped catalogue out of reach: setLocale ignores it", async () => {
+ expect(resources.fr).toBeUndefined();
+ expect(await setLocale("fr")).toBe("es");
expect(i18n.language).toBe("es");
});
+ it("switches to a released language and remembers it", async () => {
+ expect(await setLocale("en")).toBe("en");
+ expect(i18n.language).toBe("en");
+ expect(i18n.t("auth:login.title")).toBe("Sign in");
+ expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("en");
+ });
+
it("resolves a key from the bundled catalogue", () => {
expect(i18n.t("auth:login.title")).toBe("Iniciar sesión");
});
diff --git a/composable-os/src/locales/en/workflows.json b/composable-os/src/locales/en/workflows.json
new file mode 100644
index 0000000..89c07fe
--- /dev/null
+++ b/composable-os/src/locales/en/workflows.json
@@ -0,0 +1,58 @@
+{
+ "title": "Workflows",
+ "subtitle": "Event-driven automation — triggers, conditions and actions.",
+ "new": "New workflow",
+ "deleted": "Workflow deleted",
+ "kpi": {
+ "total": "Total",
+ "active": "Active",
+ "runs": "Runs"
+ },
+ "empty": {
+ "title": "No workflows yet",
+ "createFirst": "Create the first one"
+ },
+ "card": {
+ "conditions_one": "{{count}} condition",
+ "conditions_other": "{{count}} conditions",
+ "moreActions": "+{{count}} more",
+ "runs_one": "{{count}} run",
+ "runs_other": "{{count}} runs",
+ "toggle": "Turn {{name}} on or off",
+ "viewRuns": "View runs",
+ "delete": "Delete workflow"
+ },
+ "create": {
+ "title": "New workflow",
+ "name": "Name",
+ "namePlaceholder": "E.g. Notify new lead",
+ "description": "Description (optional)",
+ "descriptionPlaceholder": "What this workflow does",
+ "event": "Trigger event",
+ "eventPlaceholder": "E.g. LeadCaptured, QuoteAccepted",
+ "availableEvents": "Available events: {{events}}",
+ "actionMessage": "Action message (log)",
+ "actionMessagePlaceholder": "Use {field} for event values",
+ "defaultMessage": "Workflow executed",
+ "cancel": "Cancel",
+ "submit": "Create",
+ "created": "Workflow created"
+ },
+ "runs": {
+ "title": "Run history",
+ "empty": "No runs yet",
+ "event": "Event: {{type}}"
+ },
+ "runStatus": {
+ "completed": "completed",
+ "failed": "failed",
+ "running": "running",
+ "skipped": "skipped"
+ },
+ "triggerType": {
+ "event": "event"
+ },
+ "actionType": {
+ "log": "log"
+ }
+}
diff --git a/composable-os/src/pages/Login.test.tsx b/composable-os/src/pages/Login.test.tsx
index 97ca874..2c75861 100644
--- a/composable-os/src/pages/Login.test.tsx
+++ b/composable-os/src/pages/Login.test.tsx
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { ApiError } from "@/lib/api";
import Login from "@/pages/Login";
-import { createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n";
+import { LANGUAGE_NAMES, createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n";
const loginMock = vi.fn();
const navigateMock = vi.fn();
@@ -92,7 +92,7 @@ describe("Login, in a second language", () => {
const { container } = renderWithI18n(, english);
const hardcoded = visibleStrings(container).filter(
- (text) => !text.startsWith("EN(") && text !== BRAND,
+ (text) => !text.startsWith("EN(") && text !== BRAND && !LANGUAGE_NAMES.test(text),
);
expect(hardcoded).toEqual([]);
diff --git a/composable-os/src/pages/Register.test.tsx b/composable-os/src/pages/Register.test.tsx
index 2d8b8fa..e3cff16 100644
--- a/composable-os/src/pages/Register.test.tsx
+++ b/composable-os/src/pages/Register.test.tsx
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { ApiError } from "@/lib/api";
import Register from "@/pages/Register";
-import { createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n";
+import { LANGUAGE_NAMES, createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n";
const registerMock = vi.fn();
const navigateMock = vi.fn();
@@ -124,7 +124,7 @@ describe("Register, in a second language", () => {
const english = await createPseudoInstance("en");
const { container } = renderWithI18n(, english);
- const hardcoded = visibleStrings(container).filter((text) => !text.startsWith("EN(") && text !== BRAND);
+ const hardcoded = visibleStrings(container).filter((text) => !text.startsWith("EN(") && text !== BRAND && !LANGUAGE_NAMES.test(text));
expect(hardcoded).toEqual([]);
});
diff --git a/composable-os/src/test/i18n.tsx b/composable-os/src/test/i18n.tsx
index 856ca12..f69d7c8 100644
--- a/composable-os/src/test/i18n.tsx
+++ b/composable-os/src/test/i18n.tsx
@@ -80,6 +80,14 @@ export function renderPageWithI18n(
);
}
+/**
+ * Cada idioma escrito en si mismo, como lo pinta `LanguageSelector` (el nombre de un
+ * idioma no se traduce: quien busca el suyo lo reconoce asi). Se descarta siempre:
+ * en cuanto hay mas de un idioma ofrecido el selector aparece en login, registro y
+ * ajustes, y no es texto que falte en un catalogo.
+ */
+export const LANGUAGE_NAMES =/^(Español|English)$/;
+
/**
* Lo que una pagina pinta sin que venga del catalogo de la app: texto que llega
* del backend (nombres, descripciones) y fechas o numeros ya formateados. La
@@ -89,6 +97,7 @@ export function renderPageWithI18n(
* - `data`: valores del backend que el test sembro (se descartan si el texto los contiene).
* - se descarta tambien todo texto con un ano de cuatro cifras (una fecha formateada)
* y los numeros con unidad abreviada (`12ms`, `1,234.5ms`), que los produce `Intl`.
+ * - se descartan los nombres de idioma (`LANGUAGE_NAMES`).
*/
export function notFromCatalogue(
texts: string[],
@@ -99,6 +108,7 @@ export function notFromCatalogue(
return texts.filter(
(text) =>
!text.startsWith(prefix) &&
+ !LANGUAGE_NAMES.test(text) &&
!data.some((value) => text.includes(value)) &&
!/\d{4}/.test(text) &&
!/^\d[\d.,]*\s?[a-zA-Zµ]{0,3}$/.test(text) &&
diff --git a/composable-os/src/test/pin-locale.ts b/composable-os/src/test/pin-locale.ts
new file mode 100644
index 0000000..d2fdf02
--- /dev/null
+++ b/composable-os/src/test/pin-locale.ts
@@ -0,0 +1,16 @@
+/**
+ * jsdom declara `en-US` como idioma del navegador. Mientras `en` estaba incompleto no
+ * importaba (la deteccion lo descartaba y caia al espanol); con los 19 espacios
+ * traducidos `en` se ofrece, y el `i18n` global arrancaria en ingles: los tests de
+ * pagina "in Spanish" leerian ingles. Los tests de ingles construyen su propia
+ * instancia (`createInstance`), asi que el global se queda en espanol, que es el
+ * idioma de referencia.
+ *
+ * Va el primero en `setup.ts`: `@/i18n` lee el idioma al importarse.
+ */
+for (const [key, value] of [
+ ["language", "es"],
+ ["languages", ["es"]],
+] as const) {
+ Object.defineProperty(navigator, key, { value, configurable: true });
+}
diff --git a/composable-os/src/test/setup.ts b/composable-os/src/test/setup.ts
index ba2ab4f..ef857a1 100644
--- a/composable-os/src/test/setup.ts
+++ b/composable-os/src/test/setup.ts
@@ -1,4 +1,6 @@
import "@testing-library/jest-dom";
+// Antes que `@/i18n`, que lee el idioma del navegador al importarse.
+import "./pin-locale";
// Inicializa i18next como lo hace main.tsx: sin esto, cualquier codigo que llame
// a `t()` o a translateApiError devolveria la clave cruda.
import "@/i18n";
diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md
index d516dec..ab4fa8d 100644
--- a/docs/IMPLEMENTATION_ALIGNMENT.md
+++ b/docs/IMPLEMENTATION_ALIGNMENT.md
@@ -83,7 +83,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR
| External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces |
| Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation |
| Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR |
-| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales` and `settings` translated (18 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose |
+| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose |
| Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away |
---
diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md
index b0aa03c..9b21917 100644
--- a/docs/superpowers/plans/2026-08-07-i18n-full.md
+++ b/docs/superpowers/plans/2026-08-07-i18n-full.md
@@ -459,9 +459,11 @@ completa migrada no hay patron que seguir.
paneles), `invoicing` (facturas, pagos y alta),
`portalBuilder` (sesiones, constructor de paginas y bloques de tienda), `projects`
(lista, detalle con tareas y dialogos de alta), `sales` (panel, cotizaciones, ordenes
- y el detalle de una cotizacion con su dialogo de compartir) y `settings` (las cinco
- pestanas de Ajustes).
- Queda 1: `workflows`, `workflows`.
+ y el detalle de una cotizacion con su dialogo de compartir), `settings` (las cinco
+ pestanas de Ajustes) y `workflows` (lista, historial de ejecuciones y alta).
+ Los 19 espacios estan traducidos. Como `en` ya tiene todos los de `es`, el
+ frontend lo ofrece solo (`releasedLocales`): selector, deteccion del navegador y
+ `setLocale`. El backend sigue sin ofrecerlo; ver el punto siguiente.
- [ ] Ofrecer `en`: mover `en` de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` en el backend
y el selector de idioma en el dialogo de compartir (pendiente desde la tarea 2).
- [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: