js-kompo-utils 1.0.17 → 1.0.19

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.
@@ -0,0 +1,2093 @@
1
+ # VlLazyHierarchy — Generic Config-Driven Render Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Ship a fully generic lazy-loaded tree component whose every visual detail — node content, pills, role buttons, level colors, row actions — is composed in PHP and delivered to the Vue component as a nested kompo element tree per node. Specialized hierarchies (team/role switcher, and any future hierarchical selector) are built purely by writing a PHP `LazyHierarchySourceInterface` + a node-composition factory; no `.vue` file is ever touched.
6
+
7
+ **Architecture:**
8
+ - `VlLazyHierarchy.vue` is a pure infrastructure component. It handles state (tree cache, expansion, pagination, search, modes, dropdown/inline display), fetches payloads from two server endpoints, and renders each node's `render` field via kompo's dynamic `<component :is="'Vl' + vueComponent">` mechanism — identical to `VlCollapsible`'s `titleEl`/`elements` pattern.
9
+ - The server emits nodes with only five fields — `{id, parentId, hasChildren, isCurrent, render}` — where `render` is itself a serialized kompo element tree (a `_Flex` composing `_Html` labels, colored pills, and `_Button`s with their own `->post()->run('() => location.reload()')` actions).
10
+ - The team/role switcher provides a concrete `NodeFactory` + `Source` + `Element` triplet that recomposes the exact current visual output using utility classes on composed elements — no cross-repo CSS.
11
+
12
+ **Tech Stack:** Vue 2 (kompo convention), PHP 8.1+ / Laravel, kompo framework. Three repos edited:
13
+ - `C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils` — Vue component rewrite
14
+ - `C:/Users/jkend/Documents/Projects/kompo/utils` — PHP `LazyHierarchy` element trim
15
+ - `C:/Users/jkend/Documents/Projects/kompo/auth` — team-switcher factory/provider/element rewrite
16
+
17
+ ---
18
+
19
+ ## Design Summary
20
+
21
+ A hierarchy node is a wire object:
22
+
23
+ ```
24
+ {
25
+ id: string // "team-123"
26
+ parentId: string | null
27
+ hasChildren: bool
28
+ isCurrent: bool // drives --current highlight in chrome
29
+ render: KompoElement // composed PHP tree — name, pills, buttons
30
+ }
31
+ ```
32
+
33
+ `VlLazyHierarchy.vue` renders each row as:
34
+
35
+ ```vue
36
+ <div class="lazy-hierarchy-node" :class="{ '--current': node.isCurrent }">
37
+ <div class="lazy-hierarchy-node__row" :style="indentStyle">
38
+ <button v-if="hasChildren" class="__toggle" @click="toggle">[chevron]</button>
39
+ <span v-else class="__toggle-spacer"></span>
40
+ <component v-bind="kompoElementAttributes(node.render, item)" />
41
+ </div>
42
+ </div>
43
+ ```
44
+
45
+ Interactive elements (role pills, go buttons) are standard kompo `_Button` elements; their HTTP and post-success reload are handled by kompo's built-in action machinery, not by a hierarchy-level action dispatcher.
46
+
47
+ Level palette (for the team switcher, applied via Tailwind-ish utility classes on `_Html` pills):
48
+ - National → `bg-[rgb(255,180,0)]` (yellow)
49
+ - District → `bg-[rgb(0,146,67)]` (green)
50
+ - Group / Team → `bg-[rgb(0,44,222)]` (blue)
51
+ - Unit → `bg-[rgb(130,55,255)]` (purple)
52
+ - Committee → `bg-[rgb(255,180,0)]` (yellow)
53
+ - Default → `bg-[var(--lazy-hierarchy-primary)]`
54
+
55
+ ---
56
+
57
+ ## Task 1 — Rewrite `VlLazyHierarchy.vue`
58
+
59
+ **Files:**
60
+ - Rewrite: `C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils/components/VlLazyHierarchy.vue` (full replacement)
61
+
62
+ **Step 1: Replace the whole file with the content below**
63
+
64
+ ```vue
65
+ <template>
66
+ <component :is="tag" v-bind="rootAttrs" :class="rootClasses" ref="root" @keyup.esc="closePanel">
67
+ <slot v-bind="slotProps">
68
+ <button v-if="isDropdown" type="button" class="lazy-hierarchy__trigger" :class="uiClasses.trigger" @click="togglePanel">
69
+ <span class="lazy-hierarchy__trigger-label">{{ triggerLabel }}</span>
70
+ <svg class="lazy-hierarchy__trigger-icon" :class="{ 'lazy-hierarchy__trigger-icon--open': open }" viewBox="0 0 20 20" aria-hidden="true">
71
+ <path d="M5 7.5l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
72
+ </svg>
73
+ </button>
74
+
75
+ <div v-if="panelOpen" class="lazy-hierarchy__panel" :class="uiClasses.panel">
76
+ <div v-if="hasControls" class="lazy-hierarchy__top">
77
+ <div v-if="searchEnabled" class="vlInputWrapper lazy-hierarchy__search-wrap" :class="uiClasses.searchWrapper">
78
+ <input v-model="search" class="vlFormControl lazy-hierarchy__search" :class="uiClasses.searchInput" type="search" :placeholder="searchPlaceholder" />
79
+ </div>
80
+
81
+ <div v-if="modeOptionsList.length" class="vlRadio lazy-hierarchy__modes" :class="uiClasses.modes">
82
+ <div
83
+ v-for="modeOption in modeOptionsList"
84
+ :key="modeOption.value"
85
+ class="lazy-hierarchy__mode"
86
+ :class="[uiClasses.mode, { 'lazy-hierarchy__mode--active': mode === modeOption.value }]"
87
+ @click="mode = modeOption.value"
88
+ >
89
+ <div class="vlInputWrapper lazy-hierarchy__radio-control">
90
+ <input v-model="mode" type="radio" :value="modeOption.value" :id="modeRadioId(modeOption.value)" />
91
+ <span class="lazy-hierarchy__radio-dot" aria-hidden="true"></span>
92
+ </div>
93
+ <label class="vlFormLabel lazy-hierarchy__mode-label" :for="modeRadioId(modeOption.value)">{{ modeOption.label }}</label>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="lazy-hierarchy__body mini-scroll" :class="uiClasses.body">
99
+ <div v-if="isTreeLoading && !hasNodes" class="lazy-hierarchy__status">
100
+ <span class="lazy-hierarchy__status-label">
101
+ <span class="lazy-hierarchy__spinner" aria-hidden="true"></span>
102
+ <span>{{ labels.loading }}</span>
103
+ </span>
104
+ <span v-for="index in 5" :key="index" class="lazy-hierarchy__skeleton-row" aria-hidden="true">
105
+ <span class="lazy-hierarchy__skeleton-name"></span>
106
+ <span class="lazy-hierarchy__skeleton-pill"></span>
107
+ </span>
108
+ </div>
109
+ <div v-else-if="error" class="lazy-hierarchy__status lazy-hierarchy__status--error">{{ labels.error }}</div>
110
+ <div v-else-if="bootstrapped && rootNodeIds.length === 0" class="lazy-hierarchy__status">{{ labels.empty }}</div>
111
+
112
+ <vl-lazy-tree v-else v-bind="treeProps" @toggle="toggleNode" @load-more="loadMoreFromTree">
113
+ <template v-slot:node="item">
114
+ <div class="lazy-hierarchy-node" :class="{ 'lazy-hierarchy-node--current': item.node.isCurrent }">
115
+ <div class="lazy-hierarchy-node__row" :class="uiClasses.nodeRow" :style="item.indentStyle">
116
+ <button
117
+ v-if="item.hasChildren"
118
+ type="button"
119
+ class="lazy-hierarchy-node__toggle"
120
+ :aria-expanded="item.isExpanded ? 'true' : 'false'"
121
+ @click.stop="item.toggle"
122
+ >
123
+ <svg class="lazy-hierarchy-node__chevron" :class="{ 'lazy-hierarchy-node__chevron--open': item.isExpanded }" viewBox="0 0 20 20" aria-hidden="true">
124
+ <path d="M7.5 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
125
+ </svg>
126
+ </button>
127
+ <span v-else class="lazy-hierarchy-node__toggle-spacer"></span>
128
+
129
+ <component v-bind="kompoElementAttributes(item.node.render, item)" class="lazy-hierarchy-node__content" />
130
+ </div>
131
+ </div>
132
+ </template>
133
+
134
+ <template v-slot:loading="item">
135
+ <div :key="item.key" class="lazy-hierarchy-node__loading" :style="item.indentStyle">
136
+ <span class="lazy-hierarchy__skeleton-row" aria-hidden="true">
137
+ <span class="lazy-hierarchy__skeleton-name"></span>
138
+ <span class="lazy-hierarchy__skeleton-pill"></span>
139
+ </span>
140
+ </div>
141
+ </template>
142
+
143
+ <template v-slot:load-more="item">
144
+ <vl-infinite-load
145
+ :key="item.key"
146
+ class="lazy-hierarchy__show-more"
147
+ :button-class="uiClasses.showMore"
148
+ :loading="item.isLoading"
149
+ :disabled="item.isLoading"
150
+ :label="labels.showMore"
151
+ :loading-label="labels.loading"
152
+ @visible="item.loadMore"
153
+ />
154
+ </template>
155
+ </vl-lazy-tree>
156
+ </div>
157
+ </div>
158
+ </slot>
159
+ </component>
160
+ </template>
161
+
162
+ <script>
163
+ import Other from 'vue-kompo/js/form/mixins/Other'
164
+ import VlInfiniteLoad from './VlInfiniteLoad.vue'
165
+ import VlLazyTree from './VlLazyTree.vue'
166
+
167
+ const ROOT_KEY = 'root'
168
+
169
+ const configValue = (vm, key) => vm.vkompo && vm.vkompo.config ? vm.vkompo.config[key] : undefined
170
+
171
+ export default {
172
+ name: 'VlLazyHierarchy',
173
+ inheritAttrs: false,
174
+ mixins: [Other],
175
+ components: { VlInfiniteLoad, VlLazyTree },
176
+ props: {
177
+ vkompo: { type: Object, default: null },
178
+ kompoid: { type: String, default: '' },
179
+ kompoinfo: { type: String, default: '' },
180
+ tag: { type: String, default: 'div' },
181
+ rootKey: { type: String, default: ROOT_KEY },
182
+ parentParam: { type: String, default: 'parent_id' },
183
+ cursorParam: { type: String, default: 'cursor' },
184
+ limit: { type: Number, default: 20 },
185
+ lookahead: { type: Number, default: 80 },
186
+ autoLoad: { type: Boolean, default: true },
187
+ deferLoad: { type: Boolean, default: false },
188
+ baseIndent: { type: Number, default: 1 },
189
+ indentStep: { type: Number, default: 0.9 },
190
+ maxIndentDepth: { type: Number, default: 5 },
191
+ },
192
+ data() {
193
+ const modes = configValue(this, 'modes') || {}
194
+ const modeOptions = modes.options || {}
195
+
196
+ return {
197
+ open: false,
198
+ mode: modes.default || Object.keys(modeOptions)[0] || '',
199
+ search: '',
200
+ appliedSearch: '',
201
+ searchTimer: null,
202
+ nodesById: {},
203
+ childrenByParent: { [this.resolvedRootKey]: [] },
204
+ pagingByParent: {},
205
+ expanded: {},
206
+ loadingParents: {},
207
+ loadingInitial: false,
208
+ bootstrapped: false,
209
+ bootstrapPromise: null,
210
+ error: '',
211
+ requestSeq: 0,
212
+ }
213
+ },
214
+ computed: {
215
+ rootAttrs() {
216
+ return {
217
+ ...this.$attrs,
218
+ ...(this.vkompo && this.vkompo.id ? { id: this.vkompo.id } : {}),
219
+ style: [this.vkompo?.style, this.$attrs.style].filter(Boolean).join('; '),
220
+ }
221
+ },
222
+ rootClasses() {
223
+ return ['vl-lazy-hierarchy', 'lazy-hierarchy', `lazy-hierarchy--${this.displayMode}`, this.vkompo?.class].filter(Boolean)
224
+ },
225
+ displayMode() { return this.configOr('displayMode', 'inline') },
226
+ isDropdown() { return this.displayMode !== 'inline' },
227
+ panelOpen() { return this.open || !this.isDropdown },
228
+ hasControls() { return this.searchEnabled || this.modeOptionsList.length > 0 },
229
+ triggerLabel() { return this.configValue('triggerLabel') || '' },
230
+ searchConfig() { return this.configOr('search', {}) },
231
+ searchEnabled() { return !!this.searchConfig.enabled },
232
+ searchPlaceholder() { return this.searchConfig.placeholder || this.labels.searchPlaceholder },
233
+ searchParam() { return this.searchConfig.param || 'search' },
234
+ searchDebounce() { return this.searchConfig.debounce || 350 },
235
+ modeConfig() { return this.configOr('modes', {}) },
236
+ modeParam() { return this.modeConfig.param || 'mode' },
237
+ modeOptionsList() {
238
+ return Object.entries(this.modeConfig.options || {}).map(([value, label]) => ({ value, label }))
239
+ },
240
+ labels() {
241
+ const labels = this.configOr('labels', {})
242
+ return {
243
+ searchPlaceholder: labels.searchPlaceholder || 'Search',
244
+ loading: labels.loading || 'Loading...',
245
+ empty: labels.empty || 'No items found',
246
+ error: labels.error || 'Unable to load items',
247
+ showMore: labels.showMore || 'Show more',
248
+ }
249
+ },
250
+ uiClasses() { return this.configOr('classes', {}) },
251
+ resolvedRootKey() { return this.configOr('rootKey', this.rootKey) },
252
+ resolvedBootstrapUrl() { return this.configValue('bootstrapUrl') },
253
+ resolvedNodesUrl() { return this.configValue('nodesUrl') },
254
+ resolvedRequestParams() {
255
+ const params = { ...(this.configValue('params') || {}) }
256
+ if (this.searchEnabled) params[this.searchParam] = this.appliedSearch
257
+ if (this.modeOptionsList.length && this.mode) params[this.modeParam] = this.mode
258
+ return params
259
+ },
260
+ resolvedLimit() { return this.configValue('limit') || this.limit },
261
+ resolvedLookahead() { return this.configValue('lookahead') || this.lookahead },
262
+ resolvedBaseIndent() { return this.configOr('baseIndent', this.baseIndent) },
263
+ resolvedIndentStep() { return this.configOr('indentStep', this.indentStep) },
264
+ resolvedMaxIndentDepth() { return this.configOr('maxIndentDepth', this.maxIndentDepth) },
265
+ resolvedParentParam() { return this.configValue('parentParam') || this.parentParam },
266
+ resolvedCursorParam() { return this.configValue('cursorParam') || this.cursorParam },
267
+ resolvedAutoLoad() {
268
+ const c = this.configValue('autoLoad')
269
+ return c === undefined ? this.autoLoad : !!c
270
+ },
271
+ resolvedDeferLoad() {
272
+ const c = this.configValue('deferLoad')
273
+ return c === undefined ? this.deferLoad : !!c
274
+ },
275
+ rootNodeIds() { return this.childrenByParent[this.resolvedRootKey] || [] },
276
+ hasNodes() { return Object.keys(this.nodesById).length > 0 },
277
+ isTreeLoading() { return this.loadingInitial || !!this.bootstrapPromise },
278
+ treeProps() {
279
+ return {
280
+ rootKey: this.resolvedRootKey,
281
+ rootNodeIds: this.rootNodeIds,
282
+ nodesById: this.nodesById,
283
+ childrenByParent: this.childrenByParent,
284
+ pagingByParent: this.pagingByParent,
285
+ expanded: this.expanded,
286
+ loadingParents: this.loadingParents,
287
+ baseIndent: this.resolvedBaseIndent,
288
+ indentStep: this.resolvedIndentStep,
289
+ maxIndentDepth: this.resolvedMaxIndentDepth,
290
+ loadingLabel: this.labels.loading,
291
+ showMoreLabel: this.labels.showMore,
292
+ }
293
+ },
294
+ slotProps() {
295
+ return {
296
+ ...this.treeProps,
297
+ treeProps: this.treeProps,
298
+ rootNodeIds: this.rootNodeIds,
299
+ hasNodes: this.hasNodes,
300
+ isTreeLoading: this.isTreeLoading,
301
+ bootstrapped: this.bootstrapped,
302
+ error: this.error,
303
+ ensureBootstrap: this.ensureBootstrap,
304
+ reloadTree: this.reloadTree,
305
+ resetTree: this.resetTree,
306
+ toggleNode: this.toggleNode,
307
+ loadChildren: this.loadChildren,
308
+ loadMore: this.loadMore,
309
+ loadMoreRoot: this.loadMoreRoot,
310
+ loadMoreFromTree: this.loadMoreFromTree,
311
+ }
312
+ },
313
+ },
314
+ watch: {
315
+ mode() {
316
+ if (this.bootstrapped || this.bootstrapPromise) this.reloadTree()
317
+ },
318
+ search() {
319
+ clearTimeout(this.searchTimer)
320
+ this.searchTimer = setTimeout(() => {
321
+ this.appliedSearch = this.search
322
+ if (this.bootstrapped || this.bootstrapPromise) this.reloadTree()
323
+ }, this.searchDebounce)
324
+ },
325
+ },
326
+ mounted() {
327
+ document.addEventListener('click', this.onDocumentClick)
328
+ if (!this.resolvedAutoLoad) return
329
+ if (this.resolvedDeferLoad) { this.deferBootstrap(); return }
330
+ this.ensureBootstrap(true)
331
+ },
332
+ beforeDestroy() {
333
+ document.removeEventListener('click', this.onDocumentClick)
334
+ clearTimeout(this.searchTimer)
335
+ },
336
+ methods: {
337
+ configValue(key) { return configValue(this, key) },
338
+ configOr(key, fallback) {
339
+ const v = this.configValue(key)
340
+ return v === undefined ? fallback : v
341
+ },
342
+ modeRadioId(value) { return `lazy-hierarchy-${this._uid}-${value}` },
343
+ togglePanel() {
344
+ if (!this.isDropdown) return
345
+ this.open = !this.open
346
+ if (this.open) this.ensureBootstrap(true)
347
+ },
348
+ closePanel() { if (this.isDropdown) this.open = false },
349
+ onDocumentClick(event) {
350
+ if (!this.isDropdown || !this.open || !this.$refs.root || this.$refs.root.contains(event.target)) return
351
+ this.open = false
352
+ },
353
+ deferBootstrap() {
354
+ const run = () => this.ensureBootstrap(false)
355
+ if (window.requestIdleCallback) { window.requestIdleCallback(run, { timeout: 1200 }); return }
356
+ setTimeout(run, 250)
357
+ },
358
+ reloadTree(showLoading = true) {
359
+ this.resetTree()
360
+ return this.ensureBootstrap(showLoading)
361
+ },
362
+ resetTree() {
363
+ this.nodesById = {}
364
+ this.childrenByParent = { [this.resolvedRootKey]: [] }
365
+ this.pagingByParent = {}
366
+ this.expanded = {}
367
+ this.loadingParents = {}
368
+ this.bootstrapped = false
369
+ this.bootstrapPromise = null
370
+ this.error = ''
371
+ },
372
+ ensureBootstrap(showLoading = true) {
373
+ if (this.bootstrapped) return Promise.resolve()
374
+ if (this.bootstrapPromise) {
375
+ if (showLoading) this.loadingInitial = true
376
+ return this.bootstrapPromise
377
+ }
378
+ const seq = ++this.requestSeq
379
+ if (showLoading) this.loadingInitial = true
380
+ this.error = ''
381
+ this.bootstrapPromise = this.getJson(this.resolvedBootstrapUrl, this.baseParams())
382
+ .then(payload => {
383
+ if (seq !== this.requestSeq) return
384
+ this.mergePayload(payload)
385
+ this.bootstrapped = true
386
+ this.$emit('loaded', payload)
387
+ })
388
+ .catch(error => this.handleError(error, seq))
389
+ .finally(() => {
390
+ if (seq === this.requestSeq) {
391
+ this.loadingInitial = false
392
+ this.bootstrapPromise = null
393
+ }
394
+ })
395
+ return this.bootstrapPromise
396
+ },
397
+ baseParams(extra = {}) {
398
+ return { ...this.resolvedRequestParams, limit: this.resolvedLimit, lookahead: this.resolvedLookahead, ...extra }
399
+ },
400
+ toggleNode(node) {
401
+ if (!node || !node.id || !node.hasChildren) return
402
+ if (this.expanded[node.id]) { this.$set(this.expanded, node.id, false); return }
403
+ this.$set(this.expanded, node.id, true)
404
+ if (!Object.prototype.hasOwnProperty.call(this.childrenByParent, node.id)) this.loadChildren(node)
405
+ },
406
+ loadChildren(node) {
407
+ if (!node || this.loadingParents[node.id]) return Promise.resolve()
408
+ this.$set(this.loadingParents, node.id, true)
409
+ return this.getJson(this.resolvedNodesUrl, this.baseParams({ [this.resolvedParentParam]: node.teamId || node.id }))
410
+ .then(payload => this.mergePayload(payload))
411
+ .catch(error => this.handleError(error))
412
+ .finally(() => this.$set(this.loadingParents, node.id, false))
413
+ },
414
+ loadMore(node) {
415
+ if (!node || !node.id || this.loadingParents[node.id]) return Promise.resolve()
416
+ const paging = this.pagingByParent[node.id]
417
+ if (!paging || paging.nextCursor === null) return Promise.resolve()
418
+ this.$set(this.loadingParents, node.id, true)
419
+ return this.getJson(this.resolvedNodesUrl, this.baseParams({
420
+ [this.resolvedParentParam]: node.teamId || node.id,
421
+ [this.resolvedCursorParam]: paging.nextCursor,
422
+ }))
423
+ .then(payload => this.mergePayload(payload, node.id))
424
+ .catch(error => this.handleError(error))
425
+ .finally(() => this.$set(this.loadingParents, node.id, false))
426
+ },
427
+ loadMoreRoot() {
428
+ const rootKey = this.resolvedRootKey
429
+ const paging = this.pagingByParent[rootKey]
430
+ if (!paging || paging.nextCursor === null || this.loadingParents[rootKey]) return Promise.resolve()
431
+ this.$set(this.loadingParents, rootKey, true)
432
+ return this.getJson(this.resolvedNodesUrl, this.baseParams({ [this.resolvedCursorParam]: paging.nextCursor }))
433
+ .then(payload => this.mergePayload(payload, rootKey))
434
+ .catch(error => this.handleError(error))
435
+ .finally(() => this.$set(this.loadingParents, rootKey, false))
436
+ },
437
+ loadMoreFromTree(item) {
438
+ if (!item) return Promise.resolve()
439
+ return item.root ? this.loadMoreRoot() : this.loadMore(item.parentNode)
440
+ },
441
+ mergePayload(payload, appendParentKey = null) {
442
+ Object.entries(payload.nodes || {}).forEach(([id, node]) => { this.$set(this.nodesById, id, node) })
443
+ Object.entries(payload.children || {}).forEach(([parentKey, childIds]) => {
444
+ const existing = this.childrenByParent[parentKey] || []
445
+ const shouldAppend = appendParentKey === parentKey || (payload.paging && payload.paging[parentKey] && payload.paging[parentKey].append)
446
+ const next = shouldAppend ? this.unique([...existing, ...childIds]) : this.unique(childIds)
447
+ this.$set(this.childrenByParent, parentKey, next)
448
+ })
449
+ Object.entries(payload.paging || {}).forEach(([parentKey, paging]) => { this.$set(this.pagingByParent, parentKey, paging) })
450
+ ;(payload.expandedIds || []).forEach(id => { this.$set(this.expanded, id, true) })
451
+ },
452
+ unique(values) { return [...new Set(values)] },
453
+ getJson(url, params = {}) {
454
+ const requestUrl = new URL(url, window.location.origin)
455
+ Object.entries(params).forEach(([key, value]) => {
456
+ if (value !== null && value !== undefined && value !== '') requestUrl.searchParams.set(key, value)
457
+ })
458
+ return fetch(requestUrl.toString(), { headers: this.requestHeaders(), credentials: 'same-origin' }).then(this.parseResponse)
459
+ },
460
+ requestHeaders(extra = {}) {
461
+ const kompoInfo = this.kompoInfoHeader()
462
+ return {
463
+ 'X-Requested-With': 'XMLHttpRequest',
464
+ ...(kompoInfo ? { 'X-Kompo-Info': kompoInfo } : {}),
465
+ ...extra,
466
+ }
467
+ },
468
+ kompoInfoHeader() {
469
+ if (this.kompoinfo) return this.kompoinfo
470
+ if (this.$_kompoInfo) return this.$_kompoInfo
471
+ if (this.kompoInfo) return this.kompoInfo
472
+ if (this.$kompo && this.kompoid && this.$_elKompoId) {
473
+ this.$kompo.vlGetKomponentInfo(this.kompoid, this.$_elKompoId)
474
+ if (this.kompoInfo) return this.kompoInfo
475
+ }
476
+ return ''
477
+ },
478
+ parseResponse(response) {
479
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
480
+ return response.json()
481
+ },
482
+ handleError(error, seq = this.requestSeq) {
483
+ if (seq !== this.requestSeq) return
484
+ this.error = error.message || this.labels.error
485
+ this.$emit('error', this.error)
486
+ },
487
+ kompoElementAttributes(component, item) {
488
+ return {
489
+ key: component.id || item.key,
490
+ is: this.vueTag(component),
491
+ vkompo: component,
492
+ kompoid: this.kompoid || this.$_elKompoId,
493
+ }
494
+ },
495
+ vueTag(component) { return 'Vl' + component.vueComponent },
496
+ },
497
+ }
498
+ </script>
499
+
500
+ <style>
501
+ .lazy-hierarchy {
502
+ --lazy-hierarchy-primary: var(--level1, var(--greenmain, var(--primary, currentColor)));
503
+ --lazy-hierarchy-primary-dark: var(--greendark, var(--primary-dark, var(--lazy-hierarchy-primary)));
504
+ --lazy-hierarchy-primary-soft: color-mix(in srgb, var(--lazy-hierarchy-primary) 9%, transparent);
505
+ --lazy-hierarchy-primary-soft-hover: color-mix(in srgb, var(--lazy-hierarchy-primary) 13%, transparent);
506
+ position: relative;
507
+ display: block;
508
+ color: var(--lazy-hierarchy-primary);
509
+ }
510
+
511
+ .lazy-hierarchy--dropdown { display: inline-flex; }
512
+
513
+ .lazy-hierarchy__trigger {
514
+ display: inline-flex;
515
+ align-items: center;
516
+ gap: 0.35rem;
517
+ border: 0;
518
+ background: transparent;
519
+ color: #1f2933;
520
+ font-size: 0.875rem;
521
+ font-weight: 600;
522
+ line-height: 1.2;
523
+ cursor: pointer;
524
+ padding: 0.25rem 0.35rem;
525
+ }
526
+
527
+ .lazy-hierarchy__trigger-label {
528
+ max-width: 12rem;
529
+ overflow: hidden;
530
+ text-overflow: ellipsis;
531
+ white-space: nowrap;
532
+ }
533
+
534
+ .lazy-hierarchy__trigger-icon {
535
+ width: 1rem;
536
+ height: 1rem;
537
+ flex: 0 0 1rem;
538
+ display: block;
539
+ color: var(--lazy-hierarchy-primary);
540
+ transform: translateY(0.05rem);
541
+ transform-origin: center;
542
+ transition: transform 0.2s ease;
543
+ }
544
+ .lazy-hierarchy__trigger-icon--open { transform: translateY(0.05rem) rotate(180deg); }
545
+
546
+ .lazy-hierarchy__panel {
547
+ position: absolute;
548
+ top: calc(100% + 0.45rem);
549
+ right: 0;
550
+ width: 33rem;
551
+ max-width: calc(100vw - 1rem);
552
+ max-height: min(28rem, calc(100vh - 4rem));
553
+ display: flex;
554
+ flex-direction: column;
555
+ background: #fff;
556
+ border: 1px solid #e7ece9;
557
+ border-radius: 8px;
558
+ box-shadow: 0 10px 24px rgba(17, 24, 39, 0.11);
559
+ z-index: 940;
560
+ overflow: hidden;
561
+ }
562
+ .lazy-hierarchy--inline .lazy-hierarchy__panel {
563
+ position: static;
564
+ width: 100%;
565
+ max-width: none;
566
+ max-height: none;
567
+ border: 0;
568
+ box-shadow: none;
569
+ }
570
+
571
+ .lazy-hierarchy__top {
572
+ display: flex;
573
+ align-items: flex-start;
574
+ gap: 1.25rem;
575
+ flex: 0 0 auto;
576
+ padding: 1.5rem;
577
+ border-bottom: 1px solid #edf1ef;
578
+ }
579
+
580
+ .lazy-hierarchy__search-wrap { min-width: 0; flex: 1 1 auto; margin: 0; }
581
+ .lazy-hierarchy__search { width: 100%; min-height: 2.35rem; font-size: 0.8125rem; }
582
+
583
+ .lazy-hierarchy__modes { display: flex; flex-direction: column; gap: 0.5rem; flex: 0 0 auto; }
584
+ .lazy-hierarchy__mode {
585
+ display: flex;
586
+ align-items: center;
587
+ color: #334155;
588
+ font-size: 0.75rem;
589
+ line-height: 1;
590
+ cursor: pointer;
591
+ white-space: nowrap;
592
+ min-height: 1.35rem;
593
+ }
594
+ .lazy-hierarchy__radio-control {
595
+ width: 1.15rem !important;
596
+ min-width: 1.15rem !important;
597
+ max-width: 1.15rem !important;
598
+ height: 1.15rem !important;
599
+ min-height: 1.15rem !important;
600
+ max-height: 1.15rem !important;
601
+ flex: 0 0 1.15rem;
602
+ display: inline-block;
603
+ position: relative;
604
+ margin-right: 0.5rem;
605
+ padding: 0;
606
+ background: #fff;
607
+ border: 0;
608
+ border-radius: 9999px;
609
+ box-shadow: none;
610
+ overflow: hidden;
611
+ }
612
+ .lazy-hierarchy__radio-control::after { display: none; }
613
+ .lazy-hierarchy__mode input {
614
+ position: absolute;
615
+ inset: 0;
616
+ z-index: 2;
617
+ width: 100%;
618
+ height: 100%;
619
+ margin: 0;
620
+ padding: 0;
621
+ appearance: none;
622
+ border: 0;
623
+ border-radius: 9999px;
624
+ background: transparent;
625
+ color: var(--lazy-hierarchy-primary);
626
+ cursor: pointer;
627
+ opacity: 0;
628
+ }
629
+ .lazy-hierarchy__radio-dot {
630
+ position: absolute;
631
+ inset: 0;
632
+ border: 1px solid #d9e3df;
633
+ border-radius: 9999px;
634
+ background: #fff;
635
+ pointer-events: none;
636
+ }
637
+ .lazy-hierarchy__radio-dot::after {
638
+ content: "";
639
+ position: absolute;
640
+ inset: 0.32rem;
641
+ border-radius: 9999px;
642
+ background: transparent;
643
+ }
644
+ .lazy-hierarchy__mode input:checked + .lazy-hierarchy__radio-dot {
645
+ border-color: var(--lazy-hierarchy-primary);
646
+ background: var(--lazy-hierarchy-primary);
647
+ }
648
+ .lazy-hierarchy__mode input:checked + .lazy-hierarchy__radio-dot::after { background: #fff; }
649
+ .lazy-hierarchy__radio-control:focus-within { box-shadow: 0 0 0 3px var(--lazy-hierarchy-primary-soft-hover); }
650
+ .lazy-hierarchy__mode-label { margin: 0; color: inherit; font-size: 0.75rem; line-height: 1; }
651
+ .lazy-hierarchy__mode--active { color: var(--lazy-hierarchy-primary); font-weight: 700; }
652
+
653
+ .lazy-hierarchy__body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0.45rem 0 0.75rem; }
654
+
655
+ .lazy-hierarchy__status {
656
+ display: flex;
657
+ flex-direction: column;
658
+ align-items: center;
659
+ justify-content: center;
660
+ gap: 0.65rem;
661
+ padding: 1.1rem 0.65rem;
662
+ color: #64748b;
663
+ font-size: 0.8125rem;
664
+ text-align: center;
665
+ }
666
+ .lazy-hierarchy__status--error { color: #b42318; }
667
+ .lazy-hierarchy__status-label { display: inline-flex; align-items: center; gap: 0.5rem; }
668
+
669
+ .lazy-hierarchy__spinner {
670
+ width: 0.9rem;
671
+ height: 0.9rem;
672
+ border: 2px solid currentColor;
673
+ border-right-color: transparent;
674
+ border-radius: 9999px;
675
+ animation: lazy-hierarchy-spin 0.65s linear infinite;
676
+ }
677
+
678
+ .lazy-hierarchy__sr-only {
679
+ position: absolute;
680
+ width: 1px;
681
+ height: 1px;
682
+ padding: 0;
683
+ overflow: hidden;
684
+ clip: rect(0, 0, 0, 0);
685
+ white-space: nowrap;
686
+ border: 0;
687
+ }
688
+
689
+ @keyframes lazy-hierarchy-spin { to { transform: rotate(360deg); } }
690
+
691
+ .lazy-hierarchy__skeleton-row {
692
+ width: 100%;
693
+ min-height: 2.35rem;
694
+ display: flex;
695
+ align-items: center;
696
+ gap: 0.45rem;
697
+ }
698
+ .lazy-hierarchy__skeleton-name,
699
+ .lazy-hierarchy__skeleton-pill {
700
+ display: inline-block;
701
+ height: 0.8rem;
702
+ border-radius: 9999px;
703
+ background: linear-gradient(90deg, #eef4f1 0%, #f8faf9 50%, #eef4f1 100%);
704
+ background-size: 180% 100%;
705
+ animation: lazy-hierarchy-skeleton 1.1s ease-in-out infinite;
706
+ }
707
+ .lazy-hierarchy__skeleton-name { flex: 1 1 auto; }
708
+ .lazy-hierarchy__skeleton-pill { width: 4.2rem; }
709
+ @keyframes lazy-hierarchy-skeleton {
710
+ 0% { background-position: 100% 0; }
711
+ 100% { background-position: -100% 0; }
712
+ }
713
+
714
+ .lazy-hierarchy-node__row {
715
+ min-height: 2.55rem;
716
+ display: flex;
717
+ align-items: center;
718
+ gap: 0.45rem;
719
+ padding-top: 0.2rem;
720
+ padding-right: 1rem;
721
+ padding-bottom: 0.2rem;
722
+ color: var(--lazy-hierarchy-primary);
723
+ }
724
+ .lazy-hierarchy-node--current > .lazy-hierarchy-node__row { background: var(--lazy-hierarchy-primary-soft); }
725
+ .lazy-hierarchy-node--current > .lazy-hierarchy-node__row:hover { background: var(--lazy-hierarchy-primary-soft-hover); }
726
+ .lazy-hierarchy-node__row:hover { background: #f8faf9; }
727
+
728
+ .lazy-hierarchy-node__content { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.45rem; }
729
+
730
+ .lazy-hierarchy-node__toggle,
731
+ .lazy-hierarchy-node__toggle-spacer {
732
+ width: 1rem;
733
+ height: 1.35rem;
734
+ flex: 0 0 1rem;
735
+ }
736
+ .lazy-hierarchy-node__toggle {
737
+ display: inline-flex;
738
+ align-items: center;
739
+ justify-content: center;
740
+ border: 0;
741
+ background: transparent;
742
+ color: var(--lazy-hierarchy-primary-dark);
743
+ line-height: 1;
744
+ cursor: pointer;
745
+ }
746
+ .lazy-hierarchy-node__chevron {
747
+ width: 0.9rem;
748
+ height: 0.9rem;
749
+ flex: 0 0 0.9rem;
750
+ transition: transform 0.2s ease;
751
+ }
752
+ .lazy-hierarchy-node__chevron--open { transform: rotate(90deg); }
753
+
754
+ .lazy-hierarchy-node__loading {
755
+ display: flex;
756
+ align-items: center;
757
+ padding: 0.25rem 0.55rem;
758
+ color: #64748b;
759
+ font-size: 0.75rem;
760
+ }
761
+
762
+ .lazy-hierarchy__show-more {
763
+ display: flex;
764
+ width: calc(100% - 1rem);
765
+ margin: 0.55rem 0.5rem 0.35rem;
766
+ border: 1px solid #dbe4df;
767
+ border-radius: 6px;
768
+ background: #fff;
769
+ color: var(--lazy-hierarchy-primary);
770
+ cursor: pointer;
771
+ font-size: 0.75rem;
772
+ font-weight: 700;
773
+ padding: 0.5rem;
774
+ }
775
+ .lazy-hierarchy__show-more:hover { background: #f5fbf7; }
776
+
777
+ .lazy-hierarchy button:disabled { cursor: wait; opacity: 0.65; }
778
+
779
+ @media (max-width: 640px) {
780
+ .lazy-hierarchy__panel {
781
+ top: calc(100% + 0.35rem);
782
+ right: -0.25rem;
783
+ width: calc(100vw - 1rem);
784
+ }
785
+ .lazy-hierarchy__top { flex-direction: column; }
786
+ .lazy-hierarchy__modes { flex-direction: row; }
787
+ }
788
+ </style>
789
+ ```
790
+
791
+ **Step 2: Run bundler sanity check**
792
+
793
+ Run: `cd "C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils" && npx webpack --mode development 2>&1 | tail -30`
794
+ Expected: bundle builds without syntax errors. If webpack isn't the build tool, check `package.json` scripts and run the equivalent (`npm run build` / `npm run dev`).
795
+
796
+ **Step 3: Verify the new file is syntactically valid by lint**
797
+
798
+ Run: if ESLint is configured (`.eslintrc` present), `npx eslint components/VlLazyHierarchy.vue`.
799
+ Expected: no parse errors. Style warnings acceptable.
800
+
801
+ ---
802
+
803
+ ## Task 2 — Trim `LazyHierarchy.php` to its generic surface
804
+
805
+ **Files:**
806
+ - Modify: `C:/Users/jkend/Documents/Projects/kompo/utils/src/Kompo/Elements/LazyHierarchy.php`
807
+
808
+ **Step 1: Remove `hierarchyAction()` method**
809
+
810
+ Open the file. Delete the entire method block (currently lines 205–217). This method exposed a framework-level action dispatcher that is no longer used — interactive elements inside each node's `render` carry their own actions.
811
+
812
+ **Step 2: Remove the initial `'classes' => []` / `'labels' => []` empty defaults that are now redundant**
813
+
814
+ Leave the `config([...])` call in `initialize()` intact but drop keys whose purpose no longer maps to any Vue-side consumer. Keep: `rootKey`, `parentParam`, `cursorParam`, `params`, `limit`, `lookahead`, `autoLoad`, `deferLoad`, `displayMode`, `classes`, `labels`. The component still reads all of those.
815
+
816
+ **Step 3: Confirm the full file reads**
817
+
818
+ ```php
819
+ <?php
820
+
821
+ namespace Condoedge\Utils\Kompo\Elements;
822
+
823
+ use Condoedge\Utils\Services\LazyHierarchy\LazyHierarchyRegistry;
824
+ use Kompo\Elements\Block;
825
+ use Kompo\Routing\RouteFinder;
826
+
827
+ class LazyHierarchy extends Block
828
+ {
829
+ public $vueComponent = 'LazyHierarchy';
830
+
831
+ public function initialize($label = '')
832
+ {
833
+ parent::initialize($label);
834
+
835
+ $this->config([
836
+ 'rootKey' => 'root',
837
+ 'parentParam' => 'parent_id',
838
+ 'cursorParam' => 'cursor',
839
+ 'params' => [],
840
+ 'limit' => 20,
841
+ 'lookahead' => 80,
842
+ 'autoLoad' => true,
843
+ 'deferLoad' => false,
844
+ 'displayMode' => 'inline',
845
+ 'classes' => [],
846
+ 'labels' => [],
847
+ ]);
848
+ }
849
+
850
+ public function source(string $sourceClass, array $store = [])
851
+ {
852
+ $sourceId = app(LazyHierarchyRegistry::class)->storeSource($sourceClass, $store);
853
+
854
+ return $this
855
+ ->hierarchySource('utils.lazy-hierarchy.bootstrap', 'utils.lazy-hierarchy.nodes')
856
+ ->hierarchyParam('_lazyHierarchyId', $sourceId)
857
+ ->config(['sourceClass' => $sourceClass]);
858
+ }
859
+
860
+ public function hierarchySource(string $bootstrapRoute, ?string $nodesRoute = null, $bootstrapParameters = null, $nodesParameters = null)
861
+ {
862
+ return $this->hierarchyUrls(
863
+ RouteFinder::guessRoute($bootstrapRoute, $bootstrapParameters),
864
+ RouteFinder::guessRoute($nodesRoute ?: $bootstrapRoute, $nodesParameters),
865
+ );
866
+ }
867
+
868
+ public function hierarchyUrls(string $bootstrapUrl, string $nodesUrl)
869
+ {
870
+ return $this->config(['bootstrapUrl' => $bootstrapUrl, 'nodesUrl' => $nodesUrl]);
871
+ }
872
+
873
+ public function hierarchyParams(array $params)
874
+ {
875
+ return $this->config(['params' => array_replace($this->config('params') ?: [], $params)]);
876
+ }
877
+
878
+ public function hierarchyParam(string $key, mixed $value)
879
+ {
880
+ return $this->hierarchyParams([$key => $value]);
881
+ }
882
+
883
+ public function hierarchyPaging(int $limit = 20, int $lookahead = 80)
884
+ {
885
+ return $this->config(['limit' => $limit, 'lookahead' => $lookahead]);
886
+ }
887
+
888
+ public function hierarchyParamNames(string $parentParam = 'parent_id', string $cursorParam = 'cursor')
889
+ {
890
+ return $this->config(['parentParam' => $parentParam, 'cursorParam' => $cursorParam]);
891
+ }
892
+
893
+ public function hierarchyRootKey(string $rootKey)
894
+ {
895
+ return $this->config(['rootKey' => $rootKey]);
896
+ }
897
+
898
+ public function hierarchyIndent(float $base = 1, float $step = 0.9, int $maxDepth = 5)
899
+ {
900
+ return $this->config(['baseIndent' => $base, 'indentStep' => $step, 'maxIndentDepth' => $maxDepth]);
901
+ }
902
+
903
+ public function hierarchyLabels(array $labels)
904
+ {
905
+ return $this->config(['labels' => array_replace($this->config('labels') ?: [], $labels)]);
906
+ }
907
+
908
+ public function hierarchyClasses(array $classes)
909
+ {
910
+ return $this->config(['classes' => array_replace($this->config('classes') ?: [], $classes)]);
911
+ }
912
+
913
+ public function loadDeferred(bool $defer = true)
914
+ {
915
+ return $this->config(['autoLoad' => true, 'deferLoad' => $defer]);
916
+ }
917
+
918
+ public function loadImmediately()
919
+ {
920
+ return $this->loadDeferred(false);
921
+ }
922
+
923
+ public function manualLoad()
924
+ {
925
+ return $this->config(['autoLoad' => false]);
926
+ }
927
+
928
+ public function dropdown(?string $triggerLabel = null)
929
+ {
930
+ return $this->config(array_filter([
931
+ 'displayMode' => 'dropdown',
932
+ 'triggerLabel' => $triggerLabel,
933
+ 'deferLoad' => true,
934
+ ], fn($value) => $value !== null));
935
+ }
936
+
937
+ public function inline()
938
+ {
939
+ return $this->config(['displayMode' => 'inline']);
940
+ }
941
+
942
+ public function searchable(?string $placeholder = null, string $param = 'search', int $debounce = 350)
943
+ {
944
+ return $this->config([
945
+ 'search' => [
946
+ 'enabled' => true,
947
+ 'placeholder' => $placeholder ?: __('Search'),
948
+ 'param' => $param,
949
+ 'debounce' => $debounce,
950
+ ],
951
+ ]);
952
+ }
953
+
954
+ public function modes(array $modes, string $param = 'mode', ?string $default = null)
955
+ {
956
+ return $this->config([
957
+ 'modes' => [
958
+ 'options' => $modes,
959
+ 'param' => $param,
960
+ 'default' => $default ?: array_key_first($modes),
961
+ ],
962
+ ]);
963
+ }
964
+
965
+ public function triggerLabel(?string $label)
966
+ {
967
+ return $this->config(['triggerLabel' => $label]);
968
+ }
969
+ }
970
+ ```
971
+
972
+ **Step 4: Grep for any surviving `hierarchyAction` / `lazyLoad` callers**
973
+
974
+ Run: `grep -r "hierarchyAction\|->lazyLoad(" "C:/Users/jkend/Documents/Projects/kompo/"`
975
+ Expected: no matches (the team switcher's `switchAction` will be rewritten in Task 6). If an unrelated caller surfaces, document it in the session and update that caller to the new pattern.
976
+
977
+ ---
978
+
979
+ ## Task 3 — Introduce `HierarchyNodeContext` value object
980
+
981
+ **Files:**
982
+ - Create: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/HierarchyNodeContext.php`
983
+
984
+ A small immutable carrier for the intermediate team-plus-access state the provider needs for filtering and path logic. Separating it from the factory keeps the factory's sole responsibility "produce wire-format node with composed `render`".
985
+
986
+ **Step 1: Write the class**
987
+
988
+ ```php
989
+ <?php
990
+
991
+ namespace Kompo\Auth\Teams;
992
+
993
+ class HierarchyNodeContext
994
+ {
995
+ public function __construct(
996
+ public readonly string $id,
997
+ public readonly int $teamId,
998
+ public readonly ?int $parentId,
999
+ public readonly string $teamName,
1000
+ public readonly ?string $parentName,
1001
+ public readonly bool $isCurrent,
1002
+ public readonly bool $isInCurrentPath,
1003
+ public readonly bool $isCommittee,
1004
+ public readonly bool $isSelectable,
1005
+ public readonly bool $hasChildren,
1006
+ public readonly int $childrenCount,
1007
+ public readonly int $committeeCount,
1008
+ public readonly string $levelLabel,
1009
+ public readonly string $levelKey,
1010
+ public readonly array $roles,
1011
+ public readonly ?array $switchRole,
1012
+ ) {}
1013
+
1014
+ public function hasSwitchableRole(): bool
1015
+ {
1016
+ return !empty($this->roles) || $this->switchRole !== null;
1017
+ }
1018
+
1019
+ public function isVisible(): bool
1020
+ {
1021
+ if ($this->isSelectable) {
1022
+ return $this->hasSwitchableRole() || $this->hasChildren;
1023
+ }
1024
+
1025
+ return $this->hasChildren;
1026
+ }
1027
+
1028
+ public function switchOnlyRole(): ?array
1029
+ {
1030
+ if ($this->switchRole === null) {
1031
+ return null;
1032
+ }
1033
+
1034
+ foreach ($this->roles as $role) {
1035
+ if (($role['id'] ?? null) === ($this->switchRole['id'] ?? null)) {
1036
+ return null;
1037
+ }
1038
+ }
1039
+
1040
+ return $this->switchRole;
1041
+ }
1042
+ }
1043
+ ```
1044
+
1045
+ **Step 2: Verify PHP syntax**
1046
+
1047
+ Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/HierarchyNodeContext.php"`
1048
+ Expected: `No syntax errors detected`.
1049
+
1050
+ ---
1051
+
1052
+ ## Task 4 — Rewrite `TeamRoleSwitcherNodeFactory.php` to emit `{id, parentId, hasChildren, isCurrent, render}`
1053
+
1054
+ **Files:**
1055
+ - Rewrite: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeFactory.php`
1056
+
1057
+ The factory now has two jobs: build a `HierarchyNodeContext` for provider-side filtering, and convert that context to the final wire payload by composing a kompo `_Flex` of `_Html` + `_Button` elements using utility classes for the exact current visual output.
1058
+
1059
+ **Step 1: Replace the file**
1060
+
1061
+ ```php
1062
+ <?php
1063
+
1064
+ namespace Kompo\Auth\Teams;
1065
+
1066
+ class TeamRoleSwitcherNodeFactory
1067
+ {
1068
+ private const LEVEL_PILL_BASE = 'inline-block min-w-[3.8rem] px-3 py-1 text-white text-center text-xs font-medium rounded-full whitespace-nowrap';
1069
+
1070
+ private const LEVEL_PILL_BG = [
1071
+ 'national' => 'bg-[rgb(255,180,0)]',
1072
+ 'district' => 'bg-[rgb(0,146,67)]',
1073
+ 'group' => 'bg-[rgb(0,44,222)]',
1074
+ 'team' => 'bg-[rgb(0,44,222)]',
1075
+ 'unit' => 'bg-[rgb(130,55,255)]',
1076
+ 'committee' => 'bg-[rgb(255,180,0)]',
1077
+ ];
1078
+
1079
+ private const ROLE_PILL_BASE = 'inline-block max-w-[6rem] overflow-hidden text-ellipsis px-3 py-1 text-xs font-medium rounded-full whitespace-nowrap border-0 cursor-pointer bg-transparent';
1080
+
1081
+ private const ROLE_PILL_CURRENT = 'bg-[var(--lazy-hierarchy-primary)] text-white';
1082
+ private const ROLE_PILL_DEFAULT = 'text-[var(--lazy-hierarchy-primary)] hover:bg-[color-mix(in_srgb,var(--lazy-hierarchy-primary)_8%,white)]';
1083
+
1084
+ private const GO_BUTTON_BASE = 'inline-flex items-center justify-center w-[1.65rem] h-[1.65rem] p-0 border-0 rounded-md bg-transparent text-[var(--lazy-hierarchy-primary)] cursor-pointer hover:bg-[color-mix(in_srgb,var(--lazy-hierarchy-primary)_8%,white)]';
1085
+
1086
+ private const GO_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[1.3rem] h-[1.3rem]"><circle cx="8" cy="7" r="3" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M3.5 18.2c0-3 2-4.8 4.5-4.8s4.5 1.8 4.5 4.8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /><path d="M14 8h5m0 0-2-2m2 2-2 2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>';
1087
+
1088
+ public function __construct(
1089
+ private TeamRoleSwitcherTeamRepository $teams,
1090
+ ) {}
1091
+
1092
+ public function context(
1093
+ $team,
1094
+ array $access,
1095
+ string $mode,
1096
+ array $currentPathIds,
1097
+ int $childrenCount,
1098
+ int $committeeCount,
1099
+ $currentTeamRole = null,
1100
+ ): HierarchyNodeContext {
1101
+ $teamId = (int) $team->id;
1102
+ $isCommittee = $this->teams->isCommittee($team);
1103
+ $isSelectable = $this->teams->isSelectableForMode($team, $mode);
1104
+
1105
+ return new HierarchyNodeContext(
1106
+ id: $this->nodeId($teamId),
1107
+ teamId: $teamId,
1108
+ parentId: $team->parent_team_id ? (int) $team->parent_team_id : null,
1109
+ teamName: (string) $team->team_name,
1110
+ parentName: $team->relationLoaded('parentTeam') ? $team->parentTeam?->team_name : null,
1111
+ isCurrent: $isSelectable && $currentTeamRole && $currentTeamRole->team_id == $teamId,
1112
+ isInCurrentPath: in_array($teamId, $currentPathIds, true),
1113
+ isCommittee: $isCommittee,
1114
+ isSelectable: $isSelectable,
1115
+ hasChildren: $childrenCount > 0,
1116
+ childrenCount: $childrenCount,
1117
+ committeeCount: $committeeCount,
1118
+ levelLabel: $this->teams->teamLevelLabel($team, $isCommittee),
1119
+ levelKey: $this->teams->teamLevelKey($team, $isCommittee),
1120
+ roles: $access['roles'] ?? [],
1121
+ switchRole: $access['switchRole'] ?? null,
1122
+ );
1123
+ }
1124
+
1125
+ public function toPayload(HierarchyNodeContext $ctx, string $switchUrl): array
1126
+ {
1127
+ return [
1128
+ 'id' => $ctx->id,
1129
+ 'teamId' => $ctx->teamId,
1130
+ 'parentId' => $ctx->parentId,
1131
+ 'hasChildren' => $ctx->hasChildren,
1132
+ 'isCurrent' => $ctx->isCurrent,
1133
+ 'render' => $this->composeRender($ctx, $switchUrl),
1134
+ ];
1135
+ }
1136
+
1137
+ public function nodeId(int $teamId): string
1138
+ {
1139
+ return 'team-' . $teamId;
1140
+ }
1141
+
1142
+ private function composeRender(HierarchyNodeContext $ctx, string $switchUrl): array
1143
+ {
1144
+ $name = _Html(e($ctx->teamName))->class('lazy-hierarchy-node__name flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.8125rem] font-semibold');
1145
+
1146
+ $meta = _Flex(
1147
+ $this->levelPill($ctx),
1148
+ ...$this->roleButtons($ctx, $switchUrl),
1149
+ $this->switchOnlyButton($ctx, $switchUrl),
1150
+ )
1151
+ ->class('lazy-hierarchy-node__meta flex items-center justify-end gap-[0.55rem] max-w-[50%] ml-auto');
1152
+
1153
+ return _Flex($name, $meta)
1154
+ ->class('flex items-center w-full gap-[0.45rem]')
1155
+ ->jsonSerialize();
1156
+ }
1157
+
1158
+ private function levelPill(HierarchyNodeContext $ctx)
1159
+ {
1160
+ $bg = self::LEVEL_PILL_BG[$ctx->levelKey] ?? 'bg-[var(--lazy-hierarchy-primary)]';
1161
+
1162
+ return _Html(e($ctx->levelLabel))->class(self::LEVEL_PILL_BASE . ' ' . $bg);
1163
+ }
1164
+
1165
+ private function roleButtons(HierarchyNodeContext $ctx, string $switchUrl): array
1166
+ {
1167
+ $elements = [];
1168
+
1169
+ foreach ($ctx->roles as $role) {
1170
+ $elements[] = _Flex(
1171
+ $this->roleNameButton($ctx, $role, $switchUrl),
1172
+ $this->goButton($ctx, $role, $switchUrl),
1173
+ )->class('inline-flex items-center gap-[0.45rem]');
1174
+ }
1175
+
1176
+ return $elements;
1177
+ }
1178
+
1179
+ private function roleNameButton(HierarchyNodeContext $ctx, array $role, string $switchUrl)
1180
+ {
1181
+ $current = $role['isCurrent'] ?? false;
1182
+ $extra = $current ? self::ROLE_PILL_CURRENT : self::ROLE_PILL_DEFAULT;
1183
+
1184
+ return _Button($role['label'] ?? '')
1185
+ ->class(self::ROLE_PILL_BASE . ' ' . $extra)
1186
+ ->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
1187
+ ->run("() => location.reload()");
1188
+ }
1189
+
1190
+ private function goButton(HierarchyNodeContext $ctx, array $role, string $switchUrl)
1191
+ {
1192
+ return _Button()
1193
+ ->icon(self::GO_ICON_SVG)
1194
+ ->class(self::GO_BUTTON_BASE)
1195
+ ->attr(['title' => __('auth.switcher-switch-role'), 'aria-label' => __('auth.switcher-switch-role')])
1196
+ ->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
1197
+ ->run("() => location.reload()");
1198
+ }
1199
+
1200
+ private function switchOnlyButton(HierarchyNodeContext $ctx, string $switchUrl)
1201
+ {
1202
+ $role = $ctx->switchOnlyRole();
1203
+
1204
+ if ($role === null) {
1205
+ return null;
1206
+ }
1207
+
1208
+ $title = isset($role['label']) && $role['label'] !== ''
1209
+ ? __('auth.switcher-switch-role') . ': ' . $role['label']
1210
+ : __('auth.switcher-switch-role');
1211
+
1212
+ return _Button()
1213
+ ->icon(self::GO_ICON_SVG)
1214
+ ->class(self::GO_BUTTON_BASE . ' lazy-hierarchy-node__go--standalone')
1215
+ ->attr(['title' => $title, 'aria-label' => $title])
1216
+ ->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
1217
+ ->run("() => location.reload()");
1218
+ }
1219
+ }
1220
+ ```
1221
+
1222
+ **Step 2: Verify PHP syntax**
1223
+
1224
+ Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeFactory.php"`
1225
+ Expected: `No syntax errors detected`.
1226
+
1227
+ **Step 3: Handle the fact that `_Flex(..., null, …)` may include a null**
1228
+
1229
+ `_Flex` with a `null` argument may error. The `switchOnlyButton` can return `null`; adjust the spread in `composeRender` to filter nulls before passing to `_Flex`:
1230
+
1231
+ Replace in `composeRender`:
1232
+ ```php
1233
+ $meta = _Flex(
1234
+ $this->levelPill($ctx),
1235
+ ...$this->roleButtons($ctx, $switchUrl),
1236
+ $this->switchOnlyButton($ctx, $switchUrl),
1237
+ )
1238
+ ```
1239
+ with:
1240
+ ```php
1241
+ $metaChildren = array_filter([
1242
+ $this->levelPill($ctx),
1243
+ ...$this->roleButtons($ctx, $switchUrl),
1244
+ $this->switchOnlyButton($ctx, $switchUrl),
1245
+ ]);
1246
+
1247
+ $meta = _Flex(...$metaChildren)
1248
+ ```
1249
+
1250
+ **Step 4: Verify the `_Flex()->jsonSerialize()` trick works**
1251
+
1252
+ The `render` field must serialize to the same nested structure kompo normally emits for a PHP element. `BaseElement::jsonSerialize()` produces the `{vueComponent, config, elements, class, …}` shape that `VlLazyHierarchy`'s `kompoElementAttributes` expects. Confirm this by looking at an existing caller such as `Collapsible::titleEl()` in `C:/Users/jkend/Documents/Projects/kompo/utils/src/Kompo/Elements/Collapsible.php` — locate where a rendered element is stored into config, and confirm whether `->jsonSerialize()`, `->preview()`, or just the object itself is stored.
1253
+
1254
+ If `titleEl` stores the element object directly (no `jsonSerialize()` call), change `composeRender` to `return _Flex($name, $meta)->class(...)` (returning the object) — PHP's `json_encode` on the whole payload will walk into it. If `titleEl` explicitly calls a serializer, mirror that.
1255
+
1256
+ ---
1257
+
1258
+ ## Task 5 — Update `TeamRoleSwitcherNodeProvider.php` to use the split factory
1259
+
1260
+ **Files:**
1261
+ - Modify: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeProvider.php`
1262
+
1263
+ The provider now walks teams, builds contexts, filters, then converts the surviving contexts to payload nodes via the factory. The filtering helpers `isVisibleNode` / `hasSwitchableRole` move into `HierarchyNodeContext` (already done in Task 3).
1264
+
1265
+ **Step 1: Replace the file's contents with:**
1266
+
1267
+ ```php
1268
+ <?php
1269
+
1270
+ namespace Kompo\Auth\Teams;
1271
+
1272
+ use Condoedge\Utils\Kompo\LazyHierarchy\LazyHierarchyPayload;
1273
+ use Illuminate\Support\Collection;
1274
+ use Kompo\Auth\Teams\Contracts\TeamHierarchyInterface;
1275
+ use Kompo\Auth\Teams\Contracts\TeamRoleAccessResolverInterface;
1276
+
1277
+ class TeamRoleSwitcherNodeProvider
1278
+ {
1279
+ private const ROOT_KEY = LazyHierarchyPayload::ROOT_KEY;
1280
+ private const DEFAULT_LIMIT = 20;
1281
+ private const MAX_LIMIT = 50;
1282
+ private const LOOKAHEAD_DEPTH = 1;
1283
+ private const DEFAULT_LOOKAHEAD_BUDGET = 80;
1284
+ private const MAX_SCAN_PAGES = 6;
1285
+ private const MAX_SEARCH_SCAN_PAGES = 30;
1286
+
1287
+ public function __construct(
1288
+ private TeamRoleAccessResolverInterface $access,
1289
+ private TeamRoleSwitcherTeamRepository $teams,
1290
+ private TeamRoleSwitcherNodeFactory $nodes,
1291
+ private TeamHierarchyInterface $hierarchy,
1292
+ private string $switchUrl,
1293
+ ) {}
1294
+
1295
+ public function bootstrap($user, int|string|null $profile, string $mode, int $limit, int $lookaheadBudget): array
1296
+ {
1297
+ $mode = $this->normalizeMode($mode);
1298
+ $limit = $this->normalizeLimit($limit);
1299
+ $lookaheadBudget = max($limit, min(120, $lookaheadBudget ?: self::DEFAULT_LOOKAHEAD_BUDGET));
1300
+
1301
+ $payload = $this->emptyPayload($mode);
1302
+ $nodeBudget = $lookaheadBudget;
1303
+ $currentPathIds = $this->currentPathIds();
1304
+
1305
+ $this->appendLevel(
1306
+ payload: $payload,
1307
+ user: $user,
1308
+ profile: $profile,
1309
+ mode: $mode,
1310
+ parentId: null,
1311
+ limit: $limit,
1312
+ cursor: null,
1313
+ lookaheadDepth: self::LOOKAHEAD_DEPTH,
1314
+ nodeBudget: $nodeBudget,
1315
+ currentPathIds: $currentPathIds,
1316
+ );
1317
+
1318
+ $this->appendCurrentPath($payload, $user, $profile, $mode, $limit, $currentPathIds, $nodeBudget);
1319
+
1320
+ return $payload->toArray();
1321
+ }
1322
+
1323
+ public function children(
1324
+ $user,
1325
+ int|string|null $profile,
1326
+ string $mode,
1327
+ ?int $parentId,
1328
+ int $limit,
1329
+ ?int $cursor,
1330
+ int $lookaheadBudget,
1331
+ ): array {
1332
+ $mode = $this->normalizeMode($mode);
1333
+ $limit = $this->normalizeLimit($limit);
1334
+ $nodeBudget = max($limit, min(120, $lookaheadBudget ?: self::DEFAULT_LOOKAHEAD_BUDGET));
1335
+ $payload = $this->emptyPayload($mode, $parentId === null);
1336
+
1337
+ $this->appendLevel(
1338
+ payload: $payload,
1339
+ user: $user,
1340
+ profile: $profile,
1341
+ mode: $mode,
1342
+ parentId: $parentId,
1343
+ limit: $limit,
1344
+ cursor: $cursor,
1345
+ lookaheadDepth: self::LOOKAHEAD_DEPTH,
1346
+ nodeBudget: $nodeBudget,
1347
+ currentPathIds: $this->currentPathIds(),
1348
+ append: (int) ($cursor ?? 0) > 0,
1349
+ );
1350
+
1351
+ return $payload->toArray();
1352
+ }
1353
+
1354
+ public function search($user, int|string|null $profile, string $mode, string $search, int $limit): array
1355
+ {
1356
+ $mode = $this->normalizeMode($mode);
1357
+ $limit = $this->normalizeLimit($limit);
1358
+ $payload = $this->emptyPayload($mode);
1359
+ $search = trim($search);
1360
+
1361
+ if ($search === '') {
1362
+ return $this->bootstrap($user, $profile, $mode, $limit, self::DEFAULT_LOOKAHEAD_BUDGET);
1363
+ }
1364
+
1365
+ $currentPathIds = $this->currentPathIds();
1366
+ $contexts = collect();
1367
+ $offset = 0;
1368
+ $scanPages = 0;
1369
+ $take = max($limit * 3, self::MAX_LIMIT);
1370
+
1371
+ while ($contexts->count() < $limit && $scanPages < self::MAX_SEARCH_SCAN_PAGES) {
1372
+ $teams = $this->teams->search($mode, $search, $take, $offset);
1373
+
1374
+ if ($teams->isEmpty()) {
1375
+ break;
1376
+ }
1377
+
1378
+ $decorated = $this->decorateTeams($teams, $user, $profile, $mode, $currentPathIds)
1379
+ ->filter(fn(HierarchyNodeContext $ctx) => $ctx->isSelectable && $ctx->hasSwitchableRole());
1380
+
1381
+ $contexts = $contexts->concat($decorated)->take($limit)->values();
1382
+ $offset += $teams->count();
1383
+ $scanPages++;
1384
+
1385
+ if ($teams->count() < $take) {
1386
+ break;
1387
+ }
1388
+ }
1389
+
1390
+ foreach ($contexts as $ctx) {
1391
+ $node = $this->nodes->toPayload($ctx, $this->switchUrl);
1392
+ $payload->addNode($node)->appendChild(self::ROOT_KEY, $node['id']);
1393
+ }
1394
+
1395
+ $payload->setPaging(self::ROOT_KEY, null, $contexts->count(), $limit);
1396
+
1397
+ return $payload->toArray();
1398
+ }
1399
+
1400
+ private function appendLevel(
1401
+ LazyHierarchyPayload $payload,
1402
+ $user,
1403
+ int|string|null $profile,
1404
+ string $mode,
1405
+ ?int $parentId,
1406
+ int $limit,
1407
+ ?int $cursor,
1408
+ int $lookaheadDepth,
1409
+ int &$nodeBudget,
1410
+ array $currentPathIds,
1411
+ bool $append = false,
1412
+ ): void {
1413
+ if ($nodeBudget <= 0) {
1414
+ return;
1415
+ }
1416
+
1417
+ $parentKey = $this->parentKey($parentId);
1418
+ $page = $this->visibleChildrenPage($user, $profile, $mode, $parentId, $limit, $cursor, $currentPathIds);
1419
+ $nodeIds = [];
1420
+
1421
+ foreach ($page['contexts'] as $ctx) {
1422
+ if ($nodeBudget <= 0) {
1423
+ break;
1424
+ }
1425
+
1426
+ $node = $this->nodes->toPayload($ctx, $this->switchUrl);
1427
+ $payload->addNode($node);
1428
+ $nodeIds[] = $node['id'];
1429
+ $nodeBudget--;
1430
+ }
1431
+
1432
+ $payload
1433
+ ->setChildren($parentKey, $nodeIds, $append)
1434
+ ->setPaging($parentKey, $page['nextCursor'], $page['total'], $limit, $append);
1435
+
1436
+ if ($lookaheadDepth <= 0) {
1437
+ return;
1438
+ }
1439
+
1440
+ foreach ($page['contexts'] as $ctx) {
1441
+ if ($nodeBudget <= 0) {
1442
+ break;
1443
+ }
1444
+
1445
+ if (!$ctx->hasChildren) {
1446
+ continue;
1447
+ }
1448
+
1449
+ $this->appendLevel(
1450
+ payload: $payload,
1451
+ user: $user,
1452
+ profile: $profile,
1453
+ mode: $mode,
1454
+ parentId: $ctx->teamId,
1455
+ limit: $limit,
1456
+ cursor: null,
1457
+ lookaheadDepth: $lookaheadDepth - 1,
1458
+ nodeBudget: $nodeBudget,
1459
+ currentPathIds: $currentPathIds,
1460
+ );
1461
+ }
1462
+ }
1463
+
1464
+ private function visibleChildrenPage(
1465
+ $user,
1466
+ int|string|null $profile,
1467
+ string $mode,
1468
+ ?int $parentId,
1469
+ int $limit,
1470
+ ?int $cursor,
1471
+ array $currentPathIds,
1472
+ ): array {
1473
+ $offset = max(0, (int) ($cursor ?? 0));
1474
+ $scanOffset = $offset;
1475
+ $visible = collect();
1476
+ $scanPages = 0;
1477
+ $total = $this->teams->childrenTotal($mode, $parentId);
1478
+
1479
+ while ($visible->count() < $limit && $scanPages < self::MAX_SCAN_PAGES) {
1480
+ $take = max($limit * 2, self::DEFAULT_LIMIT);
1481
+ $rawTeams = $this->teams->children($mode, $parentId, $take, $scanOffset);
1482
+
1483
+ if ($rawTeams->isEmpty()) {
1484
+ break;
1485
+ }
1486
+
1487
+ $decorated = $this->decorateTeams($rawTeams, $user, $profile, $mode, $currentPathIds)
1488
+ ->filter(fn(HierarchyNodeContext $ctx) => $ctx->isVisible());
1489
+
1490
+ $visible = $visible->concat($decorated)->take($limit)->values();
1491
+ $scanOffset += $rawTeams->count();
1492
+ $scanPages++;
1493
+
1494
+ if ($rawTeams->count() < $take) {
1495
+ break;
1496
+ }
1497
+ }
1498
+
1499
+ return [
1500
+ 'contexts' => $visible,
1501
+ 'nextCursor' => $scanOffset < $total ? $scanOffset : null,
1502
+ 'total' => $total,
1503
+ ];
1504
+ }
1505
+
1506
+ private function appendCurrentPath(
1507
+ LazyHierarchyPayload $payload,
1508
+ $user,
1509
+ int|string|null $profile,
1510
+ string $mode,
1511
+ int $limit,
1512
+ array $currentPathIds,
1513
+ int &$nodeBudget,
1514
+ ): void {
1515
+ if (empty($currentPathIds)) {
1516
+ return;
1517
+ }
1518
+
1519
+ $teamsById = $this->teams->findMany($currentPathIds)->keyBy('id');
1520
+
1521
+ if ($teamsById->isEmpty()) {
1522
+ return;
1523
+ }
1524
+
1525
+ $currentTeam = $teamsById->get((int) end($currentPathIds));
1526
+
1527
+ if (!$currentTeam || !$this->teams->isSelectableForMode($currentTeam, $mode)) {
1528
+ return;
1529
+ }
1530
+
1531
+ $contextsByTeamId = $this->decorateTeams($teamsById->values(), $user, $profile, $mode, $currentPathIds)
1532
+ ->keyBy(fn(HierarchyNodeContext $ctx) => $ctx->teamId);
1533
+
1534
+ $currentCtx = $contextsByTeamId->get((int) end($currentPathIds));
1535
+
1536
+ if (!$currentCtx || !$currentCtx->hasSwitchableRole()) {
1537
+ return;
1538
+ }
1539
+
1540
+ foreach ($currentPathIds as $index => $teamId) {
1541
+ $ctx = $contextsByTeamId->get($teamId);
1542
+
1543
+ if (!$ctx) {
1544
+ continue;
1545
+ }
1546
+
1547
+ $node = $this->nodes->toPayload($ctx, $this->switchUrl);
1548
+ $payload->addNode($node);
1549
+
1550
+ $parentId = $index === 0 ? null : (int) $currentPathIds[$index - 1];
1551
+ $payload->prependChild($this->parentKey($parentId), $node['id']);
1552
+
1553
+ if ($index < count($currentPathIds) - 1) {
1554
+ $payload->expand($node['id']);
1555
+ $this->appendLevel(
1556
+ payload: $payload,
1557
+ user: $user,
1558
+ profile: $profile,
1559
+ mode: $mode,
1560
+ parentId: $teamId,
1561
+ limit: $limit,
1562
+ cursor: null,
1563
+ lookaheadDepth: 0,
1564
+ nodeBudget: $nodeBudget,
1565
+ currentPathIds: $currentPathIds,
1566
+ );
1567
+ }
1568
+ }
1569
+ }
1570
+
1571
+ private function decorateTeams(Collection $teams, $user, int|string|null $profile, string $mode, array $currentPathIds): Collection
1572
+ {
1573
+ if ($teams->isEmpty()) {
1574
+ return collect();
1575
+ }
1576
+
1577
+ $currentTeamRole = function_exists('currentTeamRole') ? currentTeamRole() : null;
1578
+ $accessByTeam = $this->access->resolveForCandidates($teams, $user, $profile, $mode, $currentTeamRole);
1579
+ $visibleChildrenCounts = $mode === TeamAccessHierarchyBuilder::MODE_TEAMS
1580
+ ? $this->visibleDirectChildrenCounts($teams->pluck('id'), $user, $profile, $mode)
1581
+ : [];
1582
+
1583
+ return $teams->map(function ($team) use ($accessByTeam, $visibleChildrenCounts, $mode, $currentPathIds, $currentTeamRole) {
1584
+ $teamId = (int) $team->id;
1585
+ $isSelectable = $this->teams->isSelectableForMode($team, $mode);
1586
+ $access = $isSelectable ? ($accessByTeam[$teamId] ?? []) : [];
1587
+ $childrenCount = $mode === TeamAccessHierarchyBuilder::MODE_TEAMS
1588
+ ? (int) ($visibleChildrenCounts[$teamId] ?? 0)
1589
+ : $this->teams->countDirectChildren($teamId, $mode);
1590
+ $committeeCount = $mode === TeamAccessHierarchyBuilder::MODE_COMMITTEES
1591
+ ? $this->teams->countDirectCommittees($teamId)
1592
+ : 0;
1593
+
1594
+ return $this->nodes->context(
1595
+ team: $team,
1596
+ access: $access,
1597
+ mode: $mode,
1598
+ currentPathIds: $currentPathIds,
1599
+ childrenCount: $childrenCount,
1600
+ committeeCount: $committeeCount,
1601
+ currentTeamRole: $currentTeamRole,
1602
+ );
1603
+ })->values();
1604
+ }
1605
+
1606
+ private function visibleDirectChildrenCounts(Collection $parentIds, $user, int|string|null $profile, string $mode): array
1607
+ {
1608
+ $parentIds = $parentIds->map(fn($id) => (int) $id)->filter()->unique()->values();
1609
+
1610
+ if ($parentIds->isEmpty()) {
1611
+ return [];
1612
+ }
1613
+
1614
+ $query = $this->teams->teamQuery()->whereIn('teams.parent_team_id', $parentIds->all());
1615
+ $this->teams->applyModeFilter($query, $mode);
1616
+ $children = $query->get();
1617
+
1618
+ if ($children->isEmpty()) {
1619
+ return [];
1620
+ }
1621
+
1622
+ $accessByChild = $this->access->resolveForCandidates($children, $user, $profile, $mode, includeCurrentRole: false);
1623
+ $activeRoleBranchIndex = $this->access->activeRoleBranchIndex($user, $profile, $mode);
1624
+ $counts = [];
1625
+
1626
+ foreach ($children as $child) {
1627
+ $childId = (int) $child->id;
1628
+ $parentId = $child->parent_team_id ? (int) $child->parent_team_id : null;
1629
+
1630
+ if (!$parentId) {
1631
+ continue;
1632
+ }
1633
+
1634
+ $childAccess = $this->teams->isSelectableForMode($child, $mode) ? ($accessByChild[$childId] ?? []) : [];
1635
+
1636
+ if (empty($childAccess['roles']) && empty($childAccess['switchRole']) && !$activeRoleBranchIndex->has($childId)) {
1637
+ continue;
1638
+ }
1639
+
1640
+ $counts[$parentId] = ($counts[$parentId] ?? 0) + 1;
1641
+ }
1642
+
1643
+ return $counts;
1644
+ }
1645
+
1646
+ private function currentPathIds(): array
1647
+ {
1648
+ $currentTeamId = currentTeamId();
1649
+
1650
+ if (!$currentTeamId) {
1651
+ return [];
1652
+ }
1653
+
1654
+ return $this->hierarchy
1655
+ ->getAncestorTeamIds($currentTeamId)
1656
+ ->map(fn($id) => (int) $id)
1657
+ ->values()
1658
+ ->all();
1659
+ }
1660
+
1661
+ private function emptyPayload(string $mode, bool $includeRoot = true): LazyHierarchyPayload
1662
+ {
1663
+ return LazyHierarchyPayload::make(['mode' => $mode], self::ROOT_KEY, $includeRoot);
1664
+ }
1665
+
1666
+ private function normalizeMode(?string $mode): string
1667
+ {
1668
+ return $mode === TeamAccessHierarchyBuilder::MODE_COMMITTEES
1669
+ ? TeamAccessHierarchyBuilder::MODE_COMMITTEES
1670
+ : TeamAccessHierarchyBuilder::MODE_TEAMS;
1671
+ }
1672
+
1673
+ private function normalizeLimit(int $limit): int
1674
+ {
1675
+ return max(1, min(self::MAX_LIMIT, $limit ?: self::DEFAULT_LIMIT));
1676
+ }
1677
+
1678
+ private function parentKey(?int $parentId): string
1679
+ {
1680
+ return $parentId ? $this->nodes->nodeId($parentId) : self::ROOT_KEY;
1681
+ }
1682
+ }
1683
+ ```
1684
+
1685
+ **Step 2: The provider now takes `$switchUrl` in its constructor**
1686
+
1687
+ This changes its DI signature. The `TeamRoleSwitcherHierarchySource` instantiates the provider indirectly via the container. Since the provider is built by Laravel DI, arrange for `$switchUrl` to be resolved from the source's `$store`. Update the source (Task 6 below) to pass it through.
1688
+
1689
+ **Step 3: Verify PHP syntax**
1690
+
1691
+ Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeProvider.php"`
1692
+ Expected: `No syntax errors detected`.
1693
+
1694
+ ---
1695
+
1696
+ ## Task 6 — Simplify `TeamRoleSwitcherElement.php` and wire `$switchUrl` into the source store
1697
+
1698
+ **Files:**
1699
+ - Rewrite: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherElement.php`
1700
+ - Modify: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherHierarchySource.php`
1701
+
1702
+ The element no longer carries `current`, `profile`, or domain class overrides because those values now flow through the source's `$store` and land directly in the composed node render trees. The element's only job is: wire the source, enable search, expose modes, pick dropdown vs inline, and set a trigger label.
1703
+
1704
+ **Step 1: Rewrite the element**
1705
+
1706
+ ```php
1707
+ <?php
1708
+
1709
+ namespace Kompo\Auth\Teams;
1710
+
1711
+ use Condoedge\Utils\Kompo\Elements\LazyHierarchy;
1712
+
1713
+ class TeamRoleSwitcherElement extends LazyHierarchy
1714
+ {
1715
+ public function initialize($label = '')
1716
+ {
1717
+ parent::initialize($label);
1718
+
1719
+ $currentTeamRole = currentTeamRole();
1720
+ $profile = $currentTeamRole?->roleRelation?->profile ?? 1;
1721
+ $switchUrl = route('team-role-switcher.switch');
1722
+
1723
+ $this
1724
+ ->source(TeamRoleSwitcherHierarchySource::class, ['switchUrl' => $switchUrl])
1725
+ ->hierarchyPaging(20, 80)
1726
+ ->dropdown($currentTeamRole?->getRoleName())
1727
+ ->searchable(__('auth.search-placeholder'))
1728
+ ->modes([
1729
+ TeamAccessHierarchyBuilder::MODE_TEAMS => __('auth.switcher-teams'),
1730
+ TeamAccessHierarchyBuilder::MODE_COMMITTEES => __('auth.switcher-committees'),
1731
+ ], 'mode', TeamAccessHierarchyBuilder::MODE_TEAMS)
1732
+ ->hierarchyParam('profile', $profile)
1733
+ ->hierarchyLabels([
1734
+ 'searchPlaceholder' => __('auth.search-placeholder'),
1735
+ 'loading' => __('auth.switcher-loading'),
1736
+ 'empty' => __('auth.switcher-empty'),
1737
+ 'error' => __('auth.switcher-error'),
1738
+ 'showMore' => __('auth.switcher-show-more'),
1739
+ ])
1740
+ ->class('kompo-team-role-switcher-shell');
1741
+ }
1742
+ }
1743
+ ```
1744
+
1745
+ **Step 2: Update `TeamRoleSwitcherHierarchySource` to pass `$switchUrl` from its `$store` into a fresh provider instance**
1746
+
1747
+ ```php
1748
+ <?php
1749
+
1750
+ namespace Kompo\Auth\Teams;
1751
+
1752
+ use Condoedge\Utils\Contracts\LazyHierarchySourceInterface;
1753
+ use Illuminate\Http\Request;
1754
+ use Illuminate\Validation\Rule;
1755
+
1756
+ class TeamRoleSwitcherHierarchySource implements LazyHierarchySourceInterface
1757
+ {
1758
+ private TeamRoleSwitcherNodeProvider $provider;
1759
+
1760
+ public function __construct(
1761
+ TeamRoleAccessResolverInterface $access,
1762
+ TeamRoleSwitcherTeamRepository $teams,
1763
+ TeamRoleSwitcherNodeFactory $nodes,
1764
+ TeamHierarchyInterface $hierarchy,
1765
+ protected array $store = [],
1766
+ ) {
1767
+ $switchUrl = (string) ($store['switchUrl'] ?? route('team-role-switcher.switch'));
1768
+
1769
+ $this->provider = new TeamRoleSwitcherNodeProvider(
1770
+ $access,
1771
+ $teams,
1772
+ $nodes,
1773
+ $hierarchy,
1774
+ $switchUrl,
1775
+ );
1776
+ }
1777
+
1778
+ public function bootstrap(Request $request): array
1779
+ {
1780
+ $data = $this->validatedListRequest($request);
1781
+
1782
+ if ($data['search'] !== '') {
1783
+ return $this->provider->search(
1784
+ $request->user(),
1785
+ $data['profile'],
1786
+ $data['mode'],
1787
+ $data['search'],
1788
+ $data['limit'],
1789
+ );
1790
+ }
1791
+
1792
+ return $this->provider->bootstrap(
1793
+ $request->user(),
1794
+ $data['profile'],
1795
+ $data['mode'],
1796
+ $data['limit'],
1797
+ $data['lookahead'],
1798
+ );
1799
+ }
1800
+
1801
+ public function children(Request $request): array
1802
+ {
1803
+ $data = $this->validatedListRequest($request, [
1804
+ 'parent_id' => ['nullable', 'integer', 'min:1', 'exists:teams,id'],
1805
+ 'cursor' => ['nullable', 'integer', 'min:0'],
1806
+ ]);
1807
+
1808
+ return $this->provider->children(
1809
+ $request->user(),
1810
+ $data['profile'],
1811
+ $data['mode'],
1812
+ $data['parent_id'] ?? null,
1813
+ $data['limit'],
1814
+ $data['cursor'] ?? null,
1815
+ $data['lookahead'],
1816
+ );
1817
+ }
1818
+
1819
+ protected function validatedListRequest(Request $request, array $extraRules = []): array
1820
+ {
1821
+ abort_unless($request->user(), 403);
1822
+
1823
+ $data = $request->validate(array_merge([
1824
+ 'mode' => ['nullable', Rule::in([
1825
+ TeamAccessHierarchyBuilder::MODE_TEAMS,
1826
+ TeamAccessHierarchyBuilder::MODE_COMMITTEES,
1827
+ ])],
1828
+ 'profile' => ['nullable', 'string', 'max:50'],
1829
+ 'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
1830
+ 'lookahead' => ['nullable', 'integer', 'min:20', 'max:120'],
1831
+ 'search' => ['nullable', 'string', 'max:100'],
1832
+ ], $extraRules));
1833
+
1834
+ return [
1835
+ 'mode' => $data['mode'] ?? TeamAccessHierarchyBuilder::MODE_TEAMS,
1836
+ 'profile' => $data['profile'] ?? currentTeamRole()?->roleRelation?->profile ?? 1,
1837
+ 'limit' => (int) ($data['limit'] ?? 20),
1838
+ 'lookahead' => (int) ($data['lookahead'] ?? 80),
1839
+ 'search' => trim((string) ($data['search'] ?? '')),
1840
+ ] + $data;
1841
+ }
1842
+ }
1843
+ ```
1844
+
1845
+ Add the needed `use` statements at the top:
1846
+ ```php
1847
+ use Kompo\Auth\Teams\Contracts\TeamHierarchyInterface;
1848
+ use Kompo\Auth\Teams\Contracts\TeamRoleAccessResolverInterface;
1849
+ ```
1850
+
1851
+ **Step 3: Verify the `LazyHierarchyRegistry` instantiates the source with the store**
1852
+
1853
+ Open `C:/Users/jkend/Documents/Projects/kompo/utils/src/Services/LazyHierarchy/LazyHierarchyRegistry.php` and confirm that when it resolves a stored source class, it passes `$store` as a constructor argument (likely via `app()->makeWith($class, ['store' => $store])`). If the resolution path does not currently pass `$store`, update the registry to do so — the factory/source rewrite depends on it.
1854
+
1855
+ **Step 4: PHP lint all three files**
1856
+
1857
+ Run:
1858
+ ```
1859
+ php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherElement.php"
1860
+ php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherHierarchySource.php"
1861
+ php -l "C:/Users/jkend/Documents/Projects/kompo/utils/src/Services/LazyHierarchy/LazyHierarchyRegistry.php"
1862
+ ```
1863
+ Expected: all three report `No syntax errors detected`.
1864
+
1865
+ ---
1866
+
1867
+ ## Task 7 — Feature test: payload shape & render composition
1868
+
1869
+ **Files:**
1870
+ - Create: `C:/Users/jkend/Documents/Projects/kompo/auth/tests/Feature/Teams/TeamRoleSwitcherHierarchyPayloadTest.php`
1871
+
1872
+ The Vue side doesn't have unit tests (manual browser verification in Task 8), but the server payload is the contract — write feature tests that assert exact wire shape for representative cases.
1873
+
1874
+ **Step 1: Write the failing test**
1875
+
1876
+ ```php
1877
+ <?php
1878
+
1879
+ namespace Tests\Feature\Teams;
1880
+
1881
+ use Tests\TestCase;
1882
+
1883
+ class TeamRoleSwitcherHierarchyPayloadTest extends TestCase
1884
+ {
1885
+ public function test_bootstrap_returns_root_nodes_with_render_trees(): void
1886
+ {
1887
+ [$user, $nationalTeam] = $this->seedNationalAdmin();
1888
+ $this->actingAs($user);
1889
+
1890
+ $response = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
1891
+ '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
1892
+ 'mode' => 'teams',
1893
+ ]));
1894
+
1895
+ $response->assertOk();
1896
+ $payload = $response->json();
1897
+
1898
+ $this->assertArrayHasKey('nodes', $payload);
1899
+ $this->assertArrayHasKey('children', $payload);
1900
+ $this->assertArrayHasKey('paging', $payload);
1901
+
1902
+ $rootIds = $payload['children']['root'] ?? [];
1903
+ $this->assertNotEmpty($rootIds, 'Root must have at least one node');
1904
+
1905
+ $node = $payload['nodes'][$rootIds[0]];
1906
+ $this->assertArrayHasKey('id', $node);
1907
+ $this->assertArrayHasKey('parentId', $node);
1908
+ $this->assertArrayHasKey('hasChildren', $node);
1909
+ $this->assertArrayHasKey('isCurrent', $node);
1910
+ $this->assertArrayHasKey('render', $node);
1911
+
1912
+ $render = $node['render'];
1913
+ $this->assertSame('Flex', $render['vueComponent']);
1914
+ $this->assertIsArray($render['elements']);
1915
+ $this->assertGreaterThanOrEqual(2, count($render['elements']), 'Render must contain at least name + meta');
1916
+ }
1917
+
1918
+ public function test_national_row_contains_role_name_button(): void
1919
+ {
1920
+ [$user, $nationalTeam] = $this->seedNationalAdmin();
1921
+ $this->actingAs($user);
1922
+
1923
+ $payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
1924
+ '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
1925
+ 'mode' => 'teams',
1926
+ ]))->json();
1927
+
1928
+ $node = $this->findNodeByTeamId($payload, $nationalTeam->id);
1929
+ $this->assertNotNull($node);
1930
+
1931
+ $buttonLabels = $this->collectButtonLabels($node['render']);
1932
+ $this->assertContains('Super admin', $buttonLabels, 'National row must render a "Super admin" role-name button');
1933
+ }
1934
+
1935
+ public function test_unit_row_has_only_go_button_when_role_inherited(): void
1936
+ {
1937
+ [$user, , $unitTeam] = $this->seedNationalAdmin();
1938
+ $this->actingAs($user);
1939
+
1940
+ $payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
1941
+ '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
1942
+ 'mode' => 'teams',
1943
+ ]))->json();
1944
+
1945
+ $node = $this->findNodeByTeamId($payload, $unitTeam->id);
1946
+ $this->assertNotNull($node);
1947
+
1948
+ $buttonLabels = $this->collectButtonLabels($node['render']);
1949
+ $this->assertEquals(
1950
+ [''],
1951
+ $buttonLabels,
1952
+ 'Unit row must render exactly one button with empty label (go-only variant)'
1953
+ );
1954
+ }
1955
+
1956
+ public function test_current_node_has_is_current_true(): void
1957
+ {
1958
+ [$user, , $unitTeam] = $this->seedNationalAdmin();
1959
+ $this->actingAs($user);
1960
+ $this->setCurrentTeam($user, $unitTeam);
1961
+
1962
+ $payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
1963
+ '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
1964
+ 'mode' => 'teams',
1965
+ ]))->json();
1966
+
1967
+ $node = $this->findNodeByTeamId($payload, $unitTeam->id);
1968
+ $this->assertTrue($node['isCurrent'], 'The current team must have isCurrent=true');
1969
+ }
1970
+
1971
+ // --- helpers: seed the minimal hierarchy + register a source -------
1972
+
1973
+ private function seedNationalAdmin(): array
1974
+ {
1975
+ // Produces a National → District → Group → Unit chain and a user with
1976
+ // the Super admin role on National. Return [$user, $national, $unit].
1977
+ // Implementation specific to the project's factory/fixture patterns.
1978
+ // TODO: adapt to existing test helpers in auth/tests/TestCase.php
1979
+ $this->markTestIncomplete('Implement seeder using existing auth/tests/ fixtures');
1980
+ }
1981
+
1982
+ private function registerTeamRoleSwitcherSource(): string
1983
+ {
1984
+ // Mirror what TeamRoleSwitcherElement::initialize() does to stash the
1985
+ // source in LazyHierarchyRegistry and return the opaque id.
1986
+ // TODO: call app(LazyHierarchyRegistry::class)->storeSource(...)
1987
+ $this->markTestIncomplete('Wire registerTeamRoleSwitcherSource helper');
1988
+ }
1989
+
1990
+ private function setCurrentTeam($user, $team): void
1991
+ {
1992
+ // Set the active team-role for the user so currentTeam()/currentTeamRole()
1993
+ // resolve to this team during the request.
1994
+ $this->markTestIncomplete('Wire setCurrentTeam helper');
1995
+ }
1996
+
1997
+ private function findNodeByTeamId(array $payload, int $teamId): ?array
1998
+ {
1999
+ $nodeId = 'team-' . $teamId;
2000
+ return $payload['nodes'][$nodeId] ?? null;
2001
+ }
2002
+
2003
+ private function collectButtonLabels(array $element): array
2004
+ {
2005
+ $labels = [];
2006
+ $this->walkElements($element, function ($el) use (&$labels) {
2007
+ if (($el['vueComponent'] ?? null) === 'Button') {
2008
+ $labels[] = (string) ($el['label'] ?? '');
2009
+ }
2010
+ });
2011
+ return $labels;
2012
+ }
2013
+
2014
+ private function walkElements(array $element, callable $visit): void
2015
+ {
2016
+ $visit($element);
2017
+ foreach ($element['elements'] ?? [] as $child) {
2018
+ if (is_array($child)) {
2019
+ $this->walkElements($child, $visit);
2020
+ }
2021
+ }
2022
+ }
2023
+ }
2024
+ ```
2025
+
2026
+ **Step 2: Run the failing test**
2027
+
2028
+ Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --filter TeamRoleSwitcherHierarchyPayloadTest`
2029
+ Expected: the tests are marked incomplete until seeders are wired, which is acceptable — the structure is in place.
2030
+
2031
+ **Step 3: Fill in the incomplete seeders using existing fixtures**
2032
+
2033
+ Open `C:/Users/jkend/Documents/Projects/kompo/auth/tests/TestCase.php` and any factory files under `C:/Users/jkend/Documents/Projects/kompo/auth/tests/database/` / `C:/Users/jkend/Documents/Projects/kompo/auth/tests/Helpers/`. Locate the team/team-role factory helpers and wire `seedNationalAdmin`, `registerTeamRoleSwitcherSource`, and `setCurrentTeam`.
2034
+
2035
+ **Step 4: Run the tests to green**
2036
+
2037
+ Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --filter TeamRoleSwitcherHierarchyPayloadTest`
2038
+ Expected: all four tests pass.
2039
+
2040
+ **Step 5: Run the full auth feature suite to catch regressions**
2041
+
2042
+ Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --testsuite Feature`
2043
+ Expected: previously-passing tests still pass; no new failures.
2044
+
2045
+ ---
2046
+
2047
+ ## Task 8 — Build & manual browser verification
2048
+
2049
+ **Files:** none
2050
+
2051
+ **Step 1: Rebuild the front-end bundle**
2052
+
2053
+ Run: `cd "C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils" && npm run build` (or the project's equivalent; check `package.json`).
2054
+ Expected: build succeeds with no errors.
2055
+
2056
+ **Step 2: Boot the consuming application**
2057
+
2058
+ Start whichever app embeds `MenuRolesSwitcherDropdown` (likely SISC or a Scouts Canada app). Follow its normal local dev instructions — `php artisan serve` / Herd / Sail, plus a Vite/webpack dev server if applicable.
2059
+
2060
+ **Step 3: Smoke-test the dropdown**
2061
+
2062
+ Walk through every row type in `docs/plans/2026-04-18-vl-lazy-hierarchy-generic-render.md` Design Summary and confirm:
2063
+
2064
+ - Dropdown trigger shows `currentTeamRole->getRoleName()` + chevron.
2065
+ - Opening the panel reveals search + "Teams" / "Committees" radios with Teams selected by default.
2066
+ - Root level renders at least "National" with:
2067
+ - Yellow `National` pill, `Super admin` role-name pill to its right, and a go (person-with-arrow) icon button.
2068
+ - Clicking the go icon POSTs to `route('team-role-switcher.switch')` with `{team_id, role_id}` and reloads the page. The trigger label updates to reflect the new role.
2069
+ - Expanding National → Atlantique (green `District` pill, go-only button — no role-name pill) → 001 - Scouts de Cap-Pelé (blue `Group` pill) → each Unit (purple `Unit` pill) matches the image.
2070
+ - The current team row has the highlighted (soft-green) background.
2071
+ - Switching to "Committees" reloads the tree with yellow `Committee` pills.
2072
+ - Typing in the search box debounces and filters with parent names shown under matches.
2073
+ - Loading states and skeleton rows appear briefly during fetches.
2074
+
2075
+ **Step 4: Compare side-by-side with the original**
2076
+
2077
+ If possible, pull the previous build artifact (before this branch) and open the two side by side at the same viewport. Pixel-level alignment is not required; visual equivalence and correct click behavior are.
2078
+
2079
+ **Step 5: Regression scan for other consumers**
2080
+
2081
+ Run: `grep -r "VlLazyHierarchy\|_LazyHierarchy(" "C:/Users/jkend/Documents/Projects/kompo/" --include="*.php" --include="*.vue" --include="*.js" --include="*.blade.php"`
2082
+ Confirm only `TeamRoleSwitcherElement`, `MenuRolesSwitcherDropdown`, and the helper in `utils/src/Helpers/elements/components.php` reference the element.
2083
+
2084
+ ---
2085
+
2086
+ ## Done Criteria
2087
+
2088
+ - `VlLazyHierarchy.vue` contains no references to `roles`, `committee`, `level`, `switchRole`, or any domain concept.
2089
+ - `LazyHierarchy.php` has no `hierarchyAction` method.
2090
+ - `TeamRoleSwitcherNodeFactory.php` produces `{id, parentId, hasChildren, isCurrent, render}` payload nodes with a composed `_Flex` render tree.
2091
+ - All feature tests in Task 7 pass.
2092
+ - Manual browser walk-through in Task 8 matches the provided reference image.
2093
+ - `grep -r "nodeBadges\|showCommitteeCount\|runRoleAction\|runNodeAction\|runConfiguredAction" "C:/Users/jkend/Documents/Projects/kompo/"` returns zero matches. (`switchOnlyRole` is legitimately retained as a method on `HierarchyNodeContext` — see Task 3.)