Skip to content

Icons, translations and rewording

This is a guide, not the contract. What the platform guarantees is specified under openspec/specs/. For this page: i18n · theming. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.

Three things a distribution changes about the words and glyphs the chrome shows: which icon a name renders, which translation namespaces exist, and what the shell’s own strings say.

Icons

The shell ships a small first-party icon set. provideIcons, re-exported from @loomweaver/shell, does two jobs: it adds names the shell lacks, and it replaces the ones it ships, which is how a product re-skins the workbench in its own hand:

// src/app/app.config.ts — in the providers array
import { provideIcons } from '@loomweaver/shell';
import { heroDocumentText } from '@ng-icons/heroicons/outline';
// in providers:
provideIcons({
report: heroDocumentText, // a name the shell does not ship
brandMark: '<svg …>…</svg>',
trash: '<svg …>…</svg>', // replaces the shipped glyph wherever the chrome draws it
}),

Values are @ng-icons refs or raw SVG strings. Naming one of the shipped icons replaces it in the rail, the sidebars, tabs, menus, dialogs, settings and the command palette, and it also travels into sandboxed surfaces, so a plugin drawing <lw-icon name="trash"> shows your glyph rather than the shipped one, and one screen never carries two icon sets. The key type suggests the shipped names, so a typo in an intended replacement shows up while you write it instead of quietly adding a glyph nothing draws; LoomIconName is exported if you want to name them in your own code, and LOOM_ICONS is the shipped map itself, for code that wants to read a glyph rather than draw it.

A weaver instead brings icons at runtime with ctx.contributeIcons, and a weaver contribution can never shadow a first-party or distribution name. Otherwise an installed plugin could repaint your chrome.

i18n

Three layers compose, and none can clobber another:

  • Host keys come from @loomweaver/shell at i18n/{lang}.json under the application’s base (serve them, see getting started §5).
  • Each namespace you register with provideTranslationNamespaces('notes', 'product') loads from i18n/<name>/{lang}.json under the base and nests under <name>.*. Your weaver owns notes.*; your branding owns product.*. Declarations accumulate: a second provideTranslationNamespaces('copilot') further down loads copilot beside notes, and a name declared twice is loaded once.
  • Overrides you opt into with provideTranslationOverrides() are applied last, key by key, and are the only layer that may change a host string; see Rewording the shell.

Serve the namespace files as assets (public/i18n/notes/en.json, public/i18n/product/en.json) and copy the shell’s host keys (getting-started §5). A namespace file does not repeat its namespace.

For a distribution at the root of its origin, the base is / and all of this lives under /i18n/. A distribution may also be served below a path, beside other applications on the same origin: build it with that base (ng build --base-href /customer-admin/) and serve its output, assets included, under that path. The strings, the namespaces, the default overlay directory and the pop-out windows then all follow the base, with nothing else to configure. The loader nests it under the name:

public/i18n/product/en.json
{ "tagline": "Weave anything" } // → resolved as product.tagline

Which languages are served

The workbench ships English and German and serves exactly those unless you say otherwise. Declare the whole set with provideShell({ languages }): add a language, leave one out, or serve neither shipped language at all.

provideShell({ languages: ['en', 'fr', 'ja'] });

That one list decides what is loaded, what the language switcher offers, what a stored choice or the browser’s preference may select and what <html lang> declares. Codes are canonicalised, so pt-br and pt-BR are one language. An empty list, or something that is not a language code, throws when the providers are built.

For a language the workbench does not ship, serve the workbench’s own strings beside the shipped ones, at /i18n/<code>.json (for example public/i18n/fr.json). The shipped English strings are the base, and your file is merged over them key by key. A string your file does not carry is shown in English and named once in the console during development, so a later release’s new strings never appear as bare keys. With no file at all, the workbench is shown in English while that language is active, and the console says so. Namespaces and overrides work for that language exactly as for a shipped one.

A product’s own language control reads and sets the language through LocaleService, see Recomposing host chrome.

Rewording the shell

Namespaces let you add strings and can never collide with a host key, which is what keeps a plugin from renaming your Cancel button. Rewording the shell itself is the opposite job, so it is a separate, deliberate opt-in: call provideTranslationOverrides() and serve public/i18n/overrides/{lang}.json.

public/i18n/overrides/en.json
{ "workspace": { "saveAs": "Save as" } } // the shipped string reads "Save as new"

The overlay is merged key by key, so you name only the strings you want to change and inherit everything else, including every key a later release adds. That is the point: forking the shipped bundle would leave you quietly behind on each update. It is applied last, so it also reaches the strings of a weaver you bundle.

In development the shell says something when the overlay cannot help: a language with no overlay file keeps its shipped strings and is logged, and a key the overlay names but nothing ships is logged too, since that is otherwise a string that simply never appears.

More than one wording in one build

provideTranslationOverrides() takes an optional directory, so a single build can carry several wordings and pick one while composing. That suits a white-label distribution serving three brands, or a demo that switches product:

src/app/app.config.ts
provideTranslationOverrides(`i18n/overrides/${brand}`), // → i18n/overrides/acme/en.json under the base

A relative directory resolves under the application’s base, like the default. One that starts with / or names a scheme is used exactly as written, for overlays served from elsewhere on your origin.

You probably do not need this. The default path is same-origin, so a product with a backend can already serve different bytes there per tenant, which keeps the choice on the server where the tenant is known. The argument is for the static case, where the bytes are fixed at deploy time and the choice has to happen in the composition root.

Where next

  • Branding: the identity and the --lw-* tokens beside these icons and strings.
  • Icons and theme: ctx.contributeIcons, the icons a weaver brings at runtime.
  • Translations: how a weaver fills the namespace you registered for it.