Ship-aware language picker
A project decides which translated versions to offer its users. neokapi models
that decision with two gates, both declared in kapi.yaml, both evaluated
the same way against the target status ladder
(draft → translated → reviewed → signed-off):
- The ship gate: the bar to go live. A locale that clears it is safe to
offer. It is the
ship_gate/ship_gatesconfiguration. - The verified gate: the bar to count as human-verified: a person reviewed or signed off the content. A locale that ships but is not verified is AI-only work.
The two gates are independent. Being verified is not a prerequisite for shipping: a project can go live with machine translation and mark those locales as unverified until a reviewer catches up.
Declaring the verified gate
The verified gate uses the same three additive forms and the same precedence as
the ship gate, and resolves a gate: name against the shared gates: registry:
# kapi.yaml
ship_gate: { translated: 100 } # go live once fully translated
verified_gate: { reviewed: 100 } # count as verified once fully reviewed
A rule list narrows the bar per collection or locale, most-specific rule wins:
verified_gates:
- when: { locales: [ja] }
gate: { signed-off: 100 } # Japanese needs sign-off
- gate: { reviewed: 100 } # everything else: reviewed
The recipe keys are verified_gate (a single catch-all gate) and
verified_gates (a when/gate rule list).
A project with no verified gate has no verified locales: every shippable
locale reads as AI-only. Declaring verified_gate / verified_gates is how a
project opts in to the stronger claim. This keeps the label honest: "verified"
never appears unless a bar was declared and cleared.
A locale that no ship_gate or ship_gates rule matches has no bar to clear,
so kapi reports it as not gated rather than shippable. Nothing withholds it
on coverage, so the picker offers it by default. Stale wording, a translation a
reviewer turned down, a failing check, or terms that were not checked withhold a
locale whether or not a gate matches it.
Emitting the manifest
kapi status --ship projects the per-locale standing to a minimal manifest a
language picker can consume. Write it into your app's static assets as part of
the build:
kapi status --ship --emit public/ship.json
Without --emit, the manifest goes to stdout, so a build step can redirect it:
kapi status --ship > public/ship.json
The file is keyed by locale, each entry carrying the two gates' outcomes and the locale's ship state:
{
"fr": { "shippable": true, "verified": true, "state": "shippable" },
"de": { "shippable": true, "verified": false, "state": "shippable" },
"nl": { "shippable": true, "verified": false, "state": "shippable", "not_governed": ["terms"] },
"sv": { "shippable": true, "verified": false, "state": "not_gated" },
"ja": { "shippable": false, "verified": false, "state": "withheld" }
}
Here French ships and is verified (no badge), German ships but is AI-only (flagged), Dutch ships the same way in a language no terms govern, Swedish has no ship gate and is offered as not gated, and Japanese is withheld.
state takes one of three values:
state | Meaning | shippable |
|---|---|---|
shippable | A ship gate matches the locale and the locale clears it. | true |
withheld | The locale does not ship: it is short of its gate, or stale, rejected or failing content, or unchecked terms, hold it back. | false |
not_gated | No ship gate matches the locale, and nothing withholds it. | true |
A locale spread over several collections takes the weakest state among them:
one withheld collection withholds it, and a collection no gate matches leaves it
not gated. shippable is true for both shippable and not_gated, so a
picker that reads only shippable offers a not-gated locale; state is what
tells a cleared gate from no gate. An entry carries not_governed when a dimension governs
nothing in that language: terms means that none of the terms bound where the
language's content sits, under the project defaults or on a profile, has a term
for it. The picker does not read it. It is there so a build step or a
reader does not take the language for a governed one. The richer kapi status --json
report carries the same shippable and verified fields per collection and
locale, with the state as shipState and the full coverage percentages, for
dashboards.
A hosted feed instead of a built file
The manifest does not have to be a file you build into your assets. Because the loader takes a URL, it can read the same shape from a hosted feed, a server that serves the per-locale manifest live at a public URL. The build step disappears, and the picker reads the current standing on each load rather than whatever was true at build time.
The contract is exactly the file's: an object keyed by locale, each value
{ shippable, verified, state }, with not_governed where it applies. A hosted feed is read-only and needs no auth (a
public picker fetches it directly), and should send an ETag and a short
Cache-Control: public, max-age=… so a picker or a CDN can revalidate cheaply
with a 304.
Point the loader at the URL; nothing else changes:
const status = await loadShipStatus("https://example.com/ship.json");
const model = languagePickerModel(status, ["en", "fr", "de", "ja"]);
The same languagePickerModel transform and the same useShipStatus hook (whose
second argument is the manifest URL) drive the picker whether the manifest came
from a built file or a hosted feed: one code path, one shape.
Driving the picker
@neokapi/i18n-react/ship provides a dependency-free loader and a headless
transform. The loader tolerates a missing or malformed manifest: it resolves to
an empty object, so the picker falls back to showing every locale unbadged
rather than breaking the page.
Pass locale codes; the display label for each is derived automatically as the
locale's endonym (the language named in its own language) via
Intl.DisplayNames, so there is no per-locale label table to maintain:
import { loadShipStatus, languagePickerModel } from "@neokapi/i18n-react/ship";
const status = await loadShipStatus(); // defaults to /ship.json
const model = languagePickerModel(status, ["en", "fr", "de", "ja"]);
// → [{ locale: "fr", label: "Français", shippable: true, badge: 'ai' | null, state: "shippable" }, …]
The label is resolved in this order: an explicit label on a LocaleInput, then
an entry in the labels override map, then the Intl.DisplayNames endonym, then
the raw code. So an explicit label overrides the derived one where you want a
different form, and the override map names locales Intl cannot: a pseudo-locale
such as qps has no standard name, so give it one:
const model = languagePickerModel(status, ["fr", "de", "qps"], {
labels: { qps: "Pseudo English" },
});
// fr → "Français" (derived), de → "Deutsch" (derived), qps → "Pseudo English" (override)
The signature is
languagePickerModel(status, locales, options?), where options is
{ labels?: Record<string, string>; includeNotGated?: boolean }.
The first letter of a derived endonym is capitalized for a menu-style label
(français → Français), so lowercase endonyms read consistently alongside the
ones that are already capitalized (Deutsch); scripts without case (日本語) are
left unchanged. If Intl.DisplayNames is unavailable or has no name for a code,
the label falls back to the override map and then to the raw code; it never
throws.
languagePickerModel returns the locales whose entry is shippable, which
includes a not-gated locale. Pass includeNotGated: false to offer only the
locales that clear a ship gate. Each entry carries the manifest's state and a
badge: 'ai' when the locale ships but is not verified, and null when it is
verified. 'ai' is the only badge this layer emits; a verified locale has no
badge. A React binding wraps the same two functions and takes the same options
as a third argument:
import { useShipStatus } from "@neokapi/i18n-react/ship/react";
function LanguagePicker({ locales }) {
// loads /ship.json, derives labels, returns the model
const options = useShipStatus(locales, undefined, { labels: { qps: "Pseudo English" } });
return (
<ul>
{options.map(({ locale, label, badge }) => (
<li key={locale}>
{label}
{badge === "ai" && <span className="badge-ai">AI</span>}
</li>
))}
</ul>
);
}
Rendering is left to the application: style the entries and the AI badge to
match your design. Development-only locales (pseudo-translation such as qps)
are not part of the ship manifest; name them through the labels override map
if you list them in the picker, or surface them separately in your own dev
tooling.
Compatibility
The verified gate is purely additive. Recipes that declare no verified_gate
are unaffected: their ship-gate behaviour is unchanged and every locale reads as
unverified. The picker helper degrades safely when ship.json is absent, and
reads an entry with no state by its shippable flag alone, so a project can
adopt the manifest and the picker independently.