this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add per-form respondent locale override

+452 -9
+15 -4
app/f/[slug]/page.tsx
··· 1 1 import type { Metadata } from "next"; 2 2 import { notFound } from "next/navigation"; 3 3 4 + import { I18nProvider } from "@/components/i18n-provider"; 4 5 import { PublicFormRunner } from "@/components/public-form-runner"; 5 6 import { getPublicFormBySlug } from "@/lib/forms"; 6 - import { getRequestI18n } from "@/lib/i18n-server"; 7 + import { resolveAppLocale } from "@/lib/i18n"; 8 + import { 9 + getMessages, 10 + getRequestI18n, 11 + getRequestLocale, 12 + } from "@/lib/i18n-server"; 7 13 import { resolveTitle, withTitle } from "@/lib/metadata"; 8 14 9 15 async function getPublicForm(slug: string) { ··· 35 41 }) { 36 42 const { slug } = await params; 37 43 const form = await getPublicForm(slug); 44 + const requestLocale = await getRequestLocale(); 45 + const locale = resolveAppLocale(requestLocale, form.respondentLocale); 46 + const messages = await getMessages(locale); 38 47 39 48 return ( 40 - <main className="mx-auto flex w-full max-w-6xl flex-1 items-center justify-center px-6 py-8 lg:px-10 lg:py-10"> 41 - <PublicFormRunner form={form} /> 42 - </main> 49 + <I18nProvider locale={locale} messages={messages}> 50 + <main className="mx-auto flex w-full max-w-6xl flex-1 items-center justify-center px-6 py-8 lg:px-10 lg:py-10"> 51 + <PublicFormRunner form={form} /> 52 + </main> 53 + </I18nProvider> 43 54 ); 44 55 }
+42
components/form-builder-panels.tsx
··· 47 47 PopoverContent, 48 48 PopoverTrigger, 49 49 } from "@/components/ui/popover"; 50 + import { 51 + Select, 52 + SelectContent, 53 + SelectItem, 54 + SelectTrigger, 55 + SelectValue, 56 + } from "@/components/ui/select"; 50 57 import { Textarea } from "@/components/ui/textarea"; 51 58 import { 52 59 blockTypeTranslationKeys, ··· 66 73 type ChoiceOptionDraft, 67 74 } from "@/lib/form-builder-drafts"; 68 75 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 76 + import { supportedLocales, type AppLocale } from "@/lib/i18n"; 69 77 70 78 export type FormMetadataDraft = { 71 79 title: string; ··· 73 81 completionMessage: string; 74 82 completionLinkLabel: string; 75 83 completionLinkUrl: string; 84 + respondentLocale: AppLocale | null; 76 85 showProgress: boolean; 77 86 slug: string; 78 87 }; ··· 301 310 <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> 302 311 {t("builder.appearanceDescription")} 303 312 </p> 313 + </div> 314 + 315 + <div className="grid gap-2 text-sm text-[var(--muted)]"> 316 + <span className="font-medium text-[var(--ink)]"> 317 + {t("builder.respondentLanguage")} 318 + </span> 319 + <Select 320 + value={metadataDraft.respondentLocale ?? "visitor"} 321 + onValueChange={(value) => 322 + setMetadataDraft((current) => ({ 323 + ...current, 324 + respondentLocale: 325 + value === "visitor" ? null : (value as AppLocale), 326 + })) 327 + } 328 + > 329 + <SelectTrigger className="h-11 px-4 text-sm font-medium"> 330 + <SelectValue /> 331 + </SelectTrigger> 332 + <SelectContent> 333 + <SelectItem value="visitor"> 334 + {t("builder.respondentLanguageOptions.visitor")} 335 + </SelectItem> 336 + {supportedLocales.map((localeOption) => ( 337 + <SelectItem key={localeOption} value={localeOption}> 338 + {t(`settings.profile.localeOptions.${localeOption}`)} 339 + </SelectItem> 340 + ))} 341 + </SelectContent> 342 + </Select> 343 + <span className="text-xs text-[var(--muted)]"> 344 + {t("builder.respondentLanguageHelp")} 345 + </span> 304 346 </div> 305 347 306 348 <label className="flex items-start gap-3 text-sm text-[var(--muted)]">
+114 -1
components/form-builder.test.tsx
··· 4 4 import { I18nProvider } from "@/components/i18n-provider"; 5 5 import { getDefaultBlockConfig } from "@/lib/blocks"; 6 6 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 7 + import type { TranslationTree } from "@/lib/i18n"; 7 8 import { installTestDom } from "@/test/install-dom"; 8 9 9 10 const routerPushCalls: string[] = []; ··· 52 53 completionMessage: "Thanks", 53 54 completionLinkLabel: null, 54 55 completionLinkUrl: null, 56 + respondentLocale: null, 55 57 showProgress: true, 56 58 slug: "demo-form", 57 59 status: "DRAFT", ··· 68 70 function renderBuilder( 69 71 initialForm: BuilderForm, 70 72 initialMode?: "standard" | "graph", 73 + messages: TranslationTree = {}, 71 74 ) { 72 75 return render( 73 - <I18nProvider locale="en" messages={{}}> 76 + <I18nProvider locale="en" messages={messages}> 74 77 <FormBuilder initialForm={initialForm} initialMode={initialMode} /> 75 78 </I18nProvider>, 76 79 ); ··· 130 133 }); 131 134 expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); 132 135 136 + await cleanupAndRestore(restoreDom); 137 + }); 138 + 139 + test("saves a restored respondent locale override from form settings", async () => { 140 + const restoreDom = installTestDom(); 141 + const firstBlock = createBlock({ 142 + id: "block-1", 143 + type: "SHORT_TEXT", 144 + position: 0, 145 + title: "First question", 146 + }); 147 + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; 148 + const previousFetch = globalThis.fetch; 149 + 150 + globalThis.fetch = (async ( 151 + url: string | URL | Request, 152 + init?: RequestInit, 153 + ) => { 154 + fetchCalls.push({ url: String(url), init }); 155 + 156 + return { 157 + ok: true, 158 + json: async () => ({ 159 + form: { 160 + ...createForm([firstBlock]), 161 + respondentLocale: "ru", 162 + }, 163 + }), 164 + } as Response; 165 + }) as typeof fetch; 166 + 167 + const view = renderBuilder( 168 + { 169 + ...createForm([firstBlock]), 170 + respondentLocale: "ru", 171 + }, 172 + undefined, 173 + { 174 + builder: { saveFormSettings: "Save form settings" }, 175 + }, 176 + ); 177 + 178 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 179 + 180 + await waitFor(() => { 181 + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 182 + }); 183 + 184 + fireEvent.click(view.getByRole("button", { name: "Save form settings" })); 185 + 186 + await waitFor(() => { 187 + expect(fetchCalls.length).toBe(1); 188 + }); 189 + 190 + expect(fetchCalls[0]?.url).toBe("/api/forms/form-1"); 191 + expect(fetchCalls[0]?.init?.method).toBe("PATCH"); 192 + expect(String(fetchCalls[0]?.init?.body)).toContain( 193 + '"respondentLocale":"ru"', 194 + ); 195 + 196 + globalThis.fetch = previousFetch; 197 + await cleanupAndRestore(restoreDom); 198 + }); 199 + 200 + test("saves visitor-language mode from form settings", async () => { 201 + const restoreDom = installTestDom(); 202 + const firstBlock = createBlock({ 203 + id: "block-1", 204 + type: "SHORT_TEXT", 205 + position: 0, 206 + title: "First question", 207 + }); 208 + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; 209 + const previousFetch = globalThis.fetch; 210 + 211 + globalThis.fetch = (async ( 212 + url: string | URL | Request, 213 + init?: RequestInit, 214 + ) => { 215 + fetchCalls.push({ url: String(url), init }); 216 + 217 + return { 218 + ok: true, 219 + json: async () => ({ 220 + form: createForm([firstBlock]), 221 + }), 222 + } as Response; 223 + }) as typeof fetch; 224 + 225 + const view = renderBuilder(createForm([firstBlock]), undefined, { 226 + builder: { saveFormSettings: "Save form settings" }, 227 + }); 228 + 229 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 230 + 231 + await waitFor(() => { 232 + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 233 + }); 234 + 235 + fireEvent.click(view.getByRole("button", { name: "Save form settings" })); 236 + 237 + await waitFor(() => { 238 + expect(fetchCalls.length).toBe(1); 239 + }); 240 + 241 + expect(String(fetchCalls[0]?.init?.body)).toContain( 242 + '"respondentLocale":null', 243 + ); 244 + 245 + globalThis.fetch = previousFetch; 133 246 await cleanupAndRestore(restoreDom); 134 247 }); 135 248
+3
components/form-builder.tsx
··· 284 284 completionMessage: initialForm.completionMessage, 285 285 completionLinkLabel: initialForm.completionLinkLabel ?? "", 286 286 completionLinkUrl: initialForm.completionLinkUrl ?? "", 287 + respondentLocale: initialForm.respondentLocale, 287 288 showProgress: initialForm.showProgress, 288 289 slug: initialForm.slug, 289 290 }); ··· 374 375 completionMessage: form.completionMessage, 375 376 completionLinkLabel: form.completionLinkLabel ?? "", 376 377 completionLinkUrl: form.completionLinkUrl ?? "", 378 + respondentLocale: form.respondentLocale, 377 379 showProgress: form.showProgress, 378 380 slug: form.slug, 379 381 }); ··· 383 385 form.completionMessage, 384 386 form.completionLinkLabel, 385 387 form.completionLinkUrl, 388 + form.respondentLocale, 386 389 form.showProgress, 387 390 form.slug, 388 391 ]);
+24
lib/form-metadata-schema.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import { formMetadataSchema } from "@/lib/validators"; 4 + 5 + describe("formMetadataSchema", () => { 6 + test("keeps a supported respondent locale override", () => { 7 + const parsed = formMetadataSchema.parse({ 8 + title: "Demo form", 9 + slug: "demo-form", 10 + respondentLocale: "ru", 11 + }); 12 + 13 + expect(parsed.respondentLocale).toBe("ru"); 14 + }); 15 + 16 + test("defaults respondent locale to visitor language mode", () => { 17 + const parsed = formMetadataSchema.parse({ 18 + title: "Demo form", 19 + slug: "demo-form", 20 + }); 21 + 22 + expect(parsed.respondentLocale).toBe(null); 23 + }); 24 + });
+3
lib/form-types.ts
··· 1 1 import type { FormStatus, OrganizationMemberRole } from "@prisma/client"; 2 2 3 3 import type { SerializedBlock } from "@/lib/blocks"; 4 + import type { AppLocale } from "@/lib/i18n"; 4 5 5 6 export type BuilderBlock = SerializedBlock; 6 7 export type PublicBlock = SerializedBlock; ··· 44 45 completionMessage: string; 45 46 completionLinkLabel: string | null; 46 47 completionLinkUrl: string | null; 48 + respondentLocale: AppLocale | null; 47 49 showProgress: boolean; 48 50 slug: string; 49 51 status: FormStatus; ··· 61 63 completionMessage: string; 62 64 completionLinkLabel: string | null; 63 65 completionLinkUrl: string | null; 66 + respondentLocale: AppLocale | null; 64 67 showProgress: boolean; 65 68 slug: string; 66 69 blocks: PublicBlock[];
+12 -1
lib/forms.ts
··· 39 39 reorderBlocksSchema, 40 40 } from "@/lib/validators"; 41 41 import { slugify } from "@/lib/utils"; 42 - import { DEFAULT_LOCALE, translate, type AppLocale } from "@/lib/i18n"; 42 + import { 43 + DEFAULT_LOCALE, 44 + normalizeLocale, 45 + translate, 46 + type AppLocale, 47 + } from "@/lib/i18n"; 43 48 import { getMessages } from "@/lib/i18n-server"; 44 49 import { normalizeBlockConfig } from "@/lib/block-config-normalization"; 45 50 import { ··· 122 127 completionMessage: form.completionMessage, 123 128 completionLinkLabel: form.completionLinkLabel, 124 129 completionLinkUrl: form.completionLinkUrl, 130 + respondentLocale: form.respondentLocale 131 + ? normalizeLocale(form.respondentLocale) 132 + : null, 125 133 showProgress: form.showProgress, 126 134 slug: form.slug, 127 135 status: form.status, ··· 141 149 completionMessage: form.completionMessage, 142 150 completionLinkLabel: form.completionLinkLabel, 143 151 completionLinkUrl: form.completionLinkUrl, 152 + respondentLocale: form.respondentLocale 153 + ? normalizeLocale(form.respondentLocale) 154 + : null, 144 155 showProgress: form.showProgress, 145 156 slug: form.slug, 146 157 blocks: form.blocks.map(serializeBlock),
+17
lib/i18n.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import { resolveAppLocale } from "@/lib/i18n"; 4 + 5 + describe("resolveAppLocale", () => { 6 + test("uses the explicit override before request locale", () => { 7 + expect(resolveAppLocale("en", "ru")).toBe("ru"); 8 + }); 9 + 10 + test("falls back to the preferred locale when no override is set", () => { 11 + expect(resolveAppLocale("ru", null)).toBe("ru"); 12 + }); 13 + 14 + test("falls back to English for unsupported override values", () => { 15 + expect(resolveAppLocale("ru", "de")).toBe("en"); 16 + }); 17 + });
+11
lib/i18n.ts
··· 20 20 return isSupportedLocale(base) ? base : DEFAULT_LOCALE; 21 21 } 22 22 23 + export function resolveAppLocale( 24 + preferredLocale: string | null | undefined, 25 + overrideLocale?: string | null, 26 + ): AppLocale { 27 + if (typeof overrideLocale !== "undefined" && overrideLocale !== null) { 28 + return normalizeLocale(overrideLocale); 29 + } 30 + 31 + return normalizeLocale(preferredLocale); 32 + } 33 + 23 34 export function resolveBrowserLocale( 24 35 headerValue: string | null | undefined, 25 36 ): AppLocale {
+1
lib/public-form-draft.test.ts
··· 17 17 completionMessage: "Done", 18 18 completionLinkLabel: null, 19 19 completionLinkUrl: null, 20 + respondentLocale: null, 20 21 showProgress: true, 21 22 slug: "demo-form", 22 23 blocks,
+2
lib/validators.ts
··· 11 11 completionMessage: z.string().trim().max(600).default(""), 12 12 completionLinkLabel: z.string().trim().max(80).default(""), 13 13 completionLinkUrl: z.string().trim().max(2048).default(""), 14 + respondentLocale: z.enum(supportedLocales).nullable().default(null), 14 15 showProgress: z.boolean().default(true), 15 16 slug: z 16 17 .string() ··· 59 60 }) 60 61 .transform((value) => ({ 61 62 ...value, 63 + respondentLocale: value.respondentLocale ?? null, 62 64 completionLinkLabel: value.completionLinkLabel || null, 63 65 completionLinkUrl: value.completionLinkUrl || null, 64 66 }));
+4
locales/en.yml
··· 161 161 publicRouteHelp: Published forms accept anonymous submissions. Changes to a published form go live immediately. 162 162 appearance: Appearance 163 163 appearanceDescription: Control what respondents see while moving through the form. 164 + respondentLanguage: Respondent interface language 165 + respondentLanguageHelp: Choose the language used for buttons, validation, progress, and other interface text in the public form. 166 + respondentLanguageOptions: 167 + visitor: Use visitor's language 164 168 showProgress: Show form progress 165 169 copyLink: Copy link 166 170 openRunner: Open runner
+4
locales/ru.yml
··· 161 161 publicRouteHelp: Опубликованные формы принимают анонимные ответы. Изменения в опубликованной форме становятся доступны сразу. 162 162 appearance: Внешний вид 163 163 appearanceDescription: Управляйте тем, что респонденты видят во время прохождения формы. 164 + respondentLanguage: Язык интерфейса для респондента 165 + respondentLanguageHelp: Выберите язык кнопок, валидации, прогресса и другого интерфейсного текста в публичной форме. 166 + respondentLanguageOptions: 167 + visitor: Использовать язык посетителя 164 168 showProgress: Показывать прогресс формы 165 169 copyLink: Скопировать ссылку 166 170 openRunner: Открыть раннер
+2
openspec/changes/archive/2026-04-15-add-form-locale-override/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-15
+70
openspec/changes/archive/2026-04-15-add-form-locale-override/design.md
··· 1 + ## Context 2 + 3 + Public form routes currently render localized interface strings from the active request locale, which is derived from supported browser or user-preference signals and then normalized to a supported language. That keeps general localization simple, but it does not give creators a way to keep a specific form's respondent-facing interface aligned with the language of the form content itself. This change crosses creator settings, stored form metadata, public-form loading, and locale resolution, so a short design is useful before implementation. 4 + 5 + ## Goals / Non-Goals 6 + 7 + **Goals:** 8 + - Let a creator choose a respondent interface locale override per form. 9 + - Preserve existing visitor-language detection when no override is configured. 10 + - Keep locale handling limited to the existing supported locales and fallback-to-English behavior. 11 + - Make the public runner, validation copy, progress labels, and completion state honor the saved override consistently. 12 + 13 + **Non-Goals:** 14 + - Auto-translating form content authored by creators. 15 + - Adding new supported languages in this change. 16 + - Introducing per-block or per-section locale settings. 17 + - Changing creator-dashboard locale behavior outside the form settings needed to edit the override. 18 + 19 + ## Decisions 20 + 21 + ### 1. Store locale override as form metadata, not as a transient request preference 22 + The system will persist a nullable form-level locale override alongside other form settings. `null` means the `Use visitor's language` mode; a supported locale value such as `en` or `ru` means the public runner must use that locale for respondent-facing UI. 23 + 24 + **Why:** The override is part of the form's authored behavior, similar to title, completion content, and slug. It must survive draft saves, publication, and later edits. 25 + 26 + **Alternatives considered:** 27 + - Store override only in published snapshots: rejected because creators need to preview and edit draft behavior. 28 + - Store override in browser local state: rejected because the setting belongs to the form, not the current creator session. 29 + 30 + ### 2. Apply the override only on public respondent-facing form surfaces 31 + The override will affect public form routes and the locale used to prepare public runner strings. Creator-facing builder chrome will continue to follow the creator/session locale. 32 + 33 + **Why:** The problem is respondent experience mismatch, not creator workspace localization. Keeping creator surfaces on their own locale avoids surprising workspace-wide language switches while editing a form. 34 + 35 + **Alternatives considered:** 36 + - Also force the builder locale while editing that form: rejected because it would couple one form's language to the whole creator session and make mixed-language workspaces harder to use. 37 + 38 + ### 3. Resolve locale with clear precedence: form override first, then existing detection, then fallback 39 + For public forms, locale resolution will first check the saved form override. If absent, the system will continue using the current supported-locale detection flow. Unsupported or malformed values will still normalize to English. 40 + 41 + **Why:** This preserves the existing localization model while adding one explicit author-controlled override point. 42 + 43 + **Alternatives considered:** 44 + - Let browser locale override the form setting: rejected because it does not solve the reported inconsistency. 45 + - Remove browser locale detection entirely: rejected because visitor-language localization remains desirable for forms without an override. 46 + 47 + ### 4. Keep the saved value constrained to supported locales 48 + The builder setting and persistence layer will only allow `null`, `en`, or `ru` initially. 49 + 50 + **Why:** The app already validates locale support centrally. Reusing that contract keeps runtime behavior predictable and avoids publishing forms with unusable locale values. 51 + 52 + **Alternatives considered:** 53 + - Allow arbitrary BCP-47 locale codes for future-proofing: rejected because the application only ships `en` and `ru` resources today. 54 + 55 + ## Risks / Trade-offs 56 + 57 + - [Creators may expect form content itself to be translated] → Mitigation: label the setting as interface language for respondent UI, not content translation. 58 + - [Existing public-form locale loading may have multiple entry points] → Mitigation: centralize override-aware resolution in the shared public-form loading/i18n path instead of duplicating precedence logic. 59 + - [Stored invalid values from manual edits or future bugs could break rendering] → Mitigation: normalize persisted locale override through the existing supported-locale fallback helper. 60 + - [Previewing draft behavior may differ from published behavior if override is not included in all serialization paths] → Mitigation: update both creator draft payloads and public form payload generation to carry the same field. 61 + 62 + ## Migration Plan 63 + 64 + - Add a nullable persisted locale override field or config value with default `null` for existing forms. 65 + - Treat missing values on existing records as `Use visitor's language`. 66 + - Rollback is low risk: code can ignore the field and continue using current locale detection, with existing saved `null`/supported values remaining harmless. 67 + 68 + ## Open Questions 69 + 70 + - None currently. The product intent is clear enough to proceed with a form-level override using existing supported locales.
+27
openspec/changes/archive/2026-04-15-add-form-locale-override/proposal.md
··· 1 + ## Why 2 + 3 + Public form runners currently localize respondent-facing interface strings from browser or system locale. That can produce a mixed-language experience when a creator builds a form in one language but the respondent's device prefers another, especially for labels such as navigation, validation, progress, and completion UI. Adding a per-form locale override makes respondent-facing copy consistent with the language the creator intends for that form. 4 + 5 + ## What Changes 6 + 7 + - Add a form-level setting that lets creators choose whether a form's respondent-facing interface uses `Use visitor's language` or forces a specific supported locale. 8 + - Persist the selected locale override as part of form metadata so draft and published forms keep the same respondent-language behavior. 9 + - Apply the form-level locale override on public form routes so runner UI, validation messages, progress text, and completion-state interface strings render in the configured locale. 10 + - Keep supported-locale fallback behavior intact so invalid or unsupported saved values resolve safely to English. 11 + 12 + ## Capabilities 13 + 14 + ### New Capabilities 15 + - None. 16 + 17 + ### Modified Capabilities 18 + - `conversational-form-builder`: creators can configure a per-form respondent interface locale override in form settings. 19 + - `anonymous-form-runner`: public form routes resolve respondent-facing UI locale from the form override before using browser locale detection. 20 + - `site-localization`: locale resolution supports a saved form-specific override for public form surfaces while preserving supported-locale fallback rules. 21 + 22 + ## Impact 23 + 24 + - Affected code: creator form settings UI, form serialization and persistence, public form route loading, i18n locale resolution, and respondent runner rendering. 25 + - APIs: form metadata payloads and public form data loading will include the saved locale override. 26 + - Data model: forms need a persisted locale override field or equivalent config value. 27 + - Dependencies: no new external dependencies expected.
+12
openspec/changes/archive/2026-04-15-add-form-locale-override/specs/anonymous-form-runner/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Public form runner honors form-specific interface locale overrides 4 + The system SHALL resolve respondent-facing interface strings for a public form from the form's saved locale override when one is configured. When no override is configured, the system SHALL continue using the existing automatic locale-detection flow. 5 + 6 + #### Scenario: Respondent opens a form with a Russian locale override 7 + - **WHEN** a respondent opens a published form whose saved respondent interface locale override is `ru` 8 + - **THEN** the public runner renders navigation, validation, progress, and completion interface strings in Russian regardless of the respondent's browser locale 9 + 10 + #### Scenario: Respondent opens a form with no locale override 11 + - **WHEN** a respondent opens a published form that has no saved respondent interface locale override 12 + - **THEN** the public runner resolves interface strings using the existing automatic locale-detection behavior
+16
openspec/changes/archive/2026-04-15-add-form-locale-override/specs/conversational-form-builder/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can configure respondent interface locale per form 4 + The system SHALL allow an authenticated creator to configure a form-level respondent interface locale in form settings for any accessible form. The setting SHALL support a `Use visitor's language` option and any currently supported explicit locale override. 5 + 6 + #### Scenario: Creator uses visitor-language mode 7 + - **WHEN** an authenticated creator leaves the respondent interface locale setting on `Use visitor's language` 8 + - **THEN** the system saves the form without an explicit locale override 9 + 10 + #### Scenario: Creator selects a supported explicit locale 11 + - **WHEN** an authenticated creator chooses a supported locale such as English or Russian in form settings and saves 12 + - **THEN** the system persists that locale as the form's respondent interface override 13 + 14 + #### Scenario: Creator reopens form settings with an existing locale override 15 + - **WHEN** an authenticated creator opens settings for a form that already has a saved respondent interface locale override 16 + - **THEN** the system shows the saved override as the current selected value
+20
openspec/changes/archive/2026-04-15-add-form-locale-override/specs/site-localization/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Supported locale resolution falls back to English 4 + The system SHALL support `en` and `ru` locales initially and SHALL normalize unsupported locale values to English. For public form surfaces, the system SHALL honor a saved supported form-level locale override before using browser or user-state locale detection. 5 + 6 + #### Scenario: Supported locale is requested 7 + - **WHEN** the active locale is `en` or `ru` 8 + - **THEN** the system renders strings from that supported locale resource set 9 + 10 + #### Scenario: Public form has a supported locale override 11 + - **WHEN** a public form surface is loaded for a form whose saved locale override is `en` or `ru` 12 + - **THEN** the system uses that saved form locale for rendering instead of browser locale detection 13 + 14 + #### Scenario: Unauthenticated user has a supported browser locale 15 + - **WHEN** an unauthenticated creator or respondent opens the app and no form-level locale override applies while the browser locale resolves to a supported locale 16 + - **THEN** the system uses that supported browser locale for rendering without requiring sign-in 17 + 18 + #### Scenario: Unsupported locale is requested 19 + - **WHEN** the system receives an unsupported locale value from user state, OAuth data, environment detection, or a saved form override 20 + - **THEN** the system falls back to English as the active locale
+17
openspec/changes/archive/2026-04-15-add-form-locale-override/tasks.md
··· 1 + ## 1. Form metadata and persistence 2 + 3 + - [x] 1.1 Add a nullable respondent interface locale override field to form metadata/domain types with supported-locale validation and `Use visitor's language` as the no-override/default mode. 4 + - [x] 1.2 Include the locale override in form settings load/save flows and any form serialization used by the builder. 5 + - [x] 1.3 Add or update tests covering persisted form locale override normalization and round-tripping. 6 + 7 + ## 2. Creator settings UI 8 + 9 + - [x] 2.1 Add a form settings control that lets creators choose `Use visitor's language` or a supported explicit respondent interface locale. 10 + - [x] 2.2 Ensure the builder restores the saved selection when reopening form settings and persists changes through the existing save action. 11 + - [x] 2.3 Add UI tests covering selecting, saving, and reloading the form locale override. 12 + 13 + ## 3. Public locale resolution 14 + 15 + - [x] 3.1 Update public form loading/i18n resolution so a saved form locale override takes precedence over automatic locale detection on respondent-facing routes. 16 + - [x] 3.2 Ensure public runner strings, validation copy, progress text, and completion UI all read from the resolved override-aware locale. 17 + - [x] 3.3 Add tests covering explicit override precedence, `Use visitor's language` fallback behavior, and unsupported-value fallback to English.
+11
openspec/specs/anonymous-form-runner/spec.md
··· 142 142 #### Scenario: Respondent views a formatted question description 143 143 - **WHEN** a published form contains a question description with supported Markdown 144 144 - **THEN** the runner shows the formatted description consistently with the shared authored Markdown rules 145 + 146 + ### Requirement: Public form runner honors form-specific interface locale overrides 147 + The system SHALL resolve respondent-facing interface strings for a public form from the form's saved locale override when one is configured. When no override is configured, the system SHALL continue using the existing automatic locale-detection flow. 148 + 149 + #### Scenario: Respondent opens a form with a Russian locale override 150 + - **WHEN** a respondent opens a published form whose saved respondent interface locale override is `ru` 151 + - **THEN** the public runner renders navigation, validation, progress, and completion interface strings in Russian regardless of the respondent's browser locale 152 + 153 + #### Scenario: Respondent opens a form with no locale override 154 + - **WHEN** a respondent opens a published form that has no saved respondent interface locale override 155 + - **THEN** the public runner resolves interface strings using the existing automatic locale-detection behavior
+15
openspec/specs/conversational-form-builder/spec.md
··· 129 129 - **WHEN** an authenticated creator leaves the follow-up link label or URL empty 130 130 - **THEN** the system does not save or expose a partial follow-up link for the form 131 131 132 + ### Requirement: Creator can configure respondent interface locale per form 133 + The system SHALL allow an authenticated creator to configure a form-level respondent interface locale in form settings for any accessible form. The setting SHALL support a `Use visitor's language` option and any currently supported explicit locale override. 134 + 135 + #### Scenario: Creator uses visitor-language mode 136 + - **WHEN** an authenticated creator leaves the respondent interface locale setting on `Use visitor's language` 137 + - **THEN** the system saves the form without an explicit locale override 138 + 139 + #### Scenario: Creator selects a supported explicit locale 140 + - **WHEN** an authenticated creator chooses a supported locale such as English or Russian in form settings and saves 141 + - **THEN** the system persists that locale as the form's respondent interface override 142 + 143 + #### Scenario: Creator reopens form settings with an existing locale override 144 + - **WHEN** an authenticated creator opens settings for a form that already has a saved respondent interface locale override 145 + - **THEN** the system shows the saved override as the current selected value 146 + 132 147 ### Requirement: Builder reflects the active workspace context 133 148 The system SHALL show the active workspace context when a creator creates or edits a form so they can tell whether the form belongs to their personal workspace or an organization. 134 149
+7 -3
openspec/specs/site-localization/spec.md
··· 12 12 - **THEN** the system can render that surface in the additional language without requiring the component to hardcode translated copy 13 13 14 14 ### Requirement: Supported locale resolution falls back to English 15 - The system SHALL support `en` and `ru` locales initially and SHALL normalize unsupported locale values to English. 15 + The system SHALL support `en` and `ru` locales initially and SHALL normalize unsupported locale values to English. For public form surfaces, the system SHALL honor a saved supported form-level locale override before using browser or user-state locale detection. 16 16 17 17 #### Scenario: Supported locale is requested 18 18 - **WHEN** the active locale is `en` or `ru` 19 19 - **THEN** the system renders strings from that supported locale resource set 20 20 21 + #### Scenario: Public form has a supported locale override 22 + - **WHEN** a public form surface is loaded for a form whose saved locale override is `en` or `ru` 23 + - **THEN** the system uses that saved form locale for rendering instead of browser locale detection 24 + 21 25 #### Scenario: Unauthenticated user has a supported browser locale 22 - - **WHEN** an unauthenticated creator or respondent opens the app and the browser locale resolves to a supported locale 26 + - **WHEN** an unauthenticated creator or respondent opens the app and no form-level locale override applies while the browser locale resolves to a supported locale 23 27 - **THEN** the system uses that supported browser locale for rendering without requiring sign-in 24 28 25 29 #### Scenario: Unsupported locale is requested 26 - - **WHEN** the system receives an unsupported locale value from user state, OAuth data, or environment detection 30 + - **WHEN** the system receives an unsupported locale value from user state, OAuth data, environment detection, or a saved form override 27 31 - **THEN** the system falls back to English as the active locale 28 32 29 33 ### Requirement: Missing translations are surfaced during build
+2
prisma/migrations/20260415132310_add_form_respondent_locale/migration.sql
··· 1 + -- AlterTable 2 + ALTER TABLE "Form" ADD COLUMN "respondentLocale" TEXT;
+1
prisma/schema.prisma
··· 95 95 completionMessage String @default("") 96 96 completionLinkLabel String? 97 97 completionLinkUrl String? 98 + respondentLocale String? 98 99 showProgress Boolean @default(true) 99 100 slug String @unique 100 101 status FormStatus @default(DRAFT)