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 arrayimport { 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/shellati18n/{lang}.jsonunder the application’s base (serve them, see getting started §5). - Each namespace you register with
provideTranslationNamespaces('notes', 'product')loads fromi18n/<name>/{lang}.jsonunder the base and nests under<name>.*. Your weaver ownsnotes.*; your branding ownsproduct.*. Declarations accumulate: a secondprovideTranslationNamespaces('copilot')further down loadscopilotbesidenotes, 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:
{ "tagline": "Weave anything" } // → resolved as product.taglineWhich 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.
{ "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:
provideTranslationOverrides(`i18n/overrides/${brand}`), // → i18n/overrides/acme/en.json under the baseA 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.