Plugin studio: its own words in every locale
Some checks are pending
build-gate / build (push) Waiting to run

Choosing a language in the studio translated the plugins it mounts but
left the studio itself in English: the title, the count, the filter box,
Collapse all, the section names, Copy embed, the loading and error
messages, and the rail's own Collapse button and aria labels.

The studio's strings now live in library/i18n/locales/<lc>/studio.json
for all 11 locales, held to key parity by check:i18n-parity like every
other catalog. The studio has no vue-i18n instance, so it looks strings
up directly (locale, then English, per key). The Explorer tree takes
them through a `strings` prop. VerticalTabsLayout gains an optional
`labels` prop for its five words; hosts that do not pass it keep the
English defaults, so doxa-maps-spa is unchanged until it opts in.

Verified by switching the studio through every language its switcher
offers (en, es, fr, pt, ar, ru): no English left in the studio chrome.
Technical names (CAP n, element tags, tier machine names in tooltips)
stay as they are.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 04:10:49 +00:00
parent 193127e257
commit 814124b3f6
14 changed files with 402 additions and 32 deletions

View file

@ -0,0 +1,30 @@
{
"title": "استوديو الإضافات",
"navLabel": "الإضافات حسب القدرة",
"countLabel": "{bundles} حزمة · {elements} إضافة مدرجة هنا",
"changeLanguage": "تغيير اللغة",
"copyEmbed": "نسخ رمز التضمين",
"copy": "نسخ",
"copied": "تم النسخ",
"scriptEmbed": "تضمين عبر سكربت",
"loading": "جارٍ تحميل {name}…",
"couldNotLoad": "تعذّر تحميل {name}.",
"chooseElement": "اختر إضافة من القائمة.",
"filterPlaceholder": "تصفية الإضافات",
"collapseAll": "طي الكل",
"noMatch": "لا توجد إضافة تطابق «{query}».",
"openSection": "فتح {cap}، {label}",
"part": "جزء",
"noStandalone": "بلا صفحة مستقلة",
"cap5": "هيكل التطبيق",
"cap4": "مركّب",
"cap3": "متعدد العروض",
"cap2": "إعداد مسبق",
"cap1": "مفرد",
"toggleList": "إظهار القائمة أو إخفاؤها",
"openViews": "فتح قائمة العروض",
"expandList": "توسيع القائمة",
"collapseList": "طي القائمة",
"collapse": "طي",
"partOf": "جزء من {name}، غير منشور بمفرده"
}

View file

@ -0,0 +1,30 @@
{
"title": "Plugin-Studio",
"navLabel": "Plugins nach Fähigkeit",
"countLabel": "{bundles} Bundles · {elements} Plugins hier aufgeführt",
"changeLanguage": "Sprache ändern",
"copyEmbed": "Einbettung kopieren",
"copy": "Kopieren",
"copied": "Kopiert",
"scriptEmbed": "Skript-Einbettung",
"loading": "{name} wird geladen…",
"couldNotLoad": "{name} konnte nicht geladen werden.",
"chooseElement": "Wählen Sie ein Plugin aus der Liste.",
"filterPlaceholder": "Plugins filtern",
"collapseAll": "Alle einklappen",
"noMatch": "Kein Plugin entspricht „{query}“.",
"openSection": "{cap} öffnen, {label}",
"part": "Teil",
"noStandalone": "keine eigene Seite",
"cap5": "App-Shell",
"cap4": "Komposit",
"cap3": "Mehrfachansicht",
"cap2": "Voreinstellung",
"cap1": "Einzeln",
"toggleList": "Liste ein-/ausblenden",
"openViews": "Ansichtsliste öffnen",
"expandList": "Liste ausklappen",
"collapseList": "Liste einklappen",
"collapse": "Einklappen",
"partOf": "Teil von {name}, nicht eigenständig veröffentlicht"
}

View file

@ -0,0 +1,30 @@
{
"title": "Plugin Studio",
"navLabel": "Plugins by capability",
"countLabel": "{bundles} bundles · {elements} plugins listed here",
"changeLanguage": "Change language",
"copyEmbed": "Copy embed",
"copy": "Copy",
"copied": "Copied",
"scriptEmbed": "Script embed",
"loading": "Loading {name}…",
"couldNotLoad": "Could not load {name}.",
"chooseElement": "Choose a plugin from the list.",
"filterPlaceholder": "Filter plugins",
"collapseAll": "Collapse all",
"noMatch": "No plugin matches “{query}”.",
"openSection": "Open {cap}, {label}",
"part": "part",
"noStandalone": "no standalone page",
"cap5": "App shell",
"cap4": "Composite",
"cap3": "Multi-view",
"cap2": "Preset",
"cap1": "Single",
"toggleList": "Toggle list",
"openViews": "Open the list of views",
"expandList": "Expand list",
"collapseList": "Collapse list",
"collapse": "Collapse",
"partOf": "part of {name}, not published on its own"
}

View file

@ -0,0 +1,30 @@
{
"title": "Estudio de plugins",
"navLabel": "Plugins por capacidad",
"countLabel": "{bundles} paquetes · {elements} plugins listados aquí",
"changeLanguage": "Cambiar idioma",
"copyEmbed": "Copiar código de inserción",
"copy": "Copiar",
"copied": "Copiado",
"scriptEmbed": "Inserción por script",
"loading": "Cargando {name}…",
"couldNotLoad": "No se pudo cargar {name}.",
"chooseElement": "Elija un plugin de la lista.",
"filterPlaceholder": "Filtrar plugins",
"collapseAll": "Contraer todo",
"noMatch": "Ningún plugin coincide con «{query}».",
"openSection": "Abrir {cap}, {label}",
"part": "parte",
"noStandalone": "sin página propia",
"cap5": "Contenedor de aplicación",
"cap4": "Compuesto",
"cap3": "Multivista",
"cap2": "Preajuste",
"cap1": "Individual",
"toggleList": "Mostrar u ocultar la lista",
"openViews": "Abrir la lista de vistas",
"expandList": "Expandir la lista",
"collapseList": "Contraer la lista",
"collapse": "Contraer",
"partOf": "parte de {name}, no publicada por separado"
}

View file

@ -0,0 +1,30 @@
{
"title": "Studio de plugins",
"navLabel": "Plugins par capacité",
"countLabel": "{bundles} paquets · {elements} plugins listés ici",
"changeLanguage": "Changer de langue",
"copyEmbed": "Copier le code d’intégration",
"copy": "Copier",
"copied": "Copié",
"scriptEmbed": "Intégration par script",
"loading": "Chargement de {name}…",
"couldNotLoad": "Impossible de charger {name}.",
"chooseElement": "Choisissez un plugin dans la liste.",
"filterPlaceholder": "Filtrer les plugins",
"collapseAll": "Tout replier",
"noMatch": "Aucun plugin ne correspond à « {query} ».",
"openSection": "Ouvrir {cap}, {label}",
"part": "partie",
"noStandalone": "pas de page autonome",
"cap5": "Coquille d’application",
"cap4": "Composite",
"cap3": "Multivue",
"cap2": "Préréglage",
"cap1": "Simple",
"toggleList": "Afficher ou masquer la liste",
"openViews": "Ouvrir la liste des vues",
"expandList": "Déplier la liste",
"collapseList": "Replier la liste",
"collapse": "Replier",
"partOf": "partie de {name}, non publiée seule"
}

View file

@ -0,0 +1,30 @@
{
"title": "प्लगइन स्टूडियो",
"navLabel": "क्षमता के अनुसार प्लगइन",
"countLabel": "{bundles} बंडल · यहाँ {elements} प्लगइन सूचीबद्ध",
"changeLanguage": "भाषा बदलें",
"copyEmbed": "एम्बेड कोड कॉपी करें",
"copy": "कॉपी करें",
"copied": "कॉपी हो गया",
"scriptEmbed": "स्क्रिप्ट एम्बेड",
"loading": "{name} लोड हो रहा है…",
"couldNotLoad": "{name} लोड नहीं हो सका।",
"chooseElement": "सूची से कोई प्लगइन चुनें।",
"filterPlaceholder": "प्लगइन फ़िल्टर करें",
"collapseAll": "सभी संक्षिप्त करें",
"noMatch": "“{query}” से कोई प्लगइन मेल नहीं खाता।",
"openSection": "{cap} खोलें, {label}",
"part": "भाग",
"noStandalone": "कोई अलग पेज नहीं",
"cap5": "ऐप शेल",
"cap4": "संयुक्त",
"cap3": "बहु-दृश्य",
"cap2": "प्रीसेट",
"cap1": "एकल",
"toggleList": "सूची दिखाएँ या छिपाएँ",
"openViews": "दृश्यों की सूची खोलें",
"expandList": "सूची फैलाएँ",
"collapseList": "सूची संक्षिप्त करें",
"collapse": "संक्षिप्त करें",
"partOf": "{name} का भाग, अलग से प्रकाशित नहीं"
}

View file

@ -0,0 +1,30 @@
{
"title": "Studio dei plugin",
"navLabel": "Plugin per capacità",
"countLabel": "{bundles} pacchetti · {elements} plugin elencati qui",
"changeLanguage": "Cambia lingua",
"copyEmbed": "Copia codice di incorporamento",
"copy": "Copia",
"copied": "Copiato",
"scriptEmbed": "Incorporamento tramite script",
"loading": "Caricamento di {name}…",
"couldNotLoad": "Impossibile caricare {name}.",
"chooseElement": "Scegli un plugin dall’elenco.",
"filterPlaceholder": "Filtra i plugin",
"collapseAll": "Comprimi tutto",
"noMatch": "Nessun plugin corrisponde a «{query}».",
"openSection": "Apri {cap}, {label}",
"part": "parte",
"noStandalone": "nessuna pagina propria",
"cap5": "Shell dell’app",
"cap4": "Composito",
"cap3": "Multivista",
"cap2": "Preimpostato",
"cap1": "Singolo",
"toggleList": "Mostra o nascondi l’elenco",
"openViews": "Apri l’elenco delle viste",
"expandList": "Espandi l’elenco",
"collapseList": "Comprimi l’elenco",
"collapse": "Comprimi",
"partOf": "parte di {name}, non pubblicata da sola"
}

View file

@ -0,0 +1,30 @@
{
"title": "Estúdio de plugins",
"navLabel": "Plugins por capacidade",
"countLabel": "{bundles} pacotes · {elements} plugins listados aqui",
"changeLanguage": "Mudar idioma",
"copyEmbed": "Copiar código de incorporação",
"copy": "Copiar",
"copied": "Copiado",
"scriptEmbed": "Incorporação por script",
"loading": "Carregando {name}…",
"couldNotLoad": "Não foi possível carregar {name}.",
"chooseElement": "Escolha um plugin na lista.",
"filterPlaceholder": "Filtrar plugins",
"collapseAll": "Recolher tudo",
"noMatch": "Nenhum plugin corresponde a “{query}”.",
"openSection": "Abrir {cap}, {label}",
"part": "parte",
"noStandalone": "sem página própria",
"cap5": "Shell do aplicativo",
"cap4": "Composto",
"cap3": "Multivisualização",
"cap2": "Predefinição",
"cap1": "Único",
"toggleList": "Mostrar ou ocultar a lista",
"openViews": "Abrir a lista de visualizações",
"expandList": "Expandir a lista",
"collapseList": "Recolher a lista",
"collapse": "Recolher",
"partOf": "parte de {name}, não publicada separadamente"
}

View file

@ -0,0 +1,30 @@
{
"title": "Studio de pluginuri",
"navLabel": "Pluginuri după capacitate",
"countLabel": "{bundles} pachete · {elements} pluginuri listate aici",
"changeLanguage": "Schimbă limba",
"copyEmbed": "Copiază codul de încorporare",
"copy": "Copiază",
"copied": "Copiat",
"scriptEmbed": "Încorporare prin script",
"loading": "Se încarcă {name}…",
"couldNotLoad": "{name} nu a putut fi încărcat.",
"chooseElement": "Alegeți un plugin din listă.",
"filterPlaceholder": "Filtrează pluginurile",
"collapseAll": "Restrânge tot",
"noMatch": "Niciun plugin nu corespunde cu „{query}”.",
"openSection": "Deschide {cap}, {label}",
"part": "parte",
"noStandalone": "fără pagină proprie",
"cap5": "Cadru de aplicație",
"cap4": "Compus",
"cap3": "Vizualizări multiple",
"cap2": "Presetat",
"cap1": "Simplu",
"toggleList": "Afișează sau ascunde lista",
"openViews": "Deschide lista de vizualizări",
"expandList": "Extinde lista",
"collapseList": "Restrânge lista",
"collapse": "Restrânge",
"partOf": "parte din {name}, nepublicată separat"
}

View file

@ -0,0 +1,30 @@
{
"title": "Студия плагинов",
"navLabel": "Плагины по возможностям",
"countLabel": "Здесь: пакетов {bundles} · плагинов {elements}",
"changeLanguage": "Сменить язык",
"copyEmbed": "Скопировать код встраивания",
"copy": "Копировать",
"copied": "Скопировано",
"scriptEmbed": "Встраивание через скрипт",
"loading": "Загрузка {name}…",
"couldNotLoad": "Не удалось загрузить {name}.",
"chooseElement": "Выберите плагин из списка.",
"filterPlaceholder": "Фильтр плагинов",
"collapseAll": "Свернуть всё",
"noMatch": "Нет плагинов, соответствующих «{query}».",
"openSection": "Открыть {cap}, {label}",
"part": "часть",
"noStandalone": "нет отдельной страницы",
"cap5": "Оболочка приложения",
"cap4": "Составной",
"cap3": "Несколько видов",
"cap2": "Предустановка",
"cap1": "Одиночный",
"toggleList": "Показать или скрыть список",
"openViews": "Открыть список представлений",
"expandList": "Развернуть список",
"collapseList": "Свернуть список",
"collapse": "Свернуть",
"partOf": "часть {name}, не публикуется отдельно"
}

View file

@ -0,0 +1,30 @@
{
"title": "插件工作室",
"navLabel": "按能力分类的插件",
"countLabel": "此处列出 {bundles} 个包 · {elements} 个插件",
"changeLanguage": "切换语言",
"copyEmbed": "复制嵌入代码",
"copy": "复制",
"copied": "已复制",
"scriptEmbed": "脚本嵌入",
"loading": "正在加载 {name}…",
"couldNotLoad": "无法加载 {name}。",
"chooseElement": "请从列表中选择一个插件。",
"filterPlaceholder": "筛选插件",
"collapseAll": "全部折叠",
"noMatch": "没有与“{query}”匹配的插件。",
"openSection": "打开 {cap},{label}",
"part": "部分",
"noStandalone": "无独立页面",
"cap5": "应用外壳",
"cap4": "组合",
"cap3": "多视图",
"cap2": "预设",
"cap1": "单一",
"toggleList": "显示或隐藏列表",
"openViews": "打开视图列表",
"expandList": "展开列表",
"collapseList": "折叠列表",
"collapse": "折叠",
"partOf": "{name} 的一部分,不单独发布"
}

View file

@ -94,7 +94,7 @@
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }">
<header class="vtl-top" v-if="header">
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
aria-label="Toggle list">☰</button>
:aria-label="label('toggleList')">☰</button>
<span class="vtl-brand"><slot name="brand" /></span>
<b class="vtl-title">{{ title }}</b>
<span class="vtl-meta"><slot name="meta" /></span>
@ -114,7 +114,7 @@
floating button over an open drawer would just cover a row. -->
<button v-if="narrow && !header && !navOpen"
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
@click="navOpen = true" aria-label="Open the list of views">☰</button>
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
<div class="vtl-body">
<!-- Rail visibility is CSS-driven via `transform`, never v-show/display:none — a display:none element
@ -130,10 +130,10 @@
<button
type="button" class="vtl-collapse"
:aria-expanded="String(!railCollapsed)"
:aria-label="railCollapsed ? 'Expand list' : 'Collapse list'"
:aria-label="railCollapsed ? label('expandList') : label('collapseList')"
@click="toggleRail">
<span class="vtl-collapse-ic" aria-hidden="true" v-html="iconSvg('chevron')"></span>
<span class="vtl-collapse-tx">Collapse</span>
<span class="vtl-collapse-tx">{{ label('collapse') }}</span>
</button>
<!-- #rail: a host that needs MORE than tabs (e.g. plugin-studio's tree) supplies the whole rail
@ -511,6 +511,9 @@ export default defineComponent({
// When `groups` is non-empty it takes precedence over `items`.
groups: { type: Array, default: () => [] },
navLabel: { type: String, default: 'Sections' },
// The shell's own words, for a localised host: { toggleList, openViews, expandList, collapseList,
// collapse }. Any key left out keeps its English default, so existing hosts are unchanged.
labels: { type: Object, default: () => ({}) },
// Start with the rail open (desktop). Auto-collapses on narrow screens after mount.
initialNavOpen: { type: Boolean, default: true },
// Render the top bar (#brand / title / #meta). Pass false for a sidebar-only, full-page element
@ -618,6 +621,10 @@ export default defineComponent({
this.navOpen = true
}
},
label(key) {
const EN = { toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse' }
return (this.labels && this.labels[key]) || EN[key]
},
// Open the desktop rail if it is in its slim state (a #rail host calls this when its slim-strip
// icons are clicked: "show me that section" means the full rail). No-op when already open.
expandRail() { if (!this.narrow && this.railCollapsed) this.railCollapsed = false },

View file

@ -51,8 +51,9 @@
model + owns everything in the content area: the embed bar (as #toolbar) and the per-element
panes with their direct-element mount + heavy-row teardown machinery. -->
<VerticalTabsLayout
:title="heading"
nav-label="Plugin elements · by capability 5 → 1"
:title="heading || tr('title')"
:nav-label="tr('navLabel')"
:labels="{ toggleList: tr('toggleList'), openViews: tr('openViews'), expandList: tr('expandList'), collapseList: tr('collapseList'), collapse: tr('collapse') }"
:header="true"
:active="activeKey"
@update:active="select">
@ -60,7 +61,8 @@
<!-- #rail: the Explorer tree (src/StudioTree.vue) — capability sections, each composite folding
to show what it is made of. The layout keeps the rail frame (collapse, drawer, slim strip). -->
<template #rail="{ collapsed, expand, pick }">
<StudioTree :sections="treeSections" :active="activeKey" :collapsed="collapsed" :expand="expand" :pick="pick" />
<StudioTree :sections="treeSections" :active="activeKey" :collapsed="collapsed" :expand="expand" :pick="pick"
:strings="{ filterPlaceholder: tr('filterPlaceholder'), collapseAll: tr('collapseAll'), noMatch: tr('noMatch'), treeLabel: tr('navLabel'), openSection: tr('openSection'), part: tr('part') }" />
</template>
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
@ -82,7 +84,7 @@
<span class="ps-count">{{ countLabel }}</span>
<span class="ps-locale">
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
aria-label="Change language" @click="localeOpen = !localeOpen">
:aria-label="tr('changeLanguage')" @click="localeOpen = !localeOpen">
<span class="ps-flag">{{ activeLang.flag }}</span>
<span class="ps-locale-name">{{ activeLang.nativeName }}</span>
<span class="ps-dots" aria-hidden="true">⋮</span>
@ -111,12 +113,12 @@
<span class="ps-bar-sub">{{ active.bundle }} · &lt;{{ active.tag }}&gt;<span v-if="active.description"> · {{ active.description }}</span></span>
</div>
<button type="button" class="ps-btn" :aria-expanded="String(embedOpen)"
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
@click="embedOpen = !embedOpen" v-if="active.embed">{{ tr('copyEmbed') }}</button>
</div>
<div class="ps-embed" v-if="embedOpen && active.embed">
<label>script embed
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
<label>{{ tr('scriptEmbed') }}
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">{{ tr('copy') }}</button>
</label>
<textarea readonly :value="active.embed"></textarea>
</div>
@ -142,13 +144,13 @@
<template v-if="!row.heavy || row.key === activeKey">
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
<div v-if="row.state === 'loading'" class="ps-hint">{{ tr('loading', { name: row.label }) }}</div>
<div v-else-if="row.state === 'error'" class="ps-hint err">
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
{{ tr('couldNotLoad', { name: row.label }) }}<br /><small>{{ row.error }}</small>
</div>
</template>
</section>
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
<div class="ps-hint" v-if="!activeKey">{{ tr('chooseElement') }}</div>
</div>
</VerticalTabsLayout>
</template>
@ -255,8 +257,9 @@ const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
// Rail row icon per capability: LOCAL inline SVG names (app-wrapper-material/icons.js), not emoji.
// These were emoji until 2026-09-24, and on a box with no emoji font (measured: this one) every rail
// row led with an empty tofu box — the first thing a developer saw on every plugin.
// Short tier names for the rail's section headers (the full CAP_LABEL is their tooltip).
const CAP_SHORT = { 5: 'App shell', 4: 'Composite', 3: 'Multi-view', 2: 'Preset', 1: 'Single' }
// Machine names of the five tiers — technical terms, shown untranslated after the localised tier name
// in a section header's tooltip (the localised names live in studio.json cap1…cap5).
const MACHINE_TYPE = { 5: 'nested-mfe-spa', 4: 'nested-elements-mfe', 3: 'monolithic', 2: 'parameterized', 1: 'single' }
const CAP_ICON = { 5: 'monitor', 4: 'tree', 3: 'layers', 2: 'grid', 1: 'embed' }
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
@ -302,6 +305,17 @@ const HEAVY_ELEMENTS = new Set([
// doxa-maps-spa's NOT_EXPERIMENTAL already hides big-picture for the same reason.
const STUDIO_HIDDEN = new Set(['big-picture'])
// THE STUDIO'S OWN WORDS, in every locale the switcher offers (library/i18n/locales/<lc>/studio.json,
// held to the same key parity as every other catalog by check:i18n-parity). The studio has no
// vue-i18n instance — it is a shell, not a ProfileLoader plugin — so it looks strings up directly
// and falls back to English per key. Before this, choosing a language translated the plugins
// but left the studio's own chrome in English.
const STUDIO_STRINGS = {}
for (const [path, mod] of Object.entries(import.meta.glob('../../library/i18n/locales/*/studio.json', { eager: true, import: 'default' }))) {
const m = path.match(/locales\/([^/]+)\/studio\.json$/)
if (m) STUDIO_STRINGS[m[1]] = mod
}
// DISPLAY NAMES — what a row is CALLED, keyed by "<bundle>/<profile>". Display only: the row key, the
// URL hash, the stored last-tab and the <tag> under the name are all unchanged, so links and memory
// keep working. The Driver's name for the cap-5 shell is "Research Gallery" (its meta.json label);
@ -371,7 +385,8 @@ export default defineComponent({
// Renaming the PROP is safe because nothing outside ever set it: no host passes title=, no
// profile-config reads cfg.title, and the value is only forwarded to VerticalTabsLayout — an
// ordinary Vue component, never a custom element, so its own `title` prop has no collision.
heading: { type: String, default: 'Plugin Studio' },
// Empty = the localised "Plugin Studio" (studio.json `title`); a host may still set its own.
heading: { type: String, default: '' },
tokenUrl: { type: String, default: '' },
tk: { type: String, default: '' }
},
@ -441,7 +456,7 @@ export default defineComponent({
const t = treeOf.get(r.key)
nodes.push({
id: r.key, parent: '', depth: 0, label: r.label, desc: r.tag, icon: r.icon, target: r.key,
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + (CAP_LABEL[r.cap] || '')
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + this.tr('cap' + r.cap)
})
if (!t) continue
const nid = (id) => (id === 0 ? r.key : r.key + '>' + id)
@ -450,16 +465,16 @@ export default defineComponent({
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: n.depth,
label: n.label, part: !!n.part, target: n.selects || '',
icon: n.part ? 'dots' : (CAP_ICON[n.cap] || 'embed'),
desc: n.part ? 'no standalone page' : 'cap ' + n.cap + ' · ' + n.tag,
desc: n.part ? this.tr('noStandalone') : 'cap ' + n.cap + ' · ' + n.tag,
kids: n.kids || 0,
title: n.part ? n.label + ' · part of ' + r.label + ', not published on its own'
: n.label + ' · <' + n.tag + '> · CAP ' + n.cap + ' ' + (CAP_LABEL[n.cap] || '')
title: n.part ? n.label + ' · ' + this.tr('partOf', { name: r.label })
: n.label + ' · <' + n.tag + '> · CAP ' + n.cap + ' ' + this.tr('cap' + n.cap)
})
}
}
// Short section names: the header shares a 256px rail with its CAP badge and count, and the
// full "App shell · nested-mfe-spa" truncated to "APP SHELL · NEST…". The long name is the tooltip.
return { id: 'cap-' + g.cap, cap: g.cap, label: CAP_SHORT[g.cap] || g.label, title: g.label, icon: CAP_ICON[g.cap] || 'embed', nodes }
return { id: 'cap-' + g.cap, cap: g.cap, label: this.tr('cap' + g.cap), title: this.tr('cap' + g.cap) + ' · ' + (MACHINE_TYPE[g.cap] || ''), icon: CAP_ICON[g.cap] || 'embed', nodes }
})
},
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
@ -530,7 +545,7 @@ export default defineComponent({
// total reads as a deliberate exclusion rather than an off-by-one.
countLabel() {
const nb = new Set(this.rows.map(r => r.bundle)).size
return nb + ' bundles · ' + this.rows.length + ' elements listed here'
return this.tr('countLabel', { bundles: nb, elements: this.rows.length })
},
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } }
},
@ -538,6 +553,13 @@ export default defineComponent({
this.loadManifest()
},
methods: {
// Localised studio string: current locale → English → the key itself; {name}-style placeholders.
tr(key, vars) {
const base = (this.locale || 'en').split('-')[0]
let str = (STUDIO_STRINGS[this.locale] || {})[key] || (STUDIO_STRINGS[base] || {})[key] || (STUDIO_STRINGS.en || {})[key] || key
if (vars) for (const k in vars) str = str.split('{' + k + '}').join(String(vars[k]))
return str
},
// Resolve one `composes` entry to a studio row. The manifest's refs are NOT one shape: an
// element TAG, a "<bundle>/<profile>" row KEY, a BUNDLE name whose tag differs,
// or a bare SIBLING profile name (gospel-resources composes "resources-map", "data-tables" — its
@ -785,7 +807,7 @@ export default defineComponent({
})
},
copyText(text, btn) {
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
const ok = () => { const t = btn.textContent; btn.textContent = this.tr('copied'); setTimeout(() => { btn.textContent = t }, 1200) }
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
else {
try { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); ok() } catch { /* ignore */ }

View file

@ -36,7 +36,7 @@
<div v-if="collapsed" class="st-activity">
<button v-for="s in sections" :key="s.id" type="button" class="st-act"
:class="{ active: s.id === activeSectionId }"
:aria-label="'Open CAP ' + s.cap + ', ' + s.label"
:aria-label="str('openSection', { cap: 'CAP ' + s.cap, label: s.label })"
@click="openSectionFromActivity(s.id)">
<span class="st-act-ic" aria-hidden="true" v-html="svg(s.icon)"></span>
<span class="st-act-n" aria-hidden="true">{{ s.cap }}</span>
@ -47,15 +47,15 @@
<div class="st-bar">
<label class="st-find">
<span class="st-find-ic" aria-hidden="true" v-html="svg('search')"></span>
<input ref="find" type="search" class="st-find-in" v-model="query" placeholder="Filter plugins"
aria-label="Filter plugins" @keydown="onFindKey" />
<input ref="find" type="search" class="st-find-in" v-model="query" :placeholder="str('filterPlaceholder')"
:aria-label="str('filterPlaceholder')" @keydown="onFindKey" />
</label>
<button type="button" class="st-tool" aria-label="Collapse all" title="Collapse all" @click="collapseAll">
<button type="button" class="st-tool" :aria-label="str('collapseAll')" :title="str('collapseAll')" @click="collapseAll">
<span aria-hidden="true" v-html="collapseAllSvg"></span>
</button>
</div>
<div class="st-tree" role="tree" aria-label="Plugins by capability" @keydown="onTreeKey">
<div class="st-tree" role="tree" :aria-label="str('treeLabel')" @keydown="onTreeKey">
<div v-for="item in visible" :key="item.id"
:ref="el => setRow(item.id, el)"
role="treeitem"
@ -80,12 +80,12 @@
<span v-else class="st-twisty-sp" aria-hidden="true"></span>
<span class="st-ic" aria-hidden="true" v-html="svg(item.icon)"></span>
<span class="st-label">{{ item.label }}</span>
<span class="st-part" v-if="item.part">part</span>
<span class="st-part" v-if="item.part">{{ str('part') }}</span>
<span class="st-desc">{{ item.desc }}</span>
<span class="st-count" v-if="item.expandable">{{ item.kids }}</span>
</template>
</div>
<p class="st-empty" v-if="query && !visible.length">No plugin matches “{{ query }}”.</p>
<p class="st-empty" v-if="query && !visible.length">{{ str('noMatch', { query }) }}</p>
</div>
</div>
</template>
@ -139,6 +139,10 @@ const ST_CSS = '' +
'.st-act-n { position:absolute; right:6px; bottom:5px; font:700 9px/1 system-ui,sans-serif; color:#5f8a63; }'
const COLLAPSE_ALL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><line x1="8.5" y1="12" x2="15.5" y2="12"/></svg>'
const EN = {
filterPlaceholder: 'Filter plugins', collapseAll: 'Collapse all', noMatch: 'No plugin matches “{query}”.',
treeLabel: 'Plugins by capability', openSection: 'Open {cap}, {label}', part: 'part'
}
const CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>'
export default defineComponent({
@ -150,7 +154,9 @@ export default defineComponent({
active: { type: String, default: '' },
collapsed: { type: Boolean, default: false },
pick: { type: Function, default: null },
expand: { type: Function, default: null }
expand: { type: Function, default: null },
// The host's localised strings (plugin-studio passes studio.json values); English fallbacks below.
strings: { type: Object, default: () => ({}) }
},
emits: ['select'],
setup() { useShadowStyles(ST_CSS, 'studio-tree') },
@ -221,6 +227,11 @@ export default defineComponent({
},
methods: {
svg(name) { return name ? localIcon(name) : '' },
str(key, vars) {
let v = this.strings[key] || EN[key] || key
if (vars) for (const k in vars) v = v.split('{' + k + '}').join(String(vars[k]))
return v
},
storageKey() { return 'dx-studio-tree:' + ((typeof location !== 'undefined' && location.pathname) || '') },
// Guarded: storage THROWS in private mode / under policy, and a throw here would blank the studio.
readState() { try { return JSON.parse(window.localStorage.getItem(this.storageKey()) || '{}') || {} } catch (_) { return {} } },