@cat-factory/app 0.198.0 → 0.199.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -267,7 +267,12 @@ by its `when(gates)` predicate (the same reactive gates service the nav uses, fi
267
267
  ## Extending the layer (consumer modules)
268
268
 
269
269
  A deployment can contribute its own components — result windows, nav entries, inspector
270
- panels, agent-kind palette data — **without forking**, through the auto-imported
270
+ panels, agent-kind palette data — plus two DATA-only seams that need no components at all:
271
+ its own applications in an **External tools** sidebar section (`externalTools`, each
272
+ resolving its URL from the acting user / open workspace / this board's custom fields) and the
273
+ **custom workspace metadata fields** those resolvers read (`workspaceMetadataFields`, edited
274
+ on the Metadata tab of Workspace settings). All of it **without forking**, through the
275
+ auto-imported
271
276
  `registerAppModule` seam (the frontend analogue of the backend's `registerAgentKind` /
272
277
  `registerGate` registries). The authoring walkthrough, the reusable shared building blocks
273
278
  (`ResultWindowShell`, the `StepRunMeta` run-metadata block, `useResultView`, …), and the
@@ -242,7 +242,7 @@ const decisionItems = computed(() =>
242
242
  <IconButton
243
243
  v-for="item in toolbarItems"
244
244
  :key="item.id"
245
- :label="t(item.labelKey)"
245
+ :label="item.label ?? t(item.labelKey)"
246
246
  :icon="item.icon"
247
247
  color="neutral"
248
248
  variant="ghost"
@@ -139,10 +139,14 @@ const commands = computed<Command[]>(() => {
139
139
  const asCommand = (g: (typeof commandGroups.value)[number]): Command[] =>
140
140
  g.items.map((ci) => ({
141
141
  id: ci.item.id,
142
- label: t(ci.labelKey),
142
+ // A contribution whose copy is deployment DATA (a registered external tool's title)
143
+ // carries a literal `label`; catalog destinations resolve their key. Running a tool's
144
+ // own name through `t()` would show the raw name plus a missing-key warning.
145
+ label: ci.item.label ?? t(ci.labelKey),
143
146
  group: t(g.labelKey),
144
147
  icon: ci.item.icon,
145
- keywords: ci.keywordsKey ? t(ci.keywordsKey) : undefined,
148
+ // The description doubles as fuzzy-match keywords for a tool, which has no keyword key.
149
+ keywords: ci.keywordsKey ? t(ci.keywordsKey) : ci.item.description,
146
150
  run: () => invoke(ci.item),
147
151
  }))
148
152
  const groupOrEmpty = (name: (typeof commandGroups.value)[number]['group']) => {
@@ -154,6 +158,8 @@ const commands = computed<Command[]>(() => {
154
158
  ...groupOrEmpty('repositories'),
155
159
  ...groupOrEmpty('integrations'),
156
160
  ...dynamicIntegrationCommands.value,
161
+ // The deployment's own applications (the `externalTools` slot, projected onto nav items).
162
+ ...groupOrEmpty('externalTools'),
157
163
  ...groupOrEmpty('workspace'),
158
164
  ...groupOrEmpty('account'),
159
165
  ]
@@ -17,6 +17,7 @@ import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
17
17
  import UiModeSwitcher from '~/components/layout/UiModeSwitcher.vue'
18
18
  import UserMenu from '~/components/auth/UserMenu.vue'
19
19
  import { useViewport } from '~/composables/useViewport'
20
+ import type { NavContribution } from '~/modular/nav-contributions'
20
21
 
21
22
  const { t } = useI18n()
22
23
 
@@ -37,6 +38,26 @@ const ui = useUiStore()
37
38
  // or connection flips, so this shell no longer hand-rolls per-item `show*` computeds.
38
39
  const { sidebarGroups, invoke } = useNavContributions()
39
40
 
41
+ /**
42
+ * A destination's visible label: catalog copy for a first-party item, the LITERAL `label` for
43
+ * one whose copy is deployment data (a registered external tool's title). A tool's name is not
44
+ * a catalog key — the deployment ships whatever locales it needs in its own catalog — so
45
+ * running it through `t()` would render the raw name back with a missing-key warning.
46
+ */
47
+ function navLabel(item: NavContribution): string {
48
+ return item.label ?? t(item.labelKey)
49
+ }
50
+
51
+ /**
52
+ * The hover tooltip. In the rail it names the destination (the label is hidden); expanded it
53
+ * carries the item's `description` when it has one, which is how an external tool explains
54
+ * what it is without a second line in the sidebar.
55
+ */
56
+ function navTitle(item: NavContribution, railed: boolean): string | undefined {
57
+ if (railed) return item.description ? `${navLabel(item)}: ${item.description}` : navLabel(item)
58
+ return item.description
59
+ }
60
+
40
61
  // `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
41
62
  // above it the aside is static and the drawer flag is inert.
42
63
  const { isCompact } = useViewport()
@@ -235,12 +256,12 @@ watch(
235
256
  :square="railed"
236
257
  class="w-full"
237
258
  :class="railed ? 'justify-center' : 'justify-start'"
238
- :aria-label="railed ? t(item.labelKey) : undefined"
239
- :title="railed ? t(item.labelKey) : undefined"
259
+ :aria-label="railed ? navLabel(item) : undefined"
260
+ :title="navTitle(item, railed)"
240
261
  :data-testid="item.testId"
241
262
  @click="invoke(item)"
242
263
  >
243
- <span v-if="!railed">{{ t(item.labelKey) }}</span>
264
+ <span v-if="!railed">{{ navLabel(item) }}</span>
244
265
  </UButton>
245
266
  </div>
246
267
  </section>
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ // Workspace settings: the values for the CUSTOM metadata fields a deployment declares in code
3
+ // (the `workspaceMetadataFields` slot — see `modular/workspace-metadata.ts`). The fields come
4
+ // from the registry; the values are per workspace and land in the settings row's `metadata`
5
+ // bag, where external-tool URL resolvers read them ("open the map editor on this board's game").
6
+ //
7
+ // A deployment that declares NOTHING never gets here — the panel's tab exists only where fields
8
+ // are declared, so an unwired capability is invisible rather than an empty tab everywhere. The
9
+ // empty state below is therefore the loud one: fields WERE declared and every one was rejected
10
+ // (a malformed key), which must not look like a deployment that declared none.
11
+ import { reactive, watch } from 'vue'
12
+ import { useReactiveSlots } from '@modular-vue/runtime'
13
+ import {
14
+ metadataDraftFrom,
15
+ metadataPatchFrom,
16
+ resolveMetadataFields,
17
+ } from '~/modular/workspace-metadata'
18
+ import type { WorkspaceMetadataFieldDefinition } from '~/modular/workspace-metadata'
19
+ import type { AppSlots } from '~/modular/slots'
20
+
21
+ const { t } = useI18n()
22
+ const slots = useReactiveSlots<AppSlots>()
23
+ const store = useWorkspaceSettingsStore()
24
+ const toast = useToast()
25
+
26
+ /**
27
+ * The fields to render. A malformed key is dropped (the store would refuse every save) and
28
+ * NAMED in the console rather than swallowed — the deployment author is the only person who
29
+ * can fix it, and a silently missing field looks exactly like one nobody declared.
30
+ */
31
+ const fields = computed<WorkspaceMetadataFieldDefinition[]>(() => {
32
+ const { fields: valid, rejected } = resolveMetadataFields(
33
+ (slots.value.workspaceMetadataFields ?? []) as WorkspaceMetadataFieldDefinition[],
34
+ )
35
+ if (import.meta.dev && rejected.length > 0) {
36
+ console.warn(
37
+ '[cat-factory] workspace metadata fields dropped (invalid or duplicate key):',
38
+ rejected.map((f) => f.key),
39
+ )
40
+ }
41
+ return valid
42
+ })
43
+
44
+ // Local editable copy, re-seeded whenever the stored settings are replaced (the store always
45
+ // reassigns the ref, so tracking the object reference is enough).
46
+ const draft = reactive<Record<string, string>>({})
47
+ watch(
48
+ [() => store.settings, fields],
49
+ () => {
50
+ const next = metadataDraftFrom(fields.value, store.settings.metadata)
51
+ for (const key of Object.keys(draft)) delete draft[key]
52
+ Object.assign(draft, next)
53
+ },
54
+ { immediate: true },
55
+ )
56
+
57
+ const saving = ref(false)
58
+
59
+ async function save() {
60
+ saving.value = true
61
+ try {
62
+ // `metadataPatchFrom` carries any stored key this build does not render back into the
63
+ // patch: the update REPLACES the bag, so a value written under a retired field would
64
+ // otherwise be deleted by an unrelated save.
65
+ await store.update({
66
+ metadata: metadataPatchFrom(fields.value, draft, store.settings.metadata),
67
+ })
68
+ toast.add({
69
+ title: t('settings.workspaceSettings.toast.saved'),
70
+ icon: 'i-lucide-check',
71
+ color: 'success',
72
+ })
73
+ } catch (e) {
74
+ toast.add({
75
+ title: t('settings.workspaceSettings.toast.saveFailed'),
76
+ description: e instanceof Error ? e.message : String(e),
77
+ icon: 'i-lucide-triangle-alert',
78
+ color: 'error',
79
+ })
80
+ } finally {
81
+ saving.value = false
82
+ }
83
+ }
84
+
85
+ /** A `select` field's items, with an explicit "not set" choice so a value can be cleared. */
86
+ function selectItems(field: WorkspaceMetadataFieldDefinition) {
87
+ return [
88
+ { label: t('settings.workspaceSettings.metadata.unset'), value: '' },
89
+ ...(field.options ?? []).map((o) => ({ label: o.label, value: o.value })),
90
+ ]
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <div class="space-y-6" data-testid="workspace-metadata-settings">
96
+ <section class="space-y-2">
97
+ <h3 class="text-sm font-semibold text-slate-200">
98
+ {{ t('settings.workspaceSettings.metadata.heading') }}
99
+ </h3>
100
+ <p class="text-[11px] text-slate-400">
101
+ {{ t('settings.workspaceSettings.metadata.body') }}
102
+ </p>
103
+ </section>
104
+
105
+ <p v-if="fields.length === 0" class="text-[11px] text-slate-500">
106
+ {{ t('settings.workspaceSettings.metadata.empty') }}
107
+ </p>
108
+
109
+ <template v-else>
110
+ <div class="space-y-4">
111
+ <label v-for="field in fields" :key="field.key" class="block">
112
+ <!-- Field labels are deployment DATA, rendered verbatim (see the module docs). -->
113
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
114
+ {{ field.label }}
115
+ </span>
116
+ <USelect
117
+ v-if="field.type === 'select'"
118
+ v-model="draft[field.key]"
119
+ :items="selectItems(field)"
120
+ size="sm"
121
+ :data-testid="`workspace-metadata-${field.key}`"
122
+ />
123
+ <UInput
124
+ v-else
125
+ v-model="draft[field.key]"
126
+ :type="field.type === 'number' ? 'number' : 'text'"
127
+ :placeholder="field.placeholder"
128
+ size="sm"
129
+ :data-testid="`workspace-metadata-${field.key}`"
130
+ />
131
+ <span v-if="field.description" class="mt-1 block text-[11px] text-slate-500">
132
+ {{ field.description }}
133
+ </span>
134
+ </label>
135
+ </div>
136
+
137
+ <div class="flex justify-end">
138
+ <UButton
139
+ color="primary"
140
+ size="sm"
141
+ icon="i-lucide-save"
142
+ :loading="saving"
143
+ data-testid="workspace-metadata-save"
144
+ @click="save"
145
+ >
146
+ {{ t('common.save') }}
147
+ </UButton>
148
+ </div>
149
+ </template>
150
+ </div>
151
+ </template>
@@ -5,9 +5,12 @@
5
5
  // - Merge thresholds: the auto-merge preset library.
6
6
  // - Issue tracker: filing-tracker selection + linking sources + writeback.
7
7
  // - Service best practices: the default fragments new services inherit.
8
+ // - Metadata: values for the custom workspace fields the DEPLOYMENT declares in code (read
9
+ // by external-tool URL resolvers); present only where any are declared.
8
10
  // The latter three are body-only section components rendered in tabs here (no longer
9
11
  // standalone modals).
10
12
  import { reactive, ref, watch } from 'vue'
13
+ import { useReactiveSlots } from '@modular-vue/runtime'
11
14
  import type { ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
12
15
  import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
13
16
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
@@ -15,7 +18,9 @@ import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentD
15
18
  import BudgetSettings from '~/components/settings/BudgetSettings.vue'
16
19
  import UsageSettings from '~/components/settings/UsageSettings.vue'
17
20
  import WorkspaceMembersSettings from '~/components/layout/WorkspaceMembersSettings.vue'
21
+ import WorkspaceMetadataSettings from '~/components/settings/WorkspaceMetadataSettings.vue'
18
22
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
23
+ import type { AppSlots } from '~/modular/slots'
19
24
 
20
25
  const { t, te } = useI18n()
21
26
  const ui = useUiStore()
@@ -23,6 +28,13 @@ const store = useWorkspaceSettingsStore()
23
28
  const workspace = useWorkspaceStore()
24
29
  const access = useWorkspaceAccess()
25
30
  const toast = useToast()
31
+ const slots = useReactiveSlots<AppSlots>()
32
+
33
+ // The Metadata tab exists only where the deployment DECLARES custom fields — an unwired
34
+ // capability is invisible, not an empty tab in every deployment. Declared-but-malformed fields
35
+ // still open the tab: it carries the empty state (and the console warning names the keys), so a
36
+ // broken declaration surfaces instead of looking like one nobody wrote.
37
+ const hasMetadataFields = computed(() => (slots.value.workspaceMetadataFields ?? []).length > 0)
26
38
 
27
39
  const open = computed({
28
40
  get: () => ui.workspaceSettingsOpen,
@@ -74,6 +86,16 @@ const tabs = computed(() => [
74
86
  icon: 'i-lucide-book-open-check',
75
87
  slot: 'fragments',
76
88
  },
89
+ ...(hasMetadataFields.value
90
+ ? [
91
+ {
92
+ value: 'metadata',
93
+ label: t('settings.workspaceSettings.tabs.metadata'),
94
+ icon: 'i-lucide-tags',
95
+ slot: 'metadata',
96
+ },
97
+ ]
98
+ : []),
77
99
  // Roster + access-mode management is `members.manage` (workspace admins only). Hidden
78
100
  // for everyone else — the backend 403s the writes and the tab has nothing to read.
79
101
  ...(access.canManageMembers.value
@@ -517,6 +539,11 @@ async function save() {
517
539
  <ServiceFragmentDefaultsPanel />
518
540
  </template>
519
541
 
542
+ <!-- Custom workspace metadata (only where the deployment declares fields) -->
543
+ <template v-if="hasMetadataFields" #metadata>
544
+ <WorkspaceMetadataSettings />
545
+ </template>
546
+
520
547
  <!-- Members (workspace RBAC roster + access mode; admins only) -->
521
548
  <template v-if="access.canManageMembers.value && workspace.workspaceId" #members>
522
549
  <WorkspaceMembersSettings :workspace-id="workspace.workspaceId" />
@@ -1,6 +1,9 @@
1
1
  import { computed } from 'vue'
2
2
  import { useReactiveSlots } from '@modular-vue/runtime'
3
3
  import { groupCommands, groupSidebar, sortToolbar } from '~/modular/nav-contributions'
4
+ import { EXTERNAL_TOOL_UNAVAILABLE_KEYS, projectExternalTools } from '~/modular/external-tools'
5
+ import { toMetadataBag } from '~/modular/workspace-metadata'
6
+ import type { ExternalToolContext, ExternalToolContribution } from '~/modular/external-tools'
4
7
  import type {
5
8
  AppSlots,
6
9
  CommandGroup,
@@ -23,6 +26,14 @@ import type {
23
26
  export function useNavContributions() {
24
27
  const slots = useReactiveSlots<AppSlots>()
25
28
  const ui = useUiStore()
29
+ // Resolved through the Nuxt app's global i18n instance rather than `useI18n()` (which needs
30
+ // an active component instance) — the same handle, and the same reason, as
31
+ // `usePipelineErrorToast`: nothing about this composable should depend on WHERE it is called.
32
+ const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
33
+ const toast = useToast()
34
+ const auth = useAuthStore()
35
+ const workspace = useWorkspaceStore()
36
+ const workspaceSettings = useWorkspaceSettingsStore()
26
37
 
27
38
  // First-party action ids → host handlers. Typed as an exhaustive
28
39
  // `Record<NavActionId, …>`, so a catalog `action` with no handler (or a handler
@@ -65,7 +76,86 @@ export function useNavContributions() {
65
76
  if (item.action) actions[item.action]?.()
66
77
  }
67
78
 
68
- const all = computed<NavContribution[]>(() => slots.value.nav ?? [])
79
+ /**
80
+ * The stored metadata bag, re-hung on a null prototype. A resolver is a DEPLOYMENT'S own code
81
+ * writing `ctx.metadata.gameId`, so the object it reads has to answer `undefined` for an
82
+ * unfilled field whatever that field is called — `constructor` and `toString` both pass the
83
+ * key pattern, and on a plain object both read as an inherited function. A `computed` rather
84
+ * than a copy per read, so the reference stays stable between settings changes.
85
+ */
86
+ const externalToolMetadata = computed(() => toMetadataBag(workspaceSettings.settings.metadata))
87
+
88
+ /**
89
+ * The invocation context an external tool's resolver reads. GETTERS, not a captured snapshot,
90
+ * so a resolver called at click time sees the workspace/metadata as they are NOW — a teammate
91
+ * can fill in the field the tool needs while this sidebar is open, and the click must then
92
+ * work rather than repeat a message about a fix that already happened. (They also keep the
93
+ * projection below reactive: reading a store inside the computed tracks it.)
94
+ */
95
+ const externalToolContext: ExternalToolContext = {
96
+ get userId() {
97
+ return auth.user?.id ?? null
98
+ },
99
+ get userEmail() {
100
+ return auth.user?.email ?? null
101
+ },
102
+ get workspaceId() {
103
+ return workspace.workspaceId ?? ''
104
+ },
105
+ get workspaceName() {
106
+ return workspace.activeWorkspace?.name ?? ''
107
+ },
108
+ get metadata() {
109
+ return externalToolMetadata.value
110
+ },
111
+ }
112
+
113
+ /**
114
+ * Registered external tools, projected onto nav contributions. The `externalTools` slot is
115
+ * already RBAC/tier-filtered by `navSlotFilter`, exactly like `nav`.
116
+ *
117
+ * A tool that can't currently resolve stays in the list and explains itself on click: the
118
+ * person looking at the sidebar is usually the one who can fix it (fill in the field), and
119
+ * hiding it would make an unconfigured workspace look like a deployment that never
120
+ * registered the tool.
121
+ */
122
+ const externalToolItems = computed<NavContribution[]>(() =>
123
+ projectExternalTools(
124
+ (slots.value.externalTools ?? []) as ExternalToolContribution[],
125
+ externalToolContext,
126
+ {
127
+ // A separate browsing context, with `noopener` so the opened page cannot reach back
128
+ // into this one through `window.opener`.
129
+ open: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
130
+ onUnavailable: (resolution, tool) => {
131
+ // A resolver that threw is the one refusal the toast can't fully explain: the person
132
+ // reading it can't act on a stack trace, and the deployment author who can isn't
133
+ // here. So the message says which tool is broken and the cause goes to the console —
134
+ // unconditionally, not behind `import.meta.dev`, because this is an exception being
135
+ // absorbed and the deployment debugging it is a built one.
136
+ if (resolution.reason === 'resolver-failed') {
137
+ console.error(
138
+ `[cat-factory] external tool "${tool.id}" URL resolver threw`,
139
+ resolution.cause,
140
+ )
141
+ }
142
+ toast.add({
143
+ title: t('externalTools.unavailable.title', { tool: tool.title }),
144
+ description: t(EXTERNAL_TOOL_UNAVAILABLE_KEYS[resolution.reason], {
145
+ fields: resolution.missing.join(', '),
146
+ }),
147
+ icon: 'i-lucide-triangle-alert',
148
+ color: 'warning',
149
+ })
150
+ },
151
+ },
152
+ ).map((item) => item.contribution),
153
+ )
154
+
155
+ const all = computed<NavContribution[]>(() => [
156
+ ...(slots.value.nav ?? []),
157
+ ...externalToolItems.value,
158
+ ])
69
159
 
70
160
  /** Grouped + ordered sidebar sections, empty sections dropped. */
71
161
  const sidebarGroups = computed<SidebarGroup[]>(() => groupSidebar(all.value))
@@ -220,6 +220,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
220
220
  titleKey: 'errors.conflict.title.pipeline_schedule_requires_recurring',
221
221
  descriptionKey: 'errors.conflict.description.pipeline_schedule_requires_recurring',
222
222
  },
223
+ foundational_service_exists: {
224
+ titleKey: 'errors.conflict.title.foundational_service_exists',
225
+ descriptionKey: 'errors.conflict.description.foundational_service_exists',
226
+ },
223
227
  pipeline_schedule_intake_unconfigured: {
224
228
  titleKey: 'errors.conflict.title.pipeline_schedule_intake_unconfigured',
225
229
  descriptionKey: 'errors.conflict.description.pipeline_schedule_intake_unconfigured',
@@ -56,16 +56,18 @@ export default defineNuxtPlugin(() => {
56
56
 
57
57
  ## The landed seams
58
58
 
59
- | Seam | Slot key | Entry shape | Host |
60
- | ----------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
61
- | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
62
- | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
63
- | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
64
- | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
65
- | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
66
- | Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
67
- | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
68
- | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
59
+ | Seam | Slot key | Entry shape | Host |
60
+ | ----------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
61
+ | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
62
+ | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
63
+ | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
64
+ | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
65
+ | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
66
+ | Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
67
+ | External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
68
+ | Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }` | the Metadata tab of Workspace settings |
69
+ | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
70
+ | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
69
71
 
70
72
  A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
71
73
  (the shipped default) exactly as it does for the first-party destinations — see
@@ -103,6 +105,70 @@ show the panel, and `order` places it among the built-ins. Your panel component
103
105
  selected block via `usePanelSubject<Block>()` (`@modular-vue/core`). `when` must tolerate a
104
106
  nullish subject (the boot-time validation resolve passes `null`).
105
107
 
108
+ ### External tools + workspace metadata (`externalTools`, `workspaceMetadataFields`)
109
+
110
+ Put your OWN web applications — a map editor, an asset pipeline, an admin console — in the
111
+ sidebar's **External tools** section, and open each one _already scoped to what the user is
112
+ looking at_. That second half is the point of the seam; a static link needs no registration.
113
+
114
+ ```ts
115
+ externalTools: [
116
+ {
117
+ id: 'acme:map-editor',
118
+ title: 'Map editor', // literal copy: a tool's name is DATA, not a key
119
+ description: 'Edit the level geometry for this project.',
120
+ icon: 'i-lucide-map',
121
+ requiredMetadata: ['gameId'],
122
+ url: (ctx) => {
123
+ // Build, don't splice: every value here is operator-typed text (see below).
124
+ const url = new URL('https://maps.acme.dev/edit')
125
+ url.searchParams.set('game', ctx.metadata.gameId ?? '')
126
+ url.searchParams.set('ws', ctx.workspaceId)
127
+ return url.toString()
128
+ },
129
+ },
130
+ ],
131
+ workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork' }],
132
+ ```
133
+
134
+ - **`url` is a string or a RESOLVER** `(ctx) => string | null`. The context carries `userId`,
135
+ `userEmail`, `workspaceId`, `workspaceName` and `metadata` — the custom workspace fields you
136
+ declared. It is read at CLICK time, so a value a teammate fills in while the sidebar is open
137
+ takes effect without a reload.
138
+ - **Clicking opens a separate page** (`target=_blank`, `noopener`). The resolved URL must be
139
+ `http(s)`: anything else is refused rather than handed to the browser, because the string
140
+ reaches `window.open` and a `javascript:` URL would run in the SPA's own origin.
141
+ - **Declare `requiredMetadata` for the fields your resolver needs.** An unconfigured workspace
142
+ then gets "fill in `gameId` on the Metadata tab" instead of a generic failure — and the tool
143
+ stays LISTED, because the person looking at the sidebar is usually the one who can fix it. A
144
+ resolver that returns `null` reports separately ("this tool gave no address"), since that one
145
+ is yours to fix, not the operator's.
146
+ - **Treat every `ctx.metadata` value as untrusted input.** A workspace admin types these in, so a
147
+ value is operator-supplied text that happens to be length-bounded — not a constant you chose.
148
+ Set it as a query parameter or an `encodeURIComponent`'d path segment, as above. Never build the
149
+ ORIGIN from one: `` `https://${ctx.metadata.region}.acme.dev` `` with `region` set to
150
+ `evil.com/x?a=` resolves to a URL on someone else's host, and the `http(s)` allow-list cannot
151
+ tell that apart from the link you meant.
152
+ - **A resolver that THROWS costs only its own item.** It is caught and reported as a fourth
153
+ reason (`resolver-failed`) with the cause logged to the console — the sidebar, the palette and
154
+ the toolbar all render from one catalog, so an uncaught throw would otherwise blank all three.
155
+ Do not rely on it: `requiredMetadata` is how you say a field must be there.
156
+ - **`gate` and `advanced`** work exactly as on a `nav` entry; both must pass.
157
+
158
+ **The metadata half** is a deployment-declared FIELD list (here) whose VALUES are per workspace,
159
+ typed in under _Workspace settings → Metadata_ and persisted on the workspace settings row. The
160
+ tab appears only where a deployment declares fields. Keys must be identifier-shaped
161
+ (`^[A-Za-z][A-Za-z0-9_.-]{0,63}$` — the backend refuses anything else); a malformed or duplicate
162
+ key is dropped with a dev-console warning rather than rendered. `type: 'select'` renders a picker
163
+ over your `options`; everything is stored as a string.
164
+
165
+ Two rules the editor keeps, and any other writer of the bag should too: a CLEARED field drops its
166
+ key (so "unset" never reads as "set to nothing" in a resolver), and a save carries through any
167
+ stored key the current build does not declare — the update replaces the whole bag, so a value
168
+ written under a field you have since retired must not be deleted by an unrelated save.
169
+
170
+ Values are readable anywhere in the SPA via `useWorkspaceSettingsStore().settings.metadata`.
171
+
106
172
  ### Custom task types (`taskTypes`)
107
173
 
108
174
  Model a proprietary work item — an "incident", "pentest", "compliance-audit" — as a first-class