# Release Notes

> What shipped in StringLane, newest first. The desktop app and the free stringlane CLI ship on separate version lines, so every entry names its product.

Source: https://stringlane.app/release-notes

## Desktop v1.6.0 — 2026-10-21

Find and fix an i18next count with no plural forms from the Issues panel, fewer Same as source warnings for names and terms, and plural forms a translation holds under a single source string kept.

### Added
- **A warning for an i18next count with no plural forms.** i18next uses a key's `_one`
  and `_other` forms only when they exist, so `"{{count}} genres"` reads "1 genres", and
  in Russian "1 жанров". The Issues panel now shows **Count without plural forms** on
  such a key in your source language. A label that shows the number apart from the
  words, such as `Found: {{count}}`, is not marked.
- **Add plural forms from the Issues panel.** A **Count without plural forms** row has an
  **Add plural forms…** button. Write the forms your source language needs, such as
  `one` and `other` in English, and StringLane adds them to that key's source file.
  Unsaved edits are saved first, and nothing is written if saving fails. No AI is used.
  The dialog reads `{{name}}` and `{{- name}}` placeholders, and refuses a form that
  drops one or writes it the other way. A note then lists the forms each other language
  still needs.

### Improved
- **Fewer "Same as source" warnings for names and terms.** When at least 3 of your
  languages keep a value identical to your source, and they are at least half of the
  languages that have the key, the Issues panel lists it as **Same as source** at info
  level, not as a warning. The cell keeps its **Same** badge, whose tooltip says how many
  languages kept the value, and its rail no longer turns amber, so names and terms such
  as `SSH` or `DLC` no longer crowd out the language that has a real gap. A value that
  reads as a sentence, such as "Link copied to clipboard", stays a warning. A regional
  variant of your source language, such as British English next to English, does not
  count.
- **Issue messages read as plain sentences.** Validation findings, "not for translation"
  tooltips and the hints shown when no locale files are found use commas, colons and
  full stops where they used long dashes. The wording is otherwise the same.

### Fixed
- **i18next: plural forms a language holds under a single source string are kept.** If
  your source holds `"nGenres": "{{count}} genres"` and a translation holds only
  `nGenres_one`, `nGenres_few` and so on, i18next shows those forms when your app passes
  `count`. StringLane reported the key as missing and offered it to Translate all missing
  as one string. Those forms are now that language's translation: each is checked
  against the source text, a missing form is reported by name, the cell is locked, and
  the forms count toward completion. Edit them in the JSON file.

---

## CLI v0.9.0 — 2026-10-21

Find and fix an i18next count with no plural forms with `check`, `stringlane pluralize` and the MCP `pluralize_key` tool, names and terms most languages keep reported as info, and the agent plugin for Cursor and Gemini CLI.

### Added
- **`check` warns on an i18next count with no plural forms.** i18next uses a key's
  `_one` and `_other` forms only when they exist, so `"{{count}} genres"` reads
  "1 genres", and in Russian "1 жанров" and "2 жанров". `check`, `validate` and the MCP
  server report such a key, `{{- count}}` included, as `count_without_plural`, a
  warning, once on your source language. A label that shows the number apart from the
  words, such as `Found: {{count}}`, is not reported. **Like any warning, it makes
  `check` exit `1` until you add the forms or record it in your baseline.**
- **`stringlane pluralize` gives a key its plural forms.**
  `stringlane pluralize --key item_count --one "You have {{count}} item" --other "You have {{count}} items"`
  replaces the key's single string with the plural forms your base language needs, in
  the base file only, and lists what each other locale still owes. It shows the file and
  the forms and asks first; with no terminal it needs `--yes`, and `--json` without
  `--yes` prints the change and writes nothing. It reads `{{name}}` and `{{- name}}`, and
  refuses a form that drops a placeholder or writes it the other way. It works in
  namespaced i18next projects. Then run `stringlane update` or `stringlane plan` to
  translate the new forms.
- **The MCP server has a `pluralize_key` tool.** The same fix for your agent: it sends
  the key and the base language's forms, StringLane writes them to the base file and
  lists what each locale still owes, and `prepare_translation_plan` then offers it.
  There is no preview, so the call writes at once and the tool is marked destructive;
  review the forms in the diff. The server now has eleven tools, six of which write.
  i18next projects only. The write guard's prompt for a hand edit of a locale file names
  `pluralize_key` too.
- **The agent plugin installs in Cursor.** It brings the MCP server, the commands, the
  skills and the write guard. Cursor's hooks cannot ask, so the guard refuses a hand
  edit to a locale file and names the tool to use instead. Its Cursor Marketplace
  listing is not live yet: until it is, copy `plugins/stringlane` from a clone of
  `thebedcoder/stringlane-plugin` into `~/.cursor/plugins/local/stringlane` and reload
  the window.
- **Gemini CLI installs the plugin from its URL.**
  `gemini extensions install https://github.com/thebedcoder/stringlane-plugin` now
  works, with no clone: each plugin version is a release on that repository, and
  Gemini's update check follows those releases.

### Improved
- **A name or term most of your languages keep is info, not a warning.** A translation
  identical to your source is `untranslated`, and on real projects most of these are
  names and terms kept on purpose, such as `SSH` or `DLC`. When at least 3 of your
  languages keep the same value, and they are at least half of the languages that have
  the key, `check` and the MCP server report it at info level and say how many kept it.
  A value that reads as a sentence, such as "Try again.", stays a warning. A language of
  your source's own family, such as `en-GB` under `en`, does not count. The code is the
  same, so your baseline still matches and `--fail-on untranslated` still fails on it.
  On nine public projects, `untranslated` warnings went from 8,742 to 3,129.
- **Output reads as plain sentences.** Help text, reports, refusals, what the MCP tools
  return, the agent rules block, the comments `init` writes into `stringlane.yaml`, the
  header `add-locale` writes into a new `Localizable.strings`, and the skills
  `setup skills` writes use commas, colons and full stops where they used long dashes.
  The meaning is the same.

### Fixed
- **i18next: plural forms a language holds under a single source string are kept.** If
  your source holds `"nGenres": "{{count}} genres"` and a translation holds only
  `nGenres_one`, `nGenres_few` and so on, `check` reported `missing_key`, `plan` offered
  the key as one string, and `apply` wrote it by deleting every form. Now each form is
  checked against the source text and only a form the language lacks is reported, by
  name. `inspect` counts the forms, `read_translations` returns them, and `plan` and
  `update` no longer offer the key, so `apply` keeps the forms.
- **`check` and `inspect` say what their completion percentage counts.** The line above
  the table read `86% complete` while its rows, which count keys, added up to 89%. The
  percentage counts strings, each plural form being one, and now reads
  `86% of strings filled`. `--json` is unchanged.
- **Gemini CLI: the plugin's MCP server connects without a global `stringlane`.** It
  showed "Disconnected" unless the CLI was installed globally. It now runs the plugin's
  own launcher, which falls back to `npx`.
- **On Cursor and Codex, the plugin's write guard names the right off-switch.** Its
  refusal said to run `stringlane setup hooks --host <host> --remove`, which cannot
  remove a hook the plugin ships. It now points to Cursor's **Customize** settings or
  Codex's `/hooks`.

---

## Desktop v1.5.1 — 2026-10-15

Flutter checks that match what gen-l10n builds, ARB saves that keep your layout and plural variable, i18next ordinals checked by ordinal rules, and a missing French or Spanish millions form shown as a warning.

### Improved
- **A missing `many` form in French, Spanish, Italian, Portuguese or Catalan is a
  warning.** These languages use `many` only for counts like 1,000,000, so the Issues
  panel now marks a translation without it as a warning that says so, not an error. It
  still counts against completion and is still offered for translation. In Ukrainian,
  Russian, Polish and Arabic, where `many` covers counts like 5 or 11, it stays an error.
- **Two new checks for Flutter projects.** **Plural form overridden** marks a plural
  branch Flutter never shows, because the plural holds both `=1` and `one` (or `=0` and
  `zero`, `=2` and `two`). **Not in the source** marks a key that a translation file
  holds and your source file does not. Your source language is no longer counted as
  missing such a key.
- **Flutter: an error for a plural form Flutter cannot build.** Flutter builds only `=0`,
  `=1` and `=2` as exact plural forms, and `flutter gen-l10n` fails on a file that holds
  any other, such as `=3`. The Issues panel now shows **Plural form Flutter rejects** on
  that form, in every language including your source.
- **Fewer false warnings in Flutter projects.** A British or American English file next
  to an English source is no longer marked untranslated, and two keys whose source text
  differs only in capital letters, such as "Open File" and "Open file", are no longer
  reported as sharing one translation.

### Fixed
- **i18next ordinal plurals use ordinal forms.** A key written as `place_ordinal_one`,
  `place_ordinal_two` and so on is an ordinal, which i18next picks by each language's
  ordinal rules: 1st, 2nd, 3rd in English. The Issues panel, the completion percentage,
  the **Add form** chips and AI translate now use those forms, so a correct Italian
  `many` and `other` pair no longer shows a missing `one`, and English is asked for `two`
  and `few`. Your file keeps its `_ordinal_` spelling.
- **AI translate is told when an i18next key is an ordinal.** Your AI provider is now
  given the language's ordinal forms, each one a position (1st, 2nd, 3rd), instead of its
  ordinary plural forms. Italian `many` is asked for as the 8th, 11th and 80th, not as
  the form for exact millions.
- **Flutter: a plural translated in StringLane keeps your source's variable.** A plural
  added to a language through the plural editor or AI translate was saved as
  `{count, plural, …}` even when your source used another name, such as
  `{n, plural, …}`, and `flutter gen-l10n` then reported `count` as unknown. It is now
  saved with your source's variable.
- **Saving a Flutter ARB file keeps its blank lines.** A save no longer removes the blank
  lines between key groups or adds `"@@locale"` to a file that did not have it.
- **The width bar measures an inline plural by its longest form.** A correct translation
  of a plural with text around it no longer shows a red bar because several forms were
  measured together.

---

## CLI v0.8.0 — 2026-10-15

Flutter plural checks that match what gen-l10n builds, ARB files that keep their layout, i18next ordinals checked by ordinal rules, and a missing French or Spanish millions form reported as a warning instead of an error.

### Added
- **`check` warns when Flutter never shows a plural branch.** A Flutter plural that
  holds both `=1` and `one`, `=0` and `zero`, or `=2` and `two` has one dead branch:
  Flutter treats each pair as one form and keeps the later one. `check`, `validate` and
  the MCP server report it as `icu_plural_selector_overridden`, a warning, on the branch
  that is never shown.
- **`check` fails on a Flutter plural form that stops `flutter gen-l10n`.** Flutter
  builds only `=0`, `=1` and `=2` as exact plural forms, and a plural with any other,
  such as `=3{three items}`, makes `flutter gen-l10n` fail on the whole file. It is
  reported as `icu_plural_selector_unsupported`, an error, in every language including
  your source, and `apply` refuses a translation that adds one. **It is an error, so
  `check` exits `1` on such a project after you upgrade.**
- **Keys that only a translation holds are reported.** A key in a translation file but
  not in your source file is reported as `orphan_key`, at info level, in each language
  that holds it. Add it to the source, or remove it from the translation.

### Improved
- **A missing `many` that only exact millions use is a warning.** In French, Spanish,
  Italian, Portuguese and Catalan, `many` is used only for counts like 1,000,000, so a
  first `check` could show hundreds of errors for counts the app almost never shows. It
  is now a warning that says so, under the same code, so your baseline still matches
  it. It still counts against completion and `plan` still offers it: with i18next, a
  count of 1,000,000 shows your fallback language's text, not your `other` form. In
  Ukrainian, Russian, Polish and Arabic, where `many` covers counts like 5 or 11, it is
  still an error.
- **Each language's progress is counted in keys.** `check` and `inspect` counted plural
  forms, so a language that needs more forms had a larger total. Every row now counts
  keys out of the same total, and `--json` adds `totalKeys`, `filledKeys` and
  `missingKeys` to each row.
- **A plan keeps a Flutter plural's exact zero text.** `plan` also offers `=0`, `=1` or
  `=2` where Flutter can show them, such as `=0` in Ukrainian, so "No notifications" is
  no longer lost. It does not offer `=1` to a language that has a `one` form.

### Fixed
- **i18next ordinal plurals are checked against ordinal forms.** `place_ordinal_one`,
  `place_ordinal_two` and the rest are now checked against each language's ordinal forms
  (1st, 2nd, 3rd in English), not its ordinary plural forms. A correct Italian
  `_ordinal_many` and `_ordinal_other` pair is no longer reported as missing `one`.
  **`check` may report new errors for English ordinals without `_ordinal_two` or
  `_ordinal_few`, and exits `1` until you add them.**
- **`stringlane update` asks for an i18next ordinal's own forms.** For a key like
  `place_ordinal_*`, the AI prompt listed the language's ordinary plural forms and called
  Italian `many` the form for exact millions. It now names the language's ordinal forms,
  each one a position (1st, 2nd, 3rd), so Italian `many` is asked for as the 8th, 11th
  and 80th. Prompts for other plural keys are unchanged.
- **`apply` keeps the layout of a Flutter ARB file.** It keeps the blank lines between
  key groups, adds no `@@locale`, and puts each new key where your source file has it.
- **Your source language is no longer counted as missing keys that only a translation
  holds.**
- **A British or American English file is not called untranslated.** A regional variant
  of your source language in the same script no longer gets `untranslated`.
  Traditional Chinese next to a Simplified source still does.
- **`reused_translation` ignores case.** "Open File" and "Open file" are one meaning.
- **An inline plural is measured by its longest form.** `expansion_budget_exceeded`
  compares the longest form each side shows, not the whole ICU text.

---

## Desktop v1.5.0 — 2026-10-14

See which translations are out of date and confirm the ones that still fit, iOS .stringsdict variants kept and editable, and Android regional folders read the way Android reads them.

### Added
- **See which translations are out of date, whoever wrote them** — Turn on **Track
  stale translations** in **Settings → General → Source stamps**. StringLane records which
  base text each translation was confirmed against, in `.stringlane/source-stamps.json`.
  When a base text changes, its translations get an amber **Stale** badge with a note
  showing the base text **Was** and **Now**; a translation nobody confirmed yet is
  **Unconfirmed**. Editing a translation confirms it when auto-save writes it, and
  **Accept translation** confirms one that still fits as it stands — in an Xcode string
  catalog it also sets a unit marked for review to `translated`. Filter the sidebar to
  **Stale or unconfirmed** to find them. The same file works with `stringlane check` and
  `stringlane accept`, so your team and your CI share one record.

### Improved
- **iOS: a warning for the width format Apple ignores** — A `.stringsdict` width entry
  under a format variable (`%#@w@`) is not resolved by Apple's Foundation: the app shows
  `%#@w@`, and can crash. The Issues panel now shows **Width format Apple ignores** on each
  language that has one. Move the widths directly under the key with
  `NSStringVariableWidthRuleType`.

### Fixed
- **iOS `.stringsdict`: device and width variants are kept, and device variants are
  editable** — Apple's own formats for device variants and width variants were not read,
  and the next save deleted them. StringLane now shows one row per device (with **Add
  device** chips) and per width, checks them, and saves them back in the same format. An
  entry with both, or with a value that is not text, is kept read-only and marked
  **UNSUPPORTED**. A comment above a key in both `.strings` and `.stringsdict` is kept too.
- **iOS: a plural or width variant in a language's `.stringsdict` is that language's
  translation** — not a missing key, and typing in the cell no longer deletes it. The forms
  show read-only and the cell is locked; edit them in Xcode.
- **A damaged iOS plurals file is reported** — a `.stringsdict` broken by a git conflict
  or a cut-off write is a load error naming the file, and StringLane never writes over it.
- **String Catalogs** — keys with no English entry show their own text as English and
  count in every language; stale strings no longer count as missing, get no source
  warnings, and are not added to a new language; a language that varies a string by
  plural on its own is no longer missing, and its forms show read-only.
- **Android regional folders** — `values-en-rGB/`, `values-fr-rCA/` or `values-b+es+419/`
  owe only the strings they define. Inherited strings show the parent language's text
  instead of **Missing**, and are no longer offered to the AI.
- **Android** — new regional languages get a folder Android reads (`values-fr-rCA/`,
  `values-b+zh+Hant/`), `b+` folders are checked for every plural form their language
  needs, and Canadian French gets the Canadian spacing rule.
- **Renaming a plural key keeps its plural forms.**
- **Tab and Enter on plural and select rows no longer get stuck** when the next language
  does not show the row.
- **The key info button is highlighted only when a key has metadata you wrote.**
- **Swift: keys used in SwiftUI are no longer reported unused** — every string in your
  Swift code counts as a possible use of a key, string interpolation included.
- **Format specifiers alone are copied, not translated** — `%lld` or `%.2f` is no longer
  sent to the AI.
- **Strings not for translation no longer lower the completion percentage**, and the
  Translate… popover's "missing" count matches what it translates.
- **Flutter: a plural spelled differently in a translation** is no longer reported
  missing, and Translate all locales no longer replaces it.
- **i18next** — a `path:` in `stringlane.yaml` opens that folder alone, and opening
  `src/i18n` or a `locales` folder directly finds its languages.
- **Keys named `constructor`, `toString` or `__proto__`** are checked, translated and
  kept like any other key.

---

## CLI v0.7.0 — 2026-10-14

Know which translations are out of date with source stamps and `stringlane accept`, Swift usage that finds every key, and iOS and Android layouts read the way the platforms read them.

### Added
- **Know which translations are out of date, whoever wrote them** — Add
  `sourceStamps: true` to `stringlane.yaml`, run `stringlane accept . --all` once, and
  commit `.stringlane/source-stamps.json`. In a new project that loads cleanly,
  `stringlane init` offers to do this for you. `check` then reports `translation_stale`
  when a translation's base text changed since it was confirmed, and
  `translation_unconfirmed` when nobody confirmed it. Both are warnings, so `check` exits
  `1`. `plan`, `update` and the MCP server offer a stale translation again with the text
  it was confirmed against, and `apply` and `update` record what they write. Without the
  flag, every command works as before.
- **`stringlane accept` confirms translations you checked** — `--all` confirms every
  translation; `--key`, `--locale` and `--quantity` select some. It shows the counts per
  locale and asks first, and needs `--yes` without a terminal. There is no MCP tool for
  it, on purpose: confirming a translation is a person's decision.
- **Translations Xcode marks for review stay visible until you confirm them** — With
  `sourceStamps: true`, `check` reports a `.xcstrings` translation marked for review, and
  `plan` offers it again. Confirming it with `accept`, or writing it with `apply`,
  `update` or the MCP server, marks it `translated` in the catalog in the same write.
  `accept --all` and `init` leave these alone.
- **A baseline cannot hide a stale translation** — `baseline write` leaves out
  `translation_stale` and `translation_unconfirmed`. `check` refuses a baseline that
  holds either code and exits `2`.
- **The write guard asks before an agent edits `.stringlane/source-stamps.json`**, as it
  does for `stringlane.yaml`. A shell `stringlane accept --yes` is not visible to the
  guard: review the file in pull requests.

### Improved
- **Swift: every string in your code counts as a possible use of a key** — A key passed
  to your own views, returned from a `case` or given as a labelled argument was
  `unused_key`. On three public apps the unused keys went from 802 to 9, 872 to 12 and
  278 to 108, and a possible use never reports a key as undefined.
- **iOS: `check` warns on the width format Apple ignores** — A `.stringsdict` width
  entry under a format variable (`%#@w@`) is reported as `framed_width_variant`. **It is a
  warning, so `check` exits `1` on such a project after you upgrade.**
- **The MCP server sends a bounded list of stale and unconfirmed translations** — at most
  50 of each, with a `STAMP_ISSUES_TRUNCATED` warning. `issueCounts` keeps the true
  totals, and `stringlane check --json` gives the full list.
- **The code usage scan stays fast on files built to slow it down** — Each file's work
  is limited in proportion to its size, so a crafted file in a cloned repository can no
  longer stall `scan` or `check`.

### Fixed
- **Android regional folders are checked for what they override** — `values-en-rGB/`,
  `values-fr-rCA/` or `values-b+es+419/` owe only the strings they define, as Android
  reads them. One public app had 8,059 false `missing_key`.
- **`check` asks Android `b+` folders for every plural form their language needs.**
  **These are errors, so `check` may fail on a project that passed before.** Canadian
  French folders also get the Canadian spacing rule.
- **`add-locale` writes the folder Android reads** — `fr-CA` now creates `values-fr-rCA/`
  and `zh-Hant` creates `values-b+zh+Hant/`.
- **iOS `.stringsdict`: device variants, Apple's width format, and entries with both
  are kept** — `apply` no longer deletes them; device and width variants are read,
  checked and translated, and an entry StringLane cannot edit is kept as it is and no
  longer reported missing. A comment above a key in both `.strings` and `.stringsdict`
  is kept too.
- **iOS: a `.stringsdict` plural or width variant under a `.strings` string is the
  translation** — not a missing key that `apply` would overwrite.
- **A damaged `.stringsdict` is a load error** that names the file, instead of loading
  with no plurals or both sides of a git conflict.
- **String Catalogs** — keys with no English entry are checked and translated, stale
  strings owe no translation and get no source warnings, a language that varies a
  string by plural on its own is no longer missing, and `add-locale` adds no stale
  entries and counts only the keys the language needs.
- **ARB: a plural spelled differently in a translation** is no longer reported missing,
  and `plan` and `update` no longer offer to replace it.
- **Swift: SwiftUI keys are no longer `unused_key`, and interpolated keys are no longer
  `undefined_key`.**
- **Format specifiers alone are copied, not translated** — `%lld` or `%.2f` is no longer
  sent to the model.
- **`inspect` coverage no longer counts strings not for translation as missing.**
- **A `path:` in `stringlane.yaml` loads that folder alone**, and i18next projects with
  one folder per language load from `src/i18n` or the `locales` folder itself.
- **MCP** — `read_translations` returns a translation held in the other plural shape,
  and the usage warning names a tool the server has.
- **Telemetry** — Turning usage data off with `DO_NOT_TRACK=1`,
  `STRINGLANE_TELEMETRY=0` or in CI no longer creates an install ID, and
  `stringlane telemetry status` no longer creates one either. `telemetry on` no longer
  suggests a variable that does nothing.

---

## Desktop v1.4.1 — 2026-10-07

Flutter apostrophes read the way Flutter reads them, and no more false ICU errors in regional locales such as en_GB or pt_BR.

### Fixed

- **No false Param badge on a quoted Flutter placeholder** — A Flutter string like
  `Remove '{name}' from your profile?` was read as having no placeholder, so every
  translation that kept `{name}` showed a Param badge, and one that really dropped it
  showed none. Flutter treats `'` as ordinary text unless your `l10n.yaml` sets
  `use-escaping: true`, so `{name}` there is a placeholder with quote marks around it.
  StringLane now reads it the same way, and follows `use-escaping: true` when your project
  sets it. An apostrophe right before a brace, as in `other{the divers'}` or French
  `l'{name}`, no longer shows an ICU error; a plural written that way opens with one row
  per form and saves with your apostrophes intact; the select and ICU editors open it; and
  AI translation no longer refuses a translation that keeps a quoted `{name}`.
- **ICU messages in regional locales are no longer reported as syntax errors** — In a
  locale whose code names a region, such as `en_GB`, `pt_BR` or `zh_CN`, every plural or
  select written inside a string was reported as **ICU syntax error**, even when it was
  correct. The same happened in Android locales such as `values-zh-rCN`, and in Flutter's
  `en_XA` pseudo-locale. A correct message is no longer reported. A message with a real
  syntax mistake still is.

---

## CLI v0.6.1 — 2026-10-07

check reads ARB apostrophes the way Flutter does, no false icu_parse_error in regional locales, and update handles bad plural answers without stopping.

### Fixed
- **`check` reads apostrophes in ARB files the way Flutter does** — A value like
  `Remove '{name}' from your profile?` was read as having no placeholder, so every
  translation that kept `{name}` was reported as `placeholder_mismatch — extra: name`, and
  a translation that really dropped a quoted placeholder passed. Flutter's `gen_l10n`
  treats `'` as ordinary text unless `l10n.yaml` sets `use-escaping: true`, so `{name}`
  there is a placeholder with quote marks around it. `check` now reads it that way, and
  follows `use-escaping: true` when your `l10n.yaml` sets it. An apostrophe right before a
  brace, as in `other{the divers'}` or `one{l'{name}}`, is no longer an `icu_parse_error`;
  such a plural is checked for its forms and select branches; and `update` and the MCP
  server no longer refuse an AI translation that keeps a quoted `{name}`.
- **`icu_parse_error` no longer fires for every ICU message in a regional locale** — In a
  locale whose code names a region, such as `en_GB`, `pt_BR` or `zh_CN`, every plural or
  select written inside a string was reported as `icu_parse_error` with *"RangeError:
  Incorrect locale information provided"*, so `check` failed on correct projects. The
  same happened for Android locales such as `values-zh-rCN` and for Flutter's `en_XA`
  pseudo-locale. A correct message is no longer reported; a real syntax mistake still is.
- **`update` no longer writes a literal `#` into Flutter plurals** — When the model
  answered an ARB plural with `#` for the count, as in `# Artikel`, `update` wrote it as
  is, and Flutter shows `#` rather than the number. It now writes the plural's own
  variable, such as `{count} Artikel`, the way the desktop app already did.
- **One bad plural answer no longer stops `update`** — When the model answered a plural
  with a placeholder the key does not have (such as `{anzahl}`) or a broken brace,
  `update` and the MCP server refused to write any translation in the run. That key is
  now left untranslated and named, and every other translation is written.

---

## Desktop v1.4.0 — 2026-10-05

Inline plurals get the forms each language needs, saves keep what is already in your files, and you choose how many AI requests run at once.

This release is about plurals and about not losing what is already in your files. ICU
plurals inside a longer string are now checked and translated with the forms
the target language actually uses, and a long list of saves that could drop a key, a
line ending, a catalog or a setting now keep them.

### Added

- **Choose how many AI requests run at once** — **Settings → AI → Concurrent requests**
  sets how many requests bulk translate, Translate all locales and bulk metadata send at
  a time, from 1 to 8. The default stays 2. Raise it if your provider plan allows more
  requests per minute.

### Improved

- **Inline plurals are checked for the forms each language needs** — A plural with text
  around it, such as `You have {n, plural, one {# item} other {# items}} left`, was only
  checked for an `other` form. A Polish, Russian or Ukrainian translation without `few`
  and `many` is now reported as **Missing plural category**, as is a French, Spanish,
  Italian, Portuguese or Catalan one without `many`. Fix with AI adds exactly the missing
  forms. These are errors, so existing projects may see new ones after updating.
  easy_localization projects are not asked for `few` and `many`.
- **Inline plurals translate with the target language's own forms** — Translating such a
  plural into Polish, Russian, Ukrainian, Czech, Arabic and similar languages used to keep
  English's `one` and `other`, and a translation that added the missing forms was refused.
  The AI is now told which forms the language needs, and a translation that adds exactly
  those is accepted.
- **No AI cost for values with nothing to translate** — A value made only of placeholders,
  numbers or punctuation, such as `{name}` or `—`, is copied without an AI call, and is no
  longer reported as untranslated. Text in braces that reads as words, such as
  `{Feature Name}`, is still translated.
- **The ICU editor opens more messages in Build mode** — A plural or select with
  placeholders beside it now opens in Build mode, with the surrounding text shown and kept
  exactly as it was. A plural `offset:` is no longer dropped on save.
- **easy_localization and slang projects are checked by their own rules** — Placeholders
  are checked in each package's own syntax (`{}`, `{name}`, `$name`), and a nested map of
  plural forms is one plural key, checked for the forms each language needs and saved
  back where it was.

### Fixed

- **Edits save to your files again** — In 1.3.0 no edit reached disk: every change stayed
  in the app and the status bar showed **Error saving**. Dragging a single
  translation file onto the window also opens its project folder again.
- **Quitting right after an edit no longer loses it** — Quit now saves first, and asks
  **Quit Without Saving** or **Keep Open** if an edit cannot be saved.
- **Changes made outside StringLane show up in every project layout** — A `git pull` or
  another editor changing a file in `lib/l10n/`, an `.lproj` folder, `res/values-*` or
  `locales/` now shows up by itself. Projects combining several modules or namespaces
  still ask before saving rather than reloading.
- **A change made outside StringLane is no longer overwritten when you had a cell open** —
  Typing into a cell that was opened before the file changed on disk now stops and asks,
  instead of saving the old text over the change.
- **String Catalogs keep every language** — Removing a language from an `.xcstrings`
  project deleted the whole catalog. It now saves the catalog without that language.
  Deleted keys and removed languages also stay gone instead of coming back on reopen.
- **Removing a language from an iOS `.strings` project removes its `.stringsdict`** too,
  and lists it in the confirmation.
- **Saves keep Windows line endings and a byte order mark** — A file is saved with the
  line endings and BOM it had, and files that start with a BOM now open in every format.
- **`=1` counts as the "one" form where it covers it** — A Flutter-style
  `=0 / =1 / other` plural is no longer reported as missing `one` in German, Polish,
  Spanish and similar languages.
- **ICU messages with quote escapes are left exactly as written** — Editing a plural
  quantity, a select branch or applying the `#` fix no longer turns `'{'Name'}'` into a
  placeholder or a broken message.
- **Flutter plurals with markup in a form stay plurals** — An ARB plural with a tag such
  as `<b>` in one form opens with one row per form again.
- **i18next files no longer lose keys** when a new key shares a name with a dotted nesting
  level, and a capitalised plural form such as `items_ONE` is kept on save.
- **Android text that looks like an XML entity is kept** — `&amp;lt;` no longer opens as
  `<` and saves back changed.
- **Changing a project setting keeps the rest of `stringlane.yaml`** — Fields the app does
  not know, such as `defaultNamespace:`, are left as they were, and an unreadable file is
  not overwritten.
- **Namespaced i18next projects no longer show live keys as unused** — `t('home.title')`
  now counts as a use of the key in the default namespace.
- **A symlinked file in a namespaced i18next project is no longer followed** — it is
  listed in the load errors instead.
- **Your licence is checked again each time the app starts** — in the background; if the
  server cannot be reached you keep working.
- **The usage-data switch no longer calls the data anonymous** — it now reads **Share
  usage data** and says how the device ID is derived. What is sent has not changed.

---

## CLI v0.6.0 — 2026-10-05

Inline plurals checked and translated with each language's own forms, translations re-offered when their source changes, and writes that keep your files exactly as they were.

### Added
- **`stringlane update --parallelism <1-8>`** — Sets how many AI requests run at once. The
  default stays 2. `STRINGLANE_AI_PARALLELISM` sets it for every run, and the flag wins. A
  value outside 1–8 exits `2` before anything is sent.
- **`update` says how many items replace an existing translation** — The spend question
  says how many items overwrite a translation whose source or context changed, and with
  `--yes` the report says it after the write. `--json` gains `retranslatedCount`.

### Improved
- **`plan` offers a translation again when its source text changed** — Edit the English,
  a description or other context after an `apply`, and `plan` now offers that cell again
  as `source-changed` or `context-changed`, instead of saying every locale is complete. A
  cell somebody re-translated by hand is left alone. This works for translations applied
  by 0.6.0 or later: one applied by 0.5.0 carries no record of the text it wrote, so it is
  left alone as before. Empty it and run `plan` and `apply` again to bring it under the
  new rule.
- **Inline plurals are checked for the forms each language needs** — A plural with text
  around it, such as `You have {n, plural, one {# item} other {# items}} left`, was only
  checked for `other`. A Polish, Russian or Ukrainian translation without `few` and `many`
  is now an `icu_missing_plural_category` error, as is a French, Spanish, Italian,
  Portuguese or Catalan one without `many`. easy_localization projects are not asked for
  `few` and `many`. A Flutter-style `=1` now counts as the `one` form wherever it covers
  it. These are errors, so `check` may fail after you upgrade on a project that passed
  before; the printf check below can do the same. Fix the findings, or re-record your
  baseline with `stringlane baseline write`.
- **Inline plurals translate with the target language's own forms** — `update` and the MCP
  server used to leave such a plural untranslated when the model correctly added `few` or
  `many`. The model is now told which forms the language needs, and an answer that adds
  exactly those is accepted.
- **One broken AI answer no longer stops the run** — An answer that drops a placeholder or
  a plural branch is left untranslated and named, and every other translation is written.
  Before, one such value made the whole run refuse to write.
- **No AI request for values with nothing to translate** — A value made only of
  placeholders, numbers or punctuation, such as `{name}` or `—`, is copied without a
  request, and `check` no longer calls the copy untranslated.
- **`check` compares the types in printf placeholders** — In `.strings`, `.xcstrings` and
  Android XML, `%d items` against `%s Elemente` used to pass. It crashes on Android and is
  undefined behaviour on iOS, and is now a `format_placeholder_mismatch`.
- **`plan` is several times faster on projects with product context.**

### Fixed
- **Ctrl+C during a write no longer leaves a half-finished save** — `apply`, `add-key`,
  `add-locale`, `describe` and the MCP server's writing tools now finish the write, say so,
  and then stop.
- **Writes keep your files as they were** — CRLF line endings and a UTF-8 byte order mark
  are kept, an Xcode String Catalog keeps Xcode's layout, i18next files no longer lose keys
  next to a dotted nesting level or a capitalised plural form, and Android text such as
  `&amp;lt;` is no longer changed.
- **Files that start with a byte order mark load in every format** — Before, ARB, i18next
  JSON and Android XML files with one were reported as malformed, an `.xcstrings` catalog
  stopped the project from loading, and a `.stringsdict` loaded with no plurals.
- **Multi-module projects no longer get one module's file filled with every module's
  strings** — `describe` and the MCP tool `describe_keys` now refuse a Gradle multi-module,
  multi-`.lproj` or catalog-plus-`.lproj` project and write nothing. Restore the affected
  file from version control if this happened to you.
- **`apply` writes an iOS `.strings` file as `.strings` in a project that also has a String
  Catalog** — It wrote JSON into it before. Restore the affected `Localizable.strings` and
  run `plan` again if this happened to you.
- **`apply` accepts a Flutter plural translation that contains markup**, such as `<b>`.
- **`plan` offers a cell again after you undo an `apply`** with `git checkout`.
- **`same_plural_forms` no longer fails a correct German project** — Languages whose only
  forms are `one` and `other` are not compared.
- **A `stringlane.yaml` without `path` no longer hides a namespaced i18next project**, and
  a symlinked file in such a project is reported as a load error instead of being read.
- **The write guard asks before an agent creates a new language's `.stringsdict`.**
- **`setup hooks` no longer offers `--apply` on hosts where it writes nothing** — On Codex,
  Cursor, Gemini CLI, Windsurf and VS Code it tells you to paste the document instead.

---

## CLI v0.5.0 — 2026-09-28

StringLane's agent skills on every host, and easy_localization and slang projects checked by their own rules.

### Added
- **`stringlane setup skills` gives agents without a StringLane plugin the plugin's skills** — Codex, Cursor, Gemini CLI, GitHub Copilot in VS Code, Windsurf, Zed and OpenCode can now use the skills the Claude Code plugin comes with, for adding a new string, translating a locale and reviewing the result. `--apply` writes them into your repository under `.agents/skills/`, one `stringlane-*` folder each, so a team working in several editors shares one committed copy. StringLane's host table records all seven hosts as reading that folder, but none has yet been confirmed doing so in a recorded session, so check that the skills show up in yours.
- **Installed skills stay current, and edited ones are left alone** — Every file is stamped with the CLI version that wrote it. Running `--apply` again after an upgrade replaces a stale copy. A copy somebody edited, or one a newer CLI wrote, is left alone, so a teammate a release behind cannot downgrade it. `--remove` deletes only unedited copies. `stringlane check` prints one line on stderr when your copies are older than the CLI, and its exit code and `--json` output are unaffected.

### Fixed
- **easy_localization and slang projects are checked by their own rules** — `0.4.0` opened these projects but checked them by i18next's rules, so `check` reported no errors for a translation that dropped an easy_localization `{}` or `{name}`, a slang `$name`, or a Ukrainian plural's `few` and `many`. StringLane now works out which package a project uses from `pubspec.yaml` and checks placeholders in that package's syntax. A nested map of plural forms, such as `"apples": {"one": …, "other": …}`, is now one plural key, checked for the forms each language needs. i18next projects are unchanged.
- **easy_localization's `few` and `many` forms are no longer required** — easy_localization does not show them unless you set `ignorePluralRules: false`. A missing one no longer counts against completion, is not asked for by `plan` or `update`, and is reported at **info** level. Pass `--fail-on missing_key` if your app turns the rules on.
- **Expect `check` to go red on upgrade for some easy_localization and slang projects** — The findings are real, and StringLane was not checking for them before. A baseline entry that named a plural form as `apples.other` no longer matches, because that form is now the `other` form of `apples`. Fix the findings, or re-record with `stringlane baseline write`.
- **The licence no longer says the CLI sends nothing** — Version 1.1 of the licence covers three things:
  - the usage data the CLI sends and how to turn it off;
  - the daily npm update check, which `stringlane telemetry off` does not stop but `STRINGLANE_NO_UPDATE_CHECK=1` does;
  - the text `stringlane update` sends to the AI provider you configure.

  The CLI's behaviour has not changed.

---

## CLI v0.4.1 — 2026-09-25

StringLane's MCP server is listed in the Official MCP Registry.

### Added
- **The MCP server is in the Official MCP Registry** — StringLane is listed as `io.github.thebedcoder/stringlane`, so an MCP client that installs from the registry runs `npx @stringlane/cli mcp .` — the same server `stringlane setup mcp` and the agent plugin configure. Nothing changes for an install you already have.

---

## Desktop v1.3.0 — 2026-09-24

Saving is safe alongside the CLI and your other tools — no silent overwrites, no lost edits. slang and easy_localization projects open, and your source language sticks.

This release is mostly about saving. StringLane writes to your source files as you
type, and until now it assumed nothing else was writing to them. That assumption stopped
holding once the free `stringlane` CLI — and AI agents using it — could write to the same
project you have open, and it was never true of a `git pull` or another editor. The app
now takes its turn, notices when a file changed underneath it, and asks rather than
guessing which version you meant to keep.

The rest widens what opens with no setup: Flutter projects that keep their translations
as JSON, Android apps whose default strings are not English, and single-module Gradle
projects in an unusual place.

### Added

- **A file that changed on disk is never saved over behind your back** — If a locale file
  changed outside StringLane — a `git pull`, another editor, the `stringlane` CLI — while
  you had unsaved edits to it, the app could write your edits over the new version, or
  replace your edits with it, without a word. It now stops and asks, naming the file:
  **Reload from disk** reads the new version and drops your unsaved edits to that file
  (edits to other files are saved first), and **Keep my edits** writes yours over it.
  Close the dialog and nothing is written or lost — the status bar says "File changed on
  disk" until you decide, and clicking it brings the question back.

- **Saving waits its turn instead of failing when the CLI is writing** — If the CLI, or an
  AI agent using it, was writing to the project you had open, the app's save could land at
  the same moment or show "Error saving" for something that was working fine. It now waits
  for the other write to finish and saves your edits by itself; past a few seconds the
  status bar says "Waiting for another StringLane write…". If you quit, switch or close
  the project while edits still cannot be saved, the app asks first instead of dropping
  them.

- **A save lock left by a crashed session is recovered, and a stuck one can be cleared** —
  If a StringLane process stopped while holding a project's save lock, the app takes it
  over, saves, and says so in the status bar, so you know an earlier write may not have
  finished. When a save has been waiting on another process for a few seconds, the note
  offers **Clear lock** — after a confirmation that warns what clearing a live lock can do.
  Temporary staging files left by a crashed save are cleaned up too; `.restore.tmp` files,
  which may hold an original, are kept.

- **slang projects that use namespaces now open** — `widgets_en.i18n.json`,
  `errorDialogs_fr.i18n.json` and slang's older `strings_en.i18n.json` naming open with no
  setup, including a namespace kept in its own folder, and edits save back to each
  namespace's own file. StringLane follows slang's own `namespaces:` setting, so keys read
  the way you call them. Adding a language creates one file per namespace.

- **Your source language can be written down, and it sticks** — StringLane guesses a base
  locale: English if you have it, otherwise the first alphabetically. Changing it in
  Settings → General used to last only until you closed the project. It is now saved to
  `stringlane.yaml` as `baseLocale:`, and the CLI and MCP server read the same value — so
  the app and `stringlane check` in your pipeline can no longer measure completion against
  two different source columns. Name a locale your project does not have and StringLane
  falls back to guessing, and tells you which one it used.

- **An Android project whose `values/` folder is not English can say so** —
  `res/values/strings.xml` names no language, so StringLane assumed English and loaded a
  German app's strings into the `en` column. Set `defaultFileLocale: de` in
  `stringlane.yaml` and the unnamed file loads as German. A `values-fr/` folder or a
  `tools:locale` attribute still wins.

### Improved

- **easy_localization and slang projects just open** — StringLane looked only for `.arb`
  in Flutter projects, so one that keeps its translations as JSON got "No localization
  files found". It now opens easy_localization (`assets/translations/`, `assets/langs/`)
  and slang (`lib/i18n/`, `assets/i18n/`) with no configuration, nested keys read as
  `example.hello`, and the unused-key scan understands both packages' call styles.

- **Unused keys stop flagging live Flutter strings** — The scan now follows an accessor you
  bind once and use bare (`final l10n = AppLocalizations.of(context)!;` … `l10n.save`),
  any getter name off a `BuildContext` extension, and `S.current.save` — the form you use
  where there is no `BuildContext`, in validators, repositories and services. Keys called
  only from those places were listed as not referenced, and that is the list people act on
  by deleting. What it still cannot see is written on the Unused Keys page. The first scan
  after updating re-reads your source rather than reusing what it remembered.

### Fixed

- **The bulk-translate summary now adds up** — Translated, skipped and failed were counted
  independently, so a four-key run could report "Translated 3 of 4 keys. 4 errors." — or,
  when a batch gave up quietly, no error count at all. Every key is now accounted for
  exactly once.

- **Cancelling a bulk translate is no longer a dead end** — The summary now offers
  **Re-run** whenever keys are left over. It retries only what is outstanding, so it never
  spends your API budget twice on keys that already landed.

- **The trial day-count stops going stale** — Left open across midnight, the banner kept
  the previous day's number. It now stays current, and reaching zero mid-session never
  closes your editor.

- **An Android project with one module in an unusual place opened empty** — A single Gradle
  module under `mobile/`, `shared/` or `core/ui/` now opens on the first try, with keys
  exactly as written and the path saved to `stringlane.yaml`.

---

## CLI v0.4.0 — 2026-09-24

The write guard holds on Codex and reaches Windsurf, easy_localization and slang projects open, stringlane.yaml can name your source language, and an empty plan says which kind of empty it is.

The write guard is the headline. On Codex it let every patch through, and on Windsurf
there was none; both now refuse a hand-edit to a locale file and tell the agent how to
lift the refusal. The rest widens what opens with no setup — Flutter projects that keep
their translations as JSON, Android apps whose default strings are not English, a single
Gradle module in an unusual place — and stops `stringlane plan` calling an unfinished
project finished.

**If your CI branches on exit codes, read the `apply` entry under Fixed.** One situation
that used to exit 1 now exits 2.

### The write guard

- **On Codex, the guard now holds** — The plugin gave Codex the hook it gives Claude Code,
  which looks for a file path Codex's patches do not carry, so every edit was allowed; and
  where it did recognise a locale file it answered with an "ask" Codex does not support.
  Codex now runs its own guard, which **refuses** a hand-edit to a locale file and tells
  the agent how to lift it. Codex runs a plugin's hooks only after you trust them: open
  `/hooks` in Codex and trust the StringLane hook.

- **Windsurf gets one** — `stringlane setup hooks --host windsurf` prints a hook for
  Windsurf (now Devin Desktop) that refuses a hand-edit to a locale file, says why, and
  names the command that removes it. Paste it exactly as printed.

- **`setup mcp` now says the guard is a second step** — On every host but Claude Code and
  Codex, installing the server does not install the guard, and nothing said so. Every
  `setup mcp` report now ends with the command that does: `stringlane setup hooks --host
  <host>`, or `stringlane setup guard --host zed|opencode`.

### Telemetry

- **The notice no longer calls the data anonymous** — Every event carries a random ID
  generated on your machine, so runs from one install can be linked together. The
  first-run notice now says so, and `stringlane telemetry status` shows the ID. Your
  strings, keys and file paths are still not part of it.

- **More CI systems switch it off** — Only `CI` was checked, and Azure Pipelines,
  TeamCity, AWS CodeBuild, Jenkins and Bamboo do not set it. `TF_BUILD`,
  `TEAMCITY_VERSION`, `CODEBUILD_BUILD_ID`, `JENKINS_URL`, `bamboo_buildKey` and
  `BUILDKITE` now switch off telemetry and the update check too.

### Added

- **easy_localization and slang projects just open** — `check`, `inspect` and the MCP
  server looked only for `.arb` in a Flutter project, so one that keeps its translations
  as JSON got "No localization files found". They now open easy_localization
  (`assets/translations/`, `assets/langs/`) and slang (`lib/i18n/`, `assets/i18n/`) with
  no configuration, nested keys read as `example.hello`, and `stringlane scan`
  understands both packages' call styles. A project with both `.arb` files and a JSON
  folder still opens as ARB.

- **slang namespaces too** — `widgets_en.i18n.json`, `errorDialogs-fr.i18n.json` and one
  folder per namespace open with no setup. `add-locale` creates one file per namespace,
  and the agent hook stops an agent creating one by hand.

- **`stringlane.yaml` can name your source language** — StringLane guesses a base locale:
  English if you have it, otherwise the first alphabetically. Write `baseLocale: uk` and
  `check`, `plan`, `update` and the MCP server measure against it instead. The desktop app
  writes the same field, so the app and your pipeline can no longer disagree about which
  column is the source. Codes are spelled the way your files spell them (`zh-rCN`,
  `en_GB`, `en-us`). Name a locale the project does not have and StringLane falls back to
  guessing, with a `base_locale_not_loaded` warning naming the one it used.

- **An Android project whose `values/` folder is not English can say so** —
  `res/values/strings.xml` names no language, so StringLane loaded a German app's strings
  as `en`. Set `defaultFileLocale: de` and the unnamed file loads as German. A
  `values-fr/` folder or a `tools:locale` attribute still wins.

- **Reports say whether the base locale was declared or guessed** — `stringlane inspect`
  and `stringlane check --json` carry `baseLocaleSource`, `"declared"` or `"detected"`,
  beside `baseLocale`.

### Improved

- **Flutter keys your code calls stop being reported as unused** — The scan now follows
  an accessor you bind once and use bare (`final l10n = AppLocalizations.of(context)!;` …
  `l10n.save`), any getter name off a `BuildContext` extension, and `S.current.save` — the
  form you use where there is no `BuildContext`, in validators, repositories and services.
  Keys called only from those places were listed as not referenced, and that is the list
  people act on by deleting. The first scan after upgrading re-reads your source instead
  of reusing its cache — one slower run, once.

- **`--help` stops telling you to pass a folder to commands that refuse one** —
  `upgrade` and the three `telemetry` commands no longer print `[project]` in their usage
  line.

### Fixed

- **`apply` and `validate` refuse a plan that names a file your project does not
  manage** — A `plan.json` lists every file the apply must check for changes, and `apply`
  opened each one and said in its refusal whether it matched — so a plan edited to name
  `.env` could ask about `.env`. It never wrote to those files. Both commands now refuse
  such a plan **before opening anything**, which matters most when `apply` runs in CI on a
  branch you did not write. Plans StringLane produced are unaffected. One ordinary case
  now reports differently: build a plan, then delete, rename or break a locale file, and
  you get `STALE_PLAN` with exit **2**, where some of those cases used to exit **1** with a
  per-item problem. Re-run `plan`.

- **`stringlane plan` stops telling you a project is finished when it is not** — An empty
  plan has six meanings and two of them printed the wrong one: a locale file that failed
  to parse produced *"this project has no locale but its base"*, and a language missing
  one module's file in a multi-origin project produced *"Every target locale is
  complete."* Each of the six now says what is wrong and what to do next. `plan --json`
  and the MCP server's `prepare_translation_plan` carry the verdict as `emptyReason`, so
  neither a script nor an agent has to work it out from counts.

- **`stringlane add-locale` put new files in the wrong folder for some i18next
  projects** — With namespaces under a single `locales/` folder, the new language's files
  landed at the project root, where nothing reads them. They now land in
  `locales/<code>/`.

- **Namespaced i18next projects reported live keys as missing and dead** — `t('home.title')`
  is now matched in the default namespace: `translation`, or whatever you set as
  `defaultNamespace:` in `stringlane.yaml` (next-i18next projects use `common`).

- **An Android project with one module in an unusual place loaded as empty** — A single
  Gradle module under `mobile/`, `shared/` or `core/ui/` is now found, with keys exactly
  as written.

---

## Desktop v1.2.0 — 2026-09-16

Ten new checks, per-branch editing for ICU select, and a list of the keys your code no longer calls. The app and the free stringlane CLI now run the same engine.

This release is mostly about the app knowing more about your project than what is in
the locale files. It reads your source code now, so it can tell you which keys nothing
calls. It checks ten things it did not check before. And the parts of a string it could
not previously show you — the branches of an ICU `select`, the wording around an
ordinal — are now editable one branch at a time.

Underneath, the app and the free `stringlane` CLI run the same engine commands. That is
invisible if you only use the app, and it is what stops the terminal and the project
window from growing two different ideas of what a valid translation is.

### Added

- **Unused keys** — Open any key and the detail panel now says **used in N places**, with
  the file and line of each call site; **Show all** opens the rest. The sidebar has a
  **Show only unused** filter, and an **Unused** chip stays visible while it is on so you
  always know the list is filtered. It reads Dart, JavaScript and TypeScript, Swift and
  Objective-C, Kotlin and Java.

  Two things to know, because they are the difference between a useful list and a
  dangerous one. **"Not referenced" is what the scan saw, not a verdict** — a key built
  at runtime (`t('errors.' + code)`), reached through a wrapper StringLane does not
  recognise, named only in a file type it does not read, or read straight out of the
  locale file by the platform will appear in that list and still be live. And a project
  StringLane has not read yet says **not scanned**, never "used in 0 places": those two
  look identical and mean opposite things. The scan starts after your project opens, so
  it never delays the editor.

- **Selectors view** — ICU `select` and `selectordinal` used to render as one raw string
  you edited as a whole, braces and all. Each branch is now its own row, the way plural
  categories already were, so you translate `{gender, select, male{…} female{…}
  other{…}}` one branch at a time without retyping the syntax around it. Ordinal
  categories come from the target language's own CLDR rules, so a language needing
  `few` for 3rd and `other` for 4th is shown both.

  This also fixed a case where your translation was invisible: a locale whose plural
  held nested ICU rendered an empty block, so the text you had written did not appear
  at all.

- **Ten new checks.** The Issues panel gained: trailing and leading whitespace that
  differs from the source; two consecutive spaces; a zero-width character that arrived
  by copy-paste; a word repeated consecutively; trailing punctuation that differs from
  the source, including the per-language rules (French needs a non-breaking space before
  `!` and `:`); the same source string translated two different ways in different keys;
  one translated string reused for two different source strings; two plural categories
  in a language that requires them to differ carrying identical text; a base string
  using unnamed positional placeholders such as `%@`, which a language with different
  word order cannot reorder; and a base string that already exceeds its own length
  limit, so every translation will too.

  Three of those fire on things that are frequently correct, so they are reported at a
  new quieter **info** level rather than as warnings.

- **HTML and XML markup is checked** — a dropped closing tag inside a value, or one the
  source did not have, is now a validation error rather than something you find in the
  running app.

- **A length budget** — a translation much longer than a translation of that source
  usually needs is flagged at info level, so a string that will break your layout is
  visible before you ship it. This is separate from the length limit you set yourself,
  which is still an error when exceeded.

### Improved

- **Guarded words are a term-level rule now, not a whole-string freeze.** A key
  containing a guarded word had no correct translation: the AI prompt told the model to
  keep the term verbatim *inside* a translated sentence, and the validator then flagged
  the sentence for not being identical to the base. Worse, a guarded word silently
  switched off the other checks on that key — a base of `Download for {os}` translated
  with a broken placeholder reported the guarded word and never mentioned the
  placeholder. A guarded term must now appear in the translation in the base's own
  spelling, and every other check on the key keeps running. To freeze a whole key, mark
  it not-for-translation as before.

- **Plural categories come from CLDR itself.** The table of which categories each
  language requires is now generated from the CLDR data rather than transcribed by hand,
  so a language whose requirements were recorded wrong is no longer asked for the wrong
  set of forms.

- **Adding a language to a multi-origin project writes one file per origin**, in a
  single change: either the locale exists everywhere it should or it exists nowhere.
  It used to create one file and leave the rest of the project behind.

- **The app and the CLI share one engine.** Every project command the app runs is now
  the same code path `stringlane check` runs. Three defects turned up in the app while
  doing it and were fixed.

### Fixed

- **Editing one plural form no longer resurrects the old single-value text.** A key that
  had been a plain string and later became a plural could keep both on disk, and the
  stale single value could win on the next save.

- **Two validation codes that could never fire have been removed** — `json_syntax_error`
  and `placeholder_count_mismatch` were declared but nothing produced them. If either is
  in a `--fail-on` list in your CI, take it out: that gate has been permanently green.

### Also new, and free

The `stringlane` CLI is published, and so is the agent plugin. Same engine, same
validation, no licence: [the CLI](/product/cli) runs the whole loop from a terminal or
CI, and [eight coding agents](/docs/cli/agent-hosts) can run it through the Model Context
Protocol. Seven of those eight also get a write guard that gets in the way before one of
them hand-edits a locale file — what that guard can actually do differs per host, because
it is built from whatever each host lets StringLane intercept.

The app licence and the CLI are separate in both directions — neither grants the other.

---

## CLI v0.3.2 — 2026-09-16

Packaging only. The npm page gained a header, a link row and a version badge. Nothing in the tool changed.

The npm page for `@stringlane/cli` now opens on a header image, a row of links to the docs it
refers to, and a badge showing the published version, rather than on a bare `# stringlane`.

That is the whole release. Comparing the `cli-v0.3.1` and `cli-v0.3.2` tags, the only things that
moved are the README, a continuous-integration shell script with its test, the documentation in
the repository, and the version number. **No file that ships in the npm package changed except
`package.json`**, so the command surface, the checks, the MCP tools and the write guard are all
exactly what the v0.3.0 notes below describe.

There is no reason to upgrade from `0.3.1`. If you are pinning a version in CI, `0.3.2` and
`0.3.1` will behave identically.

---

## CLI v0.3.1 — 2026-09-16

A packaging fix. The README npm renders still said the package was not published to npm, and gave an install command that could not work. The tool itself is unchanged.

This is the page you are most likely to have read first, and it was wrong.

The README that npm renders still carried the note written before there was a release —
_"Not published to npm yet"_ — followed by the only install instruction on the page:
`npm pack` in a source tree you do not have, installing a `0.1.0` tarball that was never
published. Anyone who found the package before finding this site was told to do something
impossible.

Install is now what it should have been:

```sh
npm install -g @stringlane/cli
```

with `npx @stringlane/cli check .` for the no-install path, and a CI snippet that installs
from npm.

**Nothing about the tool changed.** The two bundles differ by exactly one byte — the
version string the binary prints — so there is no reason to upgrade beyond the page now
telling the truth. Everything in the v0.3.0 notes below applies unchanged.

---

## CLI v0.3.0 — 2026-09-16

The first public release of the free stringlane CLI and the agent plugin. The engine the desktop app is built on, in your terminal, your CI, and your coding agent.

The `stringlane` command line is public, and it is free. No licence, no account, no
sign-in — the app licence and the CLI do not grant each other anything in either
direction.

It is built on the same engine as the desktop app — one implementation of the parsers,
the validation rules and the completion figures, called from both surfaces rather than
two copies that drift apart.

This is 0.3.0 rather than 1.0 on purpose: the command surface is not promised yet.

### The loop, in a terminal

`stringlane init` detects the project and writes `stringlane.yaml`. After that:

- **`inspect`** — what the project holds and what is missing, per locale.
- **`check`** — validate everything and set an exit code.
- **`plan` → `validate` → `apply`** — list what needs translating, check an agent's
  answers *without touching your files*, then write the ones that passed. The middle
  step is the point: an agent proposes, the engine decides, and nothing reaches your
  locale files unchecked.
- **`update`** — do all three at once with your own AI provider key.
- **`add-key`**, **`add-locale`**, **`describe`**, **`context`** for the edits that are
  not translations.

### A gate for CI

`stringlane check` exits **0** when there are no issues and **1** when the project has
them. The other two exist because a broken gate and a failing gate are not the same
event: **2** means the command never got far enough to have an opinion, and **3** means
the command was fine but something it depends on did not answer.

A CI step that treats every non-zero exit the same way will eventually report a
misconfigured runner as a localization failure, which is how a gate stops being read.

`--fail-on` narrows which issue codes set exit 1. `baseline write` records today's
issues so a project with existing problems can start clean and only fail on new ones.

### Keys your code no longer calls

`stringlane scan` reads your source and reports two things: keys your project defines
that no call site names, and keys your code calls that the project never defined. The
second kind is a bug that is already shipping: the screen renders a raw key where a
sentence should be.

It knows how each framework actually calls a string, so it matches the spelling your
**runtime** uses rather than the key as StringLane spells it. Those differ in a
multi-module project, and a grep for the wrong one reports every key unused, which
looks exactly like a filthy codebase and gets acted on.

**It refuses rather than guessing.** If it read no source files, or read them and
recognised no keys at all, it says so and reports nothing as unused — "I could not read
your code" and "everything is dead" produce an identical empty list, and only one of
them is safe to act on. When it does answer, it states its own blind spot beside the
list: a key built at runtime, reached through a wrapper it does not recognise, or read
straight out of the locale file by the platform will look unused and be live.

`check` runs the scan too, at **info** level, so upgrading does not turn your CI red.
`--no-scan` skips it; `--ignore-key-pattern` and `--ignore-file` silence a family or a
directory, and silenced findings are still counted, separately by cause — a report
saying "0 not referenced" because you silenced everything must not look like a clean
project.

### For coding agents

`stringlane mcp` serves the project over the Model Context Protocol: **ten tools, five
of which write**. The five writers are declared as writers, so the approval prompt your
agent host shows before a write is the gate — StringLane does not add a second one of
its own, and there is no argument an agent can pass to skip yours.

`stringlane setup mcp` prints the config block for your host — and on Claude Code,
`--apply` writes it for you.

### The write guard, and what it can actually do per host

An agent that edits a locale file by hand bypasses every check on this page. The guard
gets in the way first — but what it can do is built from whatever each host lets
StringLane intercept, so it is not the same promise everywhere:

| Host | How you install it | What the guard does |
|---|---|---|
| Claude Code | Plugin | Asks you first |
| Codex CLI | Plugin | Asks you first |
| Gemini CLI | Extension | Refuses, and says how to lift it |
| Cursor | Config you paste | Refuses, and says how to lift it |
| Zed | Config you paste | Confirms, from Zed's own permissions |
| OpenCode | Config you paste | Confirms, from OpenCode's own permissions |
| VS Code Copilot | One command | Installs, but guards nothing yet |
| Windsurf | Config you paste | None |

"Asks you first" and "refuses, and says how to lift it" are different promises: Cursor
and Gemini CLI have no *ask* in their hook protocol, so the refusal there names the way
to lift it instead of prompting a human.

`--apply` turns a printed block into a written one. For `setup mcp` and `setup hooks` it
does that on **Claude Code alone**: on the other seven it prints the same bytes and exits
non-zero rather than merging into a config file nobody has recorded that host writing.
What you lose by pasting is the backup and the merge, not the configuration.

`setup guard` never writes, on any host. It prints a block for Zed's or OpenCode's own
permission table — the two hosts that run no hook StringLane speaks — and `--apply` there
exits 2 with the block on stderr, because both files are JSONC and a JSON writer would
drop your comments on the way back out.

`setup rules --apply` — three locale-file rules written into your own `AGENTS.md`, between
markers it can take back out — works on all eight, and `--remove` works everywhere too.

The full breakdown is in [the agent hosts guide](/docs/cli/agent-hosts).

### What it reads, and what it writes

`stringlane` writes into **your repository** only when you ask it to — `init`, `apply`,
`update`, `add-key`, `add-locale`, `describe`, `context` and `baseline write`. Each of
them asks first, and refuses when there is no terminal to ask at unless you pass
`--yes`. `inspect` and `plan` write nothing at all.
`check` and `scan` write nothing into your repository, but they do cache the source scan
in StringLane's own state directory, outside your project.

It sends usage data by default, under a random per-install ID, and prints a notice
saying so the first time you run it on a machine, before anything is sent. `stringlane telemetry off` stops it,
and so do
`DO_NOT_TRACK=1`, `STRINGLANE_TELEMETRY=0` and `CI=true`. Your strings, your keys and
your file paths are not part of it.

It also asks npm, at most once a day, whether a newer version exists, and prints three
lines on **stderr** when there is one — never on stdout, so `--json | jq` is unaffected.
It can never fail your build: offline, a proxy, a registry outage and a 500 all print
nothing and change no exit code. `STRINGLANE_NO_UPDATE_CHECK=1` turns it off, as do the
telemetry opt-outs. The write guard never checks on any path — nothing that runs on
every file an agent touches is allowed to make a network request.

### Install

```sh
npm install -g @stringlane/cli
```

Claude Code and Codex CLI take the plugin instead, which brings the MCP server and the
guard with it. Start at [the CLI docs](/docs/stringlane-cli), or hand
[the agent setup page](/docs/cli/agent-setup) to your coding agent and let it do the
whole thing.

---

## Desktop v1.1.1 — 2026-08-25

Your window reopens at the size and position you left it, whichever way you quit.

### Fixed

- **Your window opens at the size you left it** — StringLane came back full-screen-sized on every launch, whatever size the window actually was when you quit. Two things were wrong. The size was never written down at all: quitting with ⌘Q, from the menu, or by closing the window all skipped the save, so the file StringLane reads on startup had been frozen for months. And on macOS a single trip into full screen was recorded as "maximised" and then re-applied on every launch after, which is why resizing the window never seemed to stick. Both are fixed — your window's size and position are saved on every way out of the app, and on macOS StringLane restores the frame you left rather than forcing the window wide. One thing to expect: if your window still opens oversized the first time you launch this version, resize it once and quit. That size is what comes back from then on.

---

## Desktop v1.1.0 — 2026-08-21

Your files come back the way you wrote them — comments, attributes, key shapes and plural wording all survive a save.

This release is about fidelity to your source files. StringLane used to rebuild a file
from the keys and text it understood, so anything it didn't show you — a comment, an
attribute, the sentence around a plural count, the exact spelling of a key — was gone
the moment you edited an unrelated string. That is fixed across every format: a file
StringLane saves is now identical to the one it opened, apart from the translations you
actually changed.

### Added

- **Mark a key "Not for translation" from the metadata panel** — Keys that should never be translated (a build identifier, an API base URL, an internal flag) could only be marked that way by editing the file in Xcode or Android Studio. The switch is now in the metadata panel, on every format. Turning it on locks the key's cells in every language, takes it out of the Issues panel and out of every AI action, and writes the flag where your format keeps it: `shouldTranslate: false` in a String Catalog, `translatable="false"` in `strings.xml`, `x-no-translate` in an ARB file's `@key` block, and alongside your other StringLane metadata for `.strings` and i18next. Turning it off works even when the flag came from the file itself.

### Improved

#### Your files keep what you wrote in them

- **Your `Localizable.strings` keeps its comments** — Every comment was deleted the moment StringLane saved: the header Xcode wrote with your project name and copyright, the note above a key, the `genstrings` placeholder on every un-annotated line. They all survive now, and a comment you haven't edited comes back exactly as you wrote it — line breaks, indentation, `/* … */` or `// …`. A comment directly above an entry is that key's **description**: it shows in the metadata panel, you can edit it there, and your edit replaces that comment in the file. Two things to know: a description you edit is rewritten as one line, and a comment that trailed an entry on the same line moves to the end of the file.
- **Your Android `strings.xml` keeps its comments** — Same story for every `<!-- -->`: the licence header, the note above a key, the "TODO: translate these" at the bottom. A comment sitting directly above a `<string>` or `<plurals>` is that key's **description**, editable in the metadata panel and written back to the same place. A multi-line comment above a key becomes one line, so the file and the panel agree; a comment written *inside* a `<plurals>`, between its rows, comes back after the last row, because the rows are re-sorted into CLDR order on every save and no row can honestly claim it.
- **Your Android `strings.xml` keeps every attribute you wrote** — `formatted="false"`, `tools:ignore="…"` and any custom attribute on a `<string>` were deleted the moment you edited any other string in that file. They now come back out of a save exactly as they went in, in the order you wrote them, and the `xmlns:` declarations on `<resources>` come with them. One consequence you will see: a `translatable="false"` string that also carried another attribute used to be hidden from the editor entirely — that was the only way to avoid destroying the attribute — and it now appears as a locked key like any other, so key counts and completion percentages move on those projects.
- **Your `<plurals>` blocks keep their attributes and their notes too** — A `<plurals>` was rebuilt from its name and its quantities on every save, so a `tools:ignore` on the block, a `formatted="false"`, a note on one `<item>` row and the comment you wrote between two rows were all deleted. All of it comes back now, with between-row comments landing after the last row for the CLDR-ordering reason above.
- **Bold, italics and `<xliff:g>` inside an Android string survive now** — A `<string>` written as `Hello <b>%1$s</b>` loaded as `Hello ` — markup gone, text either side joined — and that is what got written back. On an AOSP-style project it was worse than cosmetic: `<xliff:g id="name">%1$s</xliff:g>` is how those files name a placeholder, so the placeholder went with it and the string came out as `Hi !` in every language. Markup now stays in the value with its attributes and nesting, and the cell shows it inline so you can move a bold run when the word order changes. Plural rows are covered too. Two things to know: the tags are text in the cell, so a `&` beside them has to be written `&amp;` — StringLane fixes a bare one rather than saving a file Android can't read — and an unclosed tag saves the whole value as plain text, visibly, rather than producing a file that won't reopen.
- **A plural form with an unusual name no longer breaks your Android file** — `strings.xml` writes each plural row as `<item quantity="…">`, and the quantity was copied straight through without escaping. A file whose quantity contained a quote character came back out of a save as XML that could not be read, so the project failed to load the next time you opened it. Rare, but unrecoverable without editing the file by hand.

#### iOS plurals stop drifting

- **Your `.stringsdict` files keep their sentence wording and number type** — An iOS plural entry can wrap its count in a sentence — `"There are %#@items@ left"` — and can count something other than a plain `Int`, using `%lld`, `%f` or `%@`. StringLane rebuilt these from a fixed template on save, so the words around the count were thrown away and the number type reset to `%d` — every time, on the first save after you edited *any* key in that language. Both are now written back exactly as they were. Together with the variable-name fix below, an iOS plural file StringLane saves is identical to the one it opened apart from the translations you changed. (Plural keys you create inside StringLane still start from the plain `%d` default.)
- **Width-adaptive iOS strings are no longer turned into plurals** — A `.stringsdict` entry can pick between wordings based on how much room is on screen rather than on a number — "BT" on a narrow label, "Bluetooth" where there's space. StringLane saved these back marked as *plural* rules, so iOS started choosing between them by grammatical number instead of by available width. These entries now keep their original rule. Note this is preservation, not editing support: StringLane still shows the width options as if they were plural forms and will flag them as incomplete, so leave them alone in StringLane and edit them in Xcode.
- **iOS plural files keep the order you wrote them in** — StringLane rewrote the entries inside a plural key in its own order. For ordinary plurals you'd never notice, but width-based entries are keyed by numbers and were silently re-sorted, so a file listing the widest option first came back with the narrowest first. Harmless to the app, but it turned every save into a diff you didn't write.
- **Plural keys stay where you put them in the file** — If your plural keys are plain numbers — a project keyed by error code, say `404` and `500` — saving moved them to the top of the file and sorted them lowest-first. Saving now keeps every key where it was, and a language file StringLane creates for you follows the same order as your base language.
- **A language you translate inside StringLane now matches the rest of your project** — When you translated a plural into a language whose `.stringsdict` didn't exist yet, StringLane created it using its own defaults — `%#@count@`, a plain `%d` — even though every other language used your wording and your number type. New files now copy those declarations from your base language, so only the translations differ.

#### Flutter and ARB

- **A plural whose counter is not called `count` keeps its name** — An `.arb` plural written as `{n, plural, one{# apple} other{# apples}}` came back out of the first save as `{count, plural, …}` whenever the key also had a `@key` block below it, which is how Flutter writes them. That renames the argument in the code `flutter gen-l10n` generates, so every call site stops compiling.
- **A nonsensical character limit in your `.arb` file no longer flags every string you have** — A `@key` block's `"x-max-length"` was trusted exactly as written. A `0`, a negative number, `24.5` or `"twenty"` — from a hand edit, a merge conflict, or another tool — made the Length check fail on every non-empty translation in every language. StringLane now ignores a limit that is not a whole number of one or more, along with a description that is not text and a translation-rule flag that is not a true/false. Your file is not corrected or rewritten.

#### i18next keys survive round trips

- **When one key is written two ways, StringLane keeps the one your app reads** — A JSON file can spell the same key in more than one way: `{"a": {"": {"b": "…"}}}` and `{"a.": {"b": "…"}}` are both the key `a..b`. StringLane kept whichever came last in the file — which is not the one i18next returns — so the editor showed a value your app never used, you could translate it, and the value your app *did* use was thrown away on the next save. It now shows and edits exactly what i18next resolves. The other spelling stays in your file, unread but never removed.
- **Your i18next file keeps the key shapes you wrote** — A key written flat, `"nav.home": "Home"`, came back out of the first save rewritten as nested `{"nav": {"home": "Home"}}`. On most projects that's an unasked-for change to your file and nothing worse. If your app sets `keySeparator: false`, it is fatal and total: nesting leaves nothing for i18next to find, so every string in that file stops resolving the first time you edit any key in it. Keys now come back spelled exactly as they went in, and a file that mixes both ways keeps both. Keys you add inside StringLane are still written nested.
- **Two spellings of one plural form no longer cost you one of them** — StringLane matches the CLDR suffix on an i18next plural key without minding its case, so an `items_ONE` written by an older tool sits in the same plural row as `items_other`. If your file held both spellings of the *same* form — `items_one` beside `items_ONE` — the row could only show one, and the other was deleted the first time you saved. Both stay now, and the row shows the lowercase spelling i18next actually resolves.
- **A key and a path through it can both survive now** — i18next lets you have `nav.home` and `nav.home.label` at the same time; both are legal. StringLane loaded both, showed both, let you translate both — then deleted whichever came second in the file on save. Both survive now: the one that can't be written as a nested branch is written flat beside it, exactly how i18next reads it back. This also closes two places where the loss happened with no edit anywhere near it: adding a language wrote the new file already missing a key, and adding a key beside an existing one could remove it.
- **An i18next key with a leading or doubled dot no longer loses its name** — A key written `".b"`, `"."`, `".."` or `"a..b"` was quietly renamed. The first save wrote it as a nested level with an empty name, and the next time you opened the project that empty level was read back as if it weren't there — so `.b` became `b`, and the original name was gone with nothing to restore it from. It took two saves, so the save that looked fine was the one that set it up. Keys with empty segments now survive any number of saves, plural keys included.

#### One home for descriptions and limits

- **A description or comment now lives in exactly one place** — StringLane kept its own copy of every description, character limit and translation-rule flag in `.stringlane/metadata.yaml`, even when your format already had somewhere to put them. Two copies meant one had to win, and StringLane's did: a comment you edited in Xcode, or a `translatable="false"` you cleared in Android Studio, could be quietly replaced the next time you opened the project. Each format now keeps whatever it can in your own file — a String Catalog its `comment` and `shouldTranslate`, `strings.xml` its `translatable="false"`, an ARB file its whole `@key` block — and `.stringlane/metadata.yaml` holds only what your format has no room for.
- **iOS and Android descriptions are your file's comment now, not a StringLane file** — Now that comments can be written back, descriptions for `.strings` and `strings.xml` projects live in the file itself, the same rule String Catalog and ARB projects already followed. A comment your teammate edits in Xcode or Android Studio is simply what you see next time you open the project. Two things to check: a description typed before this version stays in `.stringlane/metadata.yaml` and is no longer read, so it will look blank in the panel — type it again and it goes where the rest of your team can see it. And a description on an iOS **plural** key still goes to `.stringlane/metadata.yaml`, because a plural lives in the companion `.stringsdict` and that file has no comment to put it in.
- **Flutter projects no longer get a `.stringlane/` folder at all** — An `.arb` file's `@key` block already holds every field the metadata panel can set, so there was never anything for a second file to keep. Nothing new appears in your repository.
- **A key's character limit lives in one place now** — StringLane kept two copies of every max-length limit: one in your project settings file, one on the key. The settings copy won, so on an Android, iOS or i18next project a limit you typed into a key's metadata panel was saved, shown back to you, and then ignored while validation enforced the old number. Opening a project now moves any settings copy onto the key and removes it from the settings file; Settings → Key Constraints and the metadata panel are two views of the same setting, and the panel tells you which file yours is written to. One caveat: a project migrated by this version and then opened in an older one will have lost its limits.
- **The metadata panel's "Guarded" checkbox said it did something it never did** — Its description read *"prevents AI translation"*. It never prevented anything: it only warned you when a translation differed from the base text, and AI translated the key like any other. It is now called **Must not change**, and says so, with the editor badge renamed to match. If you ticked it expecting AI to leave the key alone, that is what **Not for translation** does.

#### AI

- **Text from your locale files can no longer rewrite an AI prompt** — Key names, descriptions, placeholder names and examples were pasted into the prompt exactly as they appear in the file. A newline in any of them started a new line the model read as one of StringLane's own instructions, so a locale file from a vendor, a contractor or a public repo could quietly steer what the AI wrote back into your project. They all travel as quoted data now. Your product context and branding guidelines are deliberately unchanged — those are your words from Settings, and they are meant to be instructions. One visible side effect: a description longer than 500 characters is shortened before it is sent, so a very long one can no longer crowd the source string out of the model's view. Your file keeps the full text.
- **"Fix with AI" now knows what the string is for** — Fixing a broken placeholder or malformed plural sent the model the source text, your current translation and the error, and nothing else. It never saw the key's description, its character limit or its placeholder notes — not on any format, including ARB — so a fix was written blind and could wander off the string's meaning or blow past its length limit. Those now go with every fix request, single or "Fix all".
- **AI metadata generation stopped talking about Flutter on non-Flutter projects** — When you generated descriptions for an Android, iOS, i18next or String Catalog project, StringLane introduced the task to the model as an *ARB* job and listed Dart types (`String`, `int`, `DateTime`) as the valid placeholder types. Those only mean anything in a Flutter `.arb` file, and the placeholder notes the model invented for them were thrown away on arrival — generated, billed and discarded. The prompt now asks only for what your format can actually store.
- **Bulk metadata generation skips keys you marked "Not for translation"** — A description is there to tell a translator what a string means, and nobody translates these keys. "Translate all missing" has skipped them for a while; **✦ Metadata** was still sending them to the model and charging you for descriptions no one would read. Generating for a single key from the metadata panel still works — that one you asked for.

#### Multi-package projects

- **Adding a language now reaches every package, including ones translated differently from the rest** — StringLane worked out where to put new files by copying the layout of a single existing language — whichever one appeared in the most packages. A package that didn't ship that language had nothing to copy from and silently got no file: you'd add Italian and one package would never receive it, with nothing in the dialog to say so. Each package now works out its own layout from a language it actually has, so every package you tick gets its file, and the preview in the dialog shows the same list that gets created.
- **A package written in a different base language no longer looks untranslated** — If your project mixes packages that don't share a base language — an app written in English alongside a module written in German — keys belonging to the German module had nothing to show in the base column and read as missing even though the text was there. That column now shows the module's own base-language text, greyed out and tagged with the language it came from. It is shown for information only: the key still counts as missing in your project's base language, and you can still type the base translation yourself.

### Fixed

- **AI translations now actually reach your files** — "Translate all missing" applied its results, showed them in the editor and reported success, but for any key missing from a language file entirely, nothing was written to disk. You'd reopen the project and every translation was gone, along with the AI credits you spent generating them. Nothing warned you: the run reported success and the values were visibly there until you reloaded. This affected every AI action that fills a key a language file didn't already contain, including "Fix all with AI".
- **Your Android `strings.xml` keeps everything StringLane doesn't edit** — Anything that isn't a translatable `<string>` or `<plurals>` was silently deleted the moment you edited any other string in that file: `<string-array>` lists, strings marked `translatable="false"` (API URLs, feature flags, build config), `<bool>` and `<integer>` values. They now come back out of a save exactly as they went in. StringLane still doesn't let you edit them, it just no longer throws them away.
- **Android projects whose default language isn't English now load correctly** — If your `res/values/strings.xml` is in Russian, German or any language other than English, StringLane assumed English. When the project also had a `res/values-en/` folder, both files claimed English and one quietly replaced the other — so one of your two files vanished from the editor, along with any string that only existed in it. StringLane now reads the `tools:locale` attribute from the `<resources>` tag (the same one Android Studio writes) and keeps it on save. If a base file doesn't declare a language and its assumed English clashes with a real `values-en/` folder, you get an error banner naming the file instead of silently losing it.
- **iOS String Catalogs keep their "needs review" marks** — Xcode marks a translation `needs_review` when the English text changed after it was translated — the signal that tells you what has drifted, and the main reason to use a String Catalog. Saving from StringLane relabelled *every* one of those as "translated", across every language, after you edited a single unrelated key. Xcode's own "what still needs translating" view was wrong from then on, with no way to get the marks back. StringLane now only changes a translation's state when you actually change that translation.
- **The Needs Review badge shows up** — String Catalog entries awaiting review looked identical to finished ones in the editor; the amber **Needs Review** badge could never appear for a real catalog. It now does.
- **Strings Xcode marked as not-for-translation are no longer counted as errors** — A String Catalog key set to "don't translate" was reported as a missing translation in every language while the editor correctly locked those cells. The error count was inflated by entries working exactly as intended, your project could never reach 100%, and the bulk AI actions offered to translate them. They're now skipped by the checks and left out of the bulk actions.
- **Multi-target iOS projects no longer lose comments and settings in `.xcstrings` files** — In a project with more than one catalog (an app plus a framework or extension), saving rebuilt each catalog from scratch, wiping your string comments, Xcode's extraction state, and keys marked "don't translate" — the last of which disappeared from the file entirely. Every catalog now keeps its own original contents, and saving only updates the translations you changed.
- **Multi-target iOS projects no longer reformat your `.strings` files** — If a `.strings` file used unquoted keys (`greeting = "Hello";`), saving a project with more than one iOS target rewrote every key into the quoted form, producing a large and pointless diff. Each file's original style is now preserved, matching what single-target projects already did.
- **Your `.stringsdict` files keep their own plural variable name** — If an entry used a variable other than `count` — `%#@items@`, `%#@photoCount@` — saving rewrote it to `%#@count@`. Nothing broke in Xcode, but the name you chose was gone and every save showed up as a diff in source control. Each entry is now written back with the variable name it came with.
- **iOS plural files that use two variables in one string now open, and are never damaged** — An entry can combine two plurals in one sentence — "3 files and 2 folders", where *files* and *folders* each have their own wording. StringLane's plural editor models one variable per key, so it read just the first, ignored the second, and then — the next time you edited anything in that language — saved the file back with the second variable **deleted**, silently and permanently. StringLane now reads the whole entry and keeps it: the file opens normally, every other key stays editable, and the two-variable entry appears with an **UNSUPPORTED** badge and a read-only view pointing you to Xcode. It is written back exactly as you wrote it whenever the file saves, left out of your completeness percentage, raises no missing-translation issues, and is never sent to AI. You can delete or rename it from StringLane; to change its wording, edit it in Xcode. As a bonus, plural entries written with numbered variables (like `%1$#@count@`) now load correctly — they used to be invisible in the editor and were deleted on the next save.
- **A broken translation file is no longer wiped out when you save** — If one of your translation files couldn't be read when the project opened — a stray comma in `de/billing.json` — StringLane told you about it, then quietly emptied that file the next time you edited *anything else* in that language, replacing it with `{}`. A one-character mistake turned into the whole namespace gone. StringLane now leaves unreadable files strictly alone: fix the file, reopen the project, and everything is still there. If you type a translation into a key that lives in an unreadable file, the save indicator turns red and stays red rather than pretending it saved. (Only i18next namespaced projects were affected.)
- **Editing a translation file StringLane created for you now keeps saving** — If a language was missing one of your namespace files — you have `en/billing.json` but no `de/billing.json` — StringLane created it the first time you translated a key into it, exactly as it should. But every save after that turned the save indicator red and never reached disk: StringLane mistook the file it had just written for one it had failed to read. Your second and later edits to that namespace were lost until you reopened the project. Files StringLane creates now save normally from then on.
- **Saving no longer creates empty translation files for namespaces a language doesn't use** — StringLane rewrote *every* one of a language's files each time you edited any of them, and where a file didn't exist yet it got created containing `{}`. So a project that ships `billing.json` only in English would sprout an empty `billing.json` in German, French and every other language the first time you saved. A file is now only created once you actually put a translation in it. Files that already exist still save normally, including when you delete the last key in one, and adding a new language still creates all of its files as before.
- **Adding a language to packages StringLane can't place it in now tells you** — In a multi-package project, if StringLane couldn't work out where a new language's file belonged in *any* of the packages you picked, it added the language anyway. You'd get a new empty column that never saved and no explanation. It now says it couldn't place the language instead of adding one that can't work.
- **Picking packages for a new language now selects the one you clicked** — In a project with two packages whose folder names differ only by punctuation — `web-app` and `web.app` side by side — clicking a package's *name* in the Add or Remove Language list ticked the first of the two instead of the one you clicked. Clicking the checkbox itself always worked, so the wrong package could be selected without you noticing. Each row is now its own control.
- **Renaming a key and pressing ⌘T straight away now translates the new name** — If you typed a new name into the key title and then pressed **⌘T** (Ctrl+T on Windows/Linux) instead of Enter, StringLane renamed the key correctly but then translated the *old* name, which no longer existed. You'd usually get a translation error, and in some cases a stray entry under the old name was written into your language files.

---

## Desktop v1.0.0 — 2026-06-02

StringLane 1.0: the first public release of the desktop-first localization editor.

StringLane 1.0 is here. Open your project folder and edit translations across every
locale side by side: changes stream straight to your source files, with no import,
no export, and no account.

### Edit every locale, side by side

- **Open a folder, see everything**: point StringLane at your project and it loads every locale file; edits auto-save back to source.
- **One key at a time, every locale visible**: the list-view editor shows all translations for the active key together, with a sidebar for search, filters, and quick actions.
- **Status at a glance**: completion %, key and issue counts, and save state live in the status bar.
- **Drag & drop to open**: drop a folder or file on the window. Recent projects remember format, locale count, and completion.

### Every major format

- **ARB (Flutter), Apple `.strings`, iOS `.xcstrings`, Android XML, and i18next JSON**: edit each in place, no conversion.
- **Project auto-detection** for Flutter, iOS/Apple, Android, and Web layouts, including multi-module Android, multi-target iOS, and namespaced i18next, all merged into one editable project.
- **Run `flutter gen-l10n`** from inside the app, and edit `@key` ARB metadata (descriptions, placeholders, types).

### AI translation, bring your own key

- **Per-cell, per-key, and bulk "translate all missing"** across every locale.
- **Seven cloud providers** (OpenAI, Anthropic, Google Gemini, Groq, Mistral, DeepSeek, xAI) plus **any local OpenAI-compatible runner** (Ollama, LM Studio, Jan, llama.cpp). Keys are bring-your-own and stored in the OS keychain.
- **Context-aware**: cultural/locale context and guarded-word rules shape results; plural keys are translated per quantity form.

### Plurals and ICU, without the syntax

- **Plural support**: create, edit, and convert plural keys across ARB, iOS, Android, and i18next, with CLDR-required forms filled automatically.
- **Visual ICU editor** for `{count, plural, …}` and `{gender, select, …}`, plus an inline `{}` placeholder helper.
- **One-click parameter autofix** for broken `{placeholder}` usage.

### Catch problems before they ship

- **Inline validation** for missing translations, same-as-source, placeholder mismatches, length budgets, ICU errors, guarded words, and malformed syntax.
- **Issues Panel (⌘J)** lists every problem across the project; fix many of them, including with AI, in place.

### Fast, keyboard-first, and yours

- **Command Palette (⌘P)** for every action, key jump, and setting; full keyboard shortcuts with remappable keybindings.
- **Appearance**: light, dark, or follow-system, with density, locale flags, and zebra-striping options.
- **Local-first and private**: no account, no cloud, no telemetry. Your strings never leave your machine.

### Platforms

- **macOS**: signed with a Developer ID certificate and notarized by Apple, so it opens without Gatekeeper warnings.
