# VlLazyHierarchy — Generic Config-Driven Render Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**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.

**Architecture:**
- `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.
- 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).
- 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.

**Tech Stack:** Vue 2 (kompo convention), PHP 8.1+ / Laravel, kompo framework. Three repos edited:
- `C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils` — Vue component rewrite
- `C:/Users/jkend/Documents/Projects/kompo/utils` — PHP `LazyHierarchy` element trim
- `C:/Users/jkend/Documents/Projects/kompo/auth` — team-switcher factory/provider/element rewrite

---

## Design Summary

A hierarchy node is a wire object:

```
{
  id:          string           // "team-123"
  parentId:    string | null
  hasChildren: bool
  isCurrent:   bool             // drives --current highlight in chrome
  render:      KompoElement     // composed PHP tree — name, pills, buttons
}
```

`VlLazyHierarchy.vue` renders each row as:

```vue
<div class="lazy-hierarchy-node" :class="{ '--current': node.isCurrent }">
  <div class="lazy-hierarchy-node__row" :style="indentStyle">
    <button v-if="hasChildren" class="__toggle" @click="toggle">[chevron]</button>
    <span v-else class="__toggle-spacer"></span>
    <component v-bind="kompoElementAttributes(node.render, item)" />
  </div>
</div>
```

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.

Level palette (for the team switcher, applied via Tailwind-ish utility classes on `_Html` pills):
- National → `bg-[rgb(255,180,0)]` (yellow)
- District → `bg-[rgb(0,146,67)]` (green)
- Group / Team → `bg-[rgb(0,44,222)]` (blue)
- Unit → `bg-[rgb(130,55,255)]` (purple)
- Committee → `bg-[rgb(255,180,0)]` (yellow)
- Default → `bg-[var(--lazy-hierarchy-primary)]`

---

## Task 1 — Rewrite `VlLazyHierarchy.vue`

**Files:**
- Rewrite: `C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils/components/VlLazyHierarchy.vue` (full replacement)

**Step 1: Replace the whole file with the content below**

```vue
<template>
    <component :is="tag" v-bind="rootAttrs" :class="rootClasses" ref="root" @keyup.esc="closePanel">
        <slot v-bind="slotProps">
            <button v-if="isDropdown" type="button" class="lazy-hierarchy__trigger" :class="uiClasses.trigger" @click="togglePanel">
                <span class="lazy-hierarchy__trigger-label">{{ triggerLabel }}</span>
                <svg class="lazy-hierarchy__trigger-icon" :class="{ 'lazy-hierarchy__trigger-icon--open': open }" viewBox="0 0 20 20" aria-hidden="true">
                    <path d="M5 7.5l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                </svg>
            </button>

            <div v-if="panelOpen" class="lazy-hierarchy__panel" :class="uiClasses.panel">
                <div v-if="hasControls" class="lazy-hierarchy__top">
                    <div v-if="searchEnabled" class="vlInputWrapper lazy-hierarchy__search-wrap" :class="uiClasses.searchWrapper">
                        <input v-model="search" class="vlFormControl lazy-hierarchy__search" :class="uiClasses.searchInput" type="search" :placeholder="searchPlaceholder" />
                    </div>

                    <div v-if="modeOptionsList.length" class="vlRadio lazy-hierarchy__modes" :class="uiClasses.modes">
                        <div
                            v-for="modeOption in modeOptionsList"
                            :key="modeOption.value"
                            class="lazy-hierarchy__mode"
                            :class="[uiClasses.mode, { 'lazy-hierarchy__mode--active': mode === modeOption.value }]"
                            @click="mode = modeOption.value"
                        >
                            <div class="vlInputWrapper lazy-hierarchy__radio-control">
                                <input v-model="mode" type="radio" :value="modeOption.value" :id="modeRadioId(modeOption.value)" />
                                <span class="lazy-hierarchy__radio-dot" aria-hidden="true"></span>
                            </div>
                            <label class="vlFormLabel lazy-hierarchy__mode-label" :for="modeRadioId(modeOption.value)">{{ modeOption.label }}</label>
                        </div>
                    </div>
                </div>

                <div class="lazy-hierarchy__body mini-scroll" :class="uiClasses.body">
                    <div v-if="isTreeLoading && !hasNodes" class="lazy-hierarchy__status">
                        <span class="lazy-hierarchy__status-label">
                            <span class="lazy-hierarchy__spinner" aria-hidden="true"></span>
                            <span>{{ labels.loading }}</span>
                        </span>
                        <span v-for="index in 5" :key="index" class="lazy-hierarchy__skeleton-row" aria-hidden="true">
                            <span class="lazy-hierarchy__skeleton-name"></span>
                            <span class="lazy-hierarchy__skeleton-pill"></span>
                        </span>
                    </div>
                    <div v-else-if="error" class="lazy-hierarchy__status lazy-hierarchy__status--error">{{ labels.error }}</div>
                    <div v-else-if="bootstrapped && rootNodeIds.length === 0" class="lazy-hierarchy__status">{{ labels.empty }}</div>

                    <vl-lazy-tree v-else v-bind="treeProps" @toggle="toggleNode" @load-more="loadMoreFromTree">
                        <template v-slot:node="item">
                            <div class="lazy-hierarchy-node" :class="{ 'lazy-hierarchy-node--current': item.node.isCurrent }">
                                <div class="lazy-hierarchy-node__row" :class="uiClasses.nodeRow" :style="item.indentStyle">
                                    <button
                                        v-if="item.hasChildren"
                                        type="button"
                                        class="lazy-hierarchy-node__toggle"
                                        :aria-expanded="item.isExpanded ? 'true' : 'false'"
                                        @click.stop="item.toggle"
                                    >
                                        <svg class="lazy-hierarchy-node__chevron" :class="{ 'lazy-hierarchy-node__chevron--open': item.isExpanded }" viewBox="0 0 20 20" aria-hidden="true">
                                            <path d="M7.5 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                                        </svg>
                                    </button>
                                    <span v-else class="lazy-hierarchy-node__toggle-spacer"></span>

                                    <component v-bind="kompoElementAttributes(item.node.render, item)" class="lazy-hierarchy-node__content" />
                                </div>
                            </div>
                        </template>

                        <template v-slot:loading="item">
                            <div :key="item.key" class="lazy-hierarchy-node__loading" :style="item.indentStyle">
                                <span class="lazy-hierarchy__skeleton-row" aria-hidden="true">
                                    <span class="lazy-hierarchy__skeleton-name"></span>
                                    <span class="lazy-hierarchy__skeleton-pill"></span>
                                </span>
                            </div>
                        </template>

                        <template v-slot:load-more="item">
                            <vl-infinite-load
                                :key="item.key"
                                class="lazy-hierarchy__show-more"
                                :button-class="uiClasses.showMore"
                                :loading="item.isLoading"
                                :disabled="item.isLoading"
                                :label="labels.showMore"
                                :loading-label="labels.loading"
                                @visible="item.loadMore"
                            />
                        </template>
                    </vl-lazy-tree>
                </div>
            </div>
        </slot>
    </component>
</template>

<script>
import Other from 'vue-kompo/js/form/mixins/Other'
import VlInfiniteLoad from './VlInfiniteLoad.vue'
import VlLazyTree from './VlLazyTree.vue'

const ROOT_KEY = 'root'

const configValue = (vm, key) => vm.vkompo && vm.vkompo.config ? vm.vkompo.config[key] : undefined

export default {
    name: 'VlLazyHierarchy',
    inheritAttrs: false,
    mixins: [Other],
    components: { VlInfiniteLoad, VlLazyTree },
    props: {
        vkompo: { type: Object, default: null },
        kompoid: { type: String, default: '' },
        kompoinfo: { type: String, default: '' },
        tag: { type: String, default: 'div' },
        rootKey: { type: String, default: ROOT_KEY },
        parentParam: { type: String, default: 'parent_id' },
        cursorParam: { type: String, default: 'cursor' },
        limit: { type: Number, default: 20 },
        lookahead: { type: Number, default: 80 },
        autoLoad: { type: Boolean, default: true },
        deferLoad: { type: Boolean, default: false },
        baseIndent: { type: Number, default: 1 },
        indentStep: { type: Number, default: 0.9 },
        maxIndentDepth: { type: Number, default: 5 },
    },
    data() {
        const modes = configValue(this, 'modes') || {}
        const modeOptions = modes.options || {}

        return {
            open: false,
            mode: modes.default || Object.keys(modeOptions)[0] || '',
            search: '',
            appliedSearch: '',
            searchTimer: null,
            nodesById: {},
            childrenByParent: { [this.resolvedRootKey]: [] },
            pagingByParent: {},
            expanded: {},
            loadingParents: {},
            loadingInitial: false,
            bootstrapped: false,
            bootstrapPromise: null,
            error: '',
            requestSeq: 0,
        }
    },
    computed: {
        rootAttrs() {
            return {
                ...this.$attrs,
                ...(this.vkompo && this.vkompo.id ? { id: this.vkompo.id } : {}),
                style: [this.vkompo?.style, this.$attrs.style].filter(Boolean).join('; '),
            }
        },
        rootClasses() {
            return ['vl-lazy-hierarchy', 'lazy-hierarchy', `lazy-hierarchy--${this.displayMode}`, this.vkompo?.class].filter(Boolean)
        },
        displayMode() { return this.configOr('displayMode', 'inline') },
        isDropdown() { return this.displayMode !== 'inline' },
        panelOpen() { return this.open || !this.isDropdown },
        hasControls() { return this.searchEnabled || this.modeOptionsList.length > 0 },
        triggerLabel() { return this.configValue('triggerLabel') || '' },
        searchConfig() { return this.configOr('search', {}) },
        searchEnabled() { return !!this.searchConfig.enabled },
        searchPlaceholder() { return this.searchConfig.placeholder || this.labels.searchPlaceholder },
        searchParam() { return this.searchConfig.param || 'search' },
        searchDebounce() { return this.searchConfig.debounce || 350 },
        modeConfig() { return this.configOr('modes', {}) },
        modeParam() { return this.modeConfig.param || 'mode' },
        modeOptionsList() {
            return Object.entries(this.modeConfig.options || {}).map(([value, label]) => ({ value, label }))
        },
        labels() {
            const labels = this.configOr('labels', {})
            return {
                searchPlaceholder: labels.searchPlaceholder || 'Search',
                loading: labels.loading || 'Loading...',
                empty: labels.empty || 'No items found',
                error: labels.error || 'Unable to load items',
                showMore: labels.showMore || 'Show more',
            }
        },
        uiClasses() { return this.configOr('classes', {}) },
        resolvedRootKey() { return this.configOr('rootKey', this.rootKey) },
        resolvedBootstrapUrl() { return this.configValue('bootstrapUrl') },
        resolvedNodesUrl() { return this.configValue('nodesUrl') },
        resolvedRequestParams() {
            const params = { ...(this.configValue('params') || {}) }
            if (this.searchEnabled) params[this.searchParam] = this.appliedSearch
            if (this.modeOptionsList.length && this.mode) params[this.modeParam] = this.mode
            return params
        },
        resolvedLimit() { return this.configValue('limit') || this.limit },
        resolvedLookahead() { return this.configValue('lookahead') || this.lookahead },
        resolvedBaseIndent() { return this.configOr('baseIndent', this.baseIndent) },
        resolvedIndentStep() { return this.configOr('indentStep', this.indentStep) },
        resolvedMaxIndentDepth() { return this.configOr('maxIndentDepth', this.maxIndentDepth) },
        resolvedParentParam() { return this.configValue('parentParam') || this.parentParam },
        resolvedCursorParam() { return this.configValue('cursorParam') || this.cursorParam },
        resolvedAutoLoad() {
            const c = this.configValue('autoLoad')
            return c === undefined ? this.autoLoad : !!c
        },
        resolvedDeferLoad() {
            const c = this.configValue('deferLoad')
            return c === undefined ? this.deferLoad : !!c
        },
        rootNodeIds() { return this.childrenByParent[this.resolvedRootKey] || [] },
        hasNodes() { return Object.keys(this.nodesById).length > 0 },
        isTreeLoading() { return this.loadingInitial || !!this.bootstrapPromise },
        treeProps() {
            return {
                rootKey: this.resolvedRootKey,
                rootNodeIds: this.rootNodeIds,
                nodesById: this.nodesById,
                childrenByParent: this.childrenByParent,
                pagingByParent: this.pagingByParent,
                expanded: this.expanded,
                loadingParents: this.loadingParents,
                baseIndent: this.resolvedBaseIndent,
                indentStep: this.resolvedIndentStep,
                maxIndentDepth: this.resolvedMaxIndentDepth,
                loadingLabel: this.labels.loading,
                showMoreLabel: this.labels.showMore,
            }
        },
        slotProps() {
            return {
                ...this.treeProps,
                treeProps: this.treeProps,
                rootNodeIds: this.rootNodeIds,
                hasNodes: this.hasNodes,
                isTreeLoading: this.isTreeLoading,
                bootstrapped: this.bootstrapped,
                error: this.error,
                ensureBootstrap: this.ensureBootstrap,
                reloadTree: this.reloadTree,
                resetTree: this.resetTree,
                toggleNode: this.toggleNode,
                loadChildren: this.loadChildren,
                loadMore: this.loadMore,
                loadMoreRoot: this.loadMoreRoot,
                loadMoreFromTree: this.loadMoreFromTree,
            }
        },
    },
    watch: {
        mode() {
            if (this.bootstrapped || this.bootstrapPromise) this.reloadTree()
        },
        search() {
            clearTimeout(this.searchTimer)
            this.searchTimer = setTimeout(() => {
                this.appliedSearch = this.search
                if (this.bootstrapped || this.bootstrapPromise) this.reloadTree()
            }, this.searchDebounce)
        },
    },
    mounted() {
        document.addEventListener('click', this.onDocumentClick)
        if (!this.resolvedAutoLoad) return
        if (this.resolvedDeferLoad) { this.deferBootstrap(); return }
        this.ensureBootstrap(true)
    },
    beforeDestroy() {
        document.removeEventListener('click', this.onDocumentClick)
        clearTimeout(this.searchTimer)
    },
    methods: {
        configValue(key) { return configValue(this, key) },
        configOr(key, fallback) {
            const v = this.configValue(key)
            return v === undefined ? fallback : v
        },
        modeRadioId(value) { return `lazy-hierarchy-${this._uid}-${value}` },
        togglePanel() {
            if (!this.isDropdown) return
            this.open = !this.open
            if (this.open) this.ensureBootstrap(true)
        },
        closePanel() { if (this.isDropdown) this.open = false },
        onDocumentClick(event) {
            if (!this.isDropdown || !this.open || !this.$refs.root || this.$refs.root.contains(event.target)) return
            this.open = false
        },
        deferBootstrap() {
            const run = () => this.ensureBootstrap(false)
            if (window.requestIdleCallback) { window.requestIdleCallback(run, { timeout: 1200 }); return }
            setTimeout(run, 250)
        },
        reloadTree(showLoading = true) {
            this.resetTree()
            return this.ensureBootstrap(showLoading)
        },
        resetTree() {
            this.nodesById = {}
            this.childrenByParent = { [this.resolvedRootKey]: [] }
            this.pagingByParent = {}
            this.expanded = {}
            this.loadingParents = {}
            this.bootstrapped = false
            this.bootstrapPromise = null
            this.error = ''
        },
        ensureBootstrap(showLoading = true) {
            if (this.bootstrapped) return Promise.resolve()
            if (this.bootstrapPromise) {
                if (showLoading) this.loadingInitial = true
                return this.bootstrapPromise
            }
            const seq = ++this.requestSeq
            if (showLoading) this.loadingInitial = true
            this.error = ''
            this.bootstrapPromise = this.getJson(this.resolvedBootstrapUrl, this.baseParams())
                .then(payload => {
                    if (seq !== this.requestSeq) return
                    this.mergePayload(payload)
                    this.bootstrapped = true
                    this.$emit('loaded', payload)
                })
                .catch(error => this.handleError(error, seq))
                .finally(() => {
                    if (seq === this.requestSeq) {
                        this.loadingInitial = false
                        this.bootstrapPromise = null
                    }
                })
            return this.bootstrapPromise
        },
        baseParams(extra = {}) {
            return { ...this.resolvedRequestParams, limit: this.resolvedLimit, lookahead: this.resolvedLookahead, ...extra }
        },
        toggleNode(node) {
            if (!node || !node.id || !node.hasChildren) return
            if (this.expanded[node.id]) { this.$set(this.expanded, node.id, false); return }
            this.$set(this.expanded, node.id, true)
            if (!Object.prototype.hasOwnProperty.call(this.childrenByParent, node.id)) this.loadChildren(node)
        },
        loadChildren(node) {
            if (!node || this.loadingParents[node.id]) return Promise.resolve()
            this.$set(this.loadingParents, node.id, true)
            return this.getJson(this.resolvedNodesUrl, this.baseParams({ [this.resolvedParentParam]: node.teamId || node.id }))
                .then(payload => this.mergePayload(payload))
                .catch(error => this.handleError(error))
                .finally(() => this.$set(this.loadingParents, node.id, false))
        },
        loadMore(node) {
            if (!node || !node.id || this.loadingParents[node.id]) return Promise.resolve()
            const paging = this.pagingByParent[node.id]
            if (!paging || paging.nextCursor === null) return Promise.resolve()
            this.$set(this.loadingParents, node.id, true)
            return this.getJson(this.resolvedNodesUrl, this.baseParams({
                [this.resolvedParentParam]: node.teamId || node.id,
                [this.resolvedCursorParam]: paging.nextCursor,
            }))
                .then(payload => this.mergePayload(payload, node.id))
                .catch(error => this.handleError(error))
                .finally(() => this.$set(this.loadingParents, node.id, false))
        },
        loadMoreRoot() {
            const rootKey = this.resolvedRootKey
            const paging = this.pagingByParent[rootKey]
            if (!paging || paging.nextCursor === null || this.loadingParents[rootKey]) return Promise.resolve()
            this.$set(this.loadingParents, rootKey, true)
            return this.getJson(this.resolvedNodesUrl, this.baseParams({ [this.resolvedCursorParam]: paging.nextCursor }))
                .then(payload => this.mergePayload(payload, rootKey))
                .catch(error => this.handleError(error))
                .finally(() => this.$set(this.loadingParents, rootKey, false))
        },
        loadMoreFromTree(item) {
            if (!item) return Promise.resolve()
            return item.root ? this.loadMoreRoot() : this.loadMore(item.parentNode)
        },
        mergePayload(payload, appendParentKey = null) {
            Object.entries(payload.nodes || {}).forEach(([id, node]) => { this.$set(this.nodesById, id, node) })
            Object.entries(payload.children || {}).forEach(([parentKey, childIds]) => {
                const existing = this.childrenByParent[parentKey] || []
                const shouldAppend = appendParentKey === parentKey || (payload.paging && payload.paging[parentKey] && payload.paging[parentKey].append)
                const next = shouldAppend ? this.unique([...existing, ...childIds]) : this.unique(childIds)
                this.$set(this.childrenByParent, parentKey, next)
            })
            Object.entries(payload.paging || {}).forEach(([parentKey, paging]) => { this.$set(this.pagingByParent, parentKey, paging) })
            ;(payload.expandedIds || []).forEach(id => { this.$set(this.expanded, id, true) })
        },
        unique(values) { return [...new Set(values)] },
        getJson(url, params = {}) {
            const requestUrl = new URL(url, window.location.origin)
            Object.entries(params).forEach(([key, value]) => {
                if (value !== null && value !== undefined && value !== '') requestUrl.searchParams.set(key, value)
            })
            return fetch(requestUrl.toString(), { headers: this.requestHeaders(), credentials: 'same-origin' }).then(this.parseResponse)
        },
        requestHeaders(extra = {}) {
            const kompoInfo = this.kompoInfoHeader()
            return {
                'X-Requested-With': 'XMLHttpRequest',
                ...(kompoInfo ? { 'X-Kompo-Info': kompoInfo } : {}),
                ...extra,
            }
        },
        kompoInfoHeader() {
            if (this.kompoinfo) return this.kompoinfo
            if (this.$_kompoInfo) return this.$_kompoInfo
            if (this.kompoInfo) return this.kompoInfo
            if (this.$kompo && this.kompoid && this.$_elKompoId) {
                this.$kompo.vlGetKomponentInfo(this.kompoid, this.$_elKompoId)
                if (this.kompoInfo) return this.kompoInfo
            }
            return ''
        },
        parseResponse(response) {
            if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
            return response.json()
        },
        handleError(error, seq = this.requestSeq) {
            if (seq !== this.requestSeq) return
            this.error = error.message || this.labels.error
            this.$emit('error', this.error)
        },
        kompoElementAttributes(component, item) {
            return {
                key: component.id || item.key,
                is: this.vueTag(component),
                vkompo: component,
                kompoid: this.kompoid || this.$_elKompoId,
            }
        },
        vueTag(component) { return 'Vl' + component.vueComponent },
    },
}
</script>

<style>
.lazy-hierarchy {
    --lazy-hierarchy-primary: var(--level1, var(--greenmain, var(--primary, currentColor)));
    --lazy-hierarchy-primary-dark: var(--greendark, var(--primary-dark, var(--lazy-hierarchy-primary)));
    --lazy-hierarchy-primary-soft: color-mix(in srgb, var(--lazy-hierarchy-primary) 9%, transparent);
    --lazy-hierarchy-primary-soft-hover: color-mix(in srgb, var(--lazy-hierarchy-primary) 13%, transparent);
    position: relative;
    display: block;
    color: var(--lazy-hierarchy-primary);
}

.lazy-hierarchy--dropdown { display: inline-flex; }

.lazy-hierarchy__trigger {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    border: 0;
    background: transparent;
    color: #1f2933;
    font-size: 0.875rem;
    font-weight: 600;
    line-height: 1.2;
    cursor: pointer;
    padding: 0.25rem 0.35rem;
}

.lazy-hierarchy__trigger-label {
    max-width: 12rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.lazy-hierarchy__trigger-icon {
    width: 1rem;
    height: 1rem;
    flex: 0 0 1rem;
    display: block;
    color: var(--lazy-hierarchy-primary);
    transform: translateY(0.05rem);
    transform-origin: center;
    transition: transform 0.2s ease;
}
.lazy-hierarchy__trigger-icon--open { transform: translateY(0.05rem) rotate(180deg); }

.lazy-hierarchy__panel {
    position: absolute;
    top: calc(100% + 0.45rem);
    right: 0;
    width: 33rem;
    max-width: calc(100vw - 1rem);
    max-height: min(28rem, calc(100vh - 4rem));
    display: flex;
    flex-direction: column;
    background: #fff;
    border: 1px solid #e7ece9;
    border-radius: 8px;
    box-shadow: 0 10px 24px rgba(17, 24, 39, 0.11);
    z-index: 940;
    overflow: hidden;
}
.lazy-hierarchy--inline .lazy-hierarchy__panel {
    position: static;
    width: 100%;
    max-width: none;
    max-height: none;
    border: 0;
    box-shadow: none;
}

.lazy-hierarchy__top {
    display: flex;
    align-items: flex-start;
    gap: 1.25rem;
    flex: 0 0 auto;
    padding: 1.5rem;
    border-bottom: 1px solid #edf1ef;
}

.lazy-hierarchy__search-wrap { min-width: 0; flex: 1 1 auto; margin: 0; }
.lazy-hierarchy__search { width: 100%; min-height: 2.35rem; font-size: 0.8125rem; }

.lazy-hierarchy__modes { display: flex; flex-direction: column; gap: 0.5rem; flex: 0 0 auto; }
.lazy-hierarchy__mode {
    display: flex;
    align-items: center;
    color: #334155;
    font-size: 0.75rem;
    line-height: 1;
    cursor: pointer;
    white-space: nowrap;
    min-height: 1.35rem;
}
.lazy-hierarchy__radio-control {
    width: 1.15rem !important;
    min-width: 1.15rem !important;
    max-width: 1.15rem !important;
    height: 1.15rem !important;
    min-height: 1.15rem !important;
    max-height: 1.15rem !important;
    flex: 0 0 1.15rem;
    display: inline-block;
    position: relative;
    margin-right: 0.5rem;
    padding: 0;
    background: #fff;
    border: 0;
    border-radius: 9999px;
    box-shadow: none;
    overflow: hidden;
}
.lazy-hierarchy__radio-control::after { display: none; }
.lazy-hierarchy__mode input {
    position: absolute;
    inset: 0;
    z-index: 2;
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
    appearance: none;
    border: 0;
    border-radius: 9999px;
    background: transparent;
    color: var(--lazy-hierarchy-primary);
    cursor: pointer;
    opacity: 0;
}
.lazy-hierarchy__radio-dot {
    position: absolute;
    inset: 0;
    border: 1px solid #d9e3df;
    border-radius: 9999px;
    background: #fff;
    pointer-events: none;
}
.lazy-hierarchy__radio-dot::after {
    content: "";
    position: absolute;
    inset: 0.32rem;
    border-radius: 9999px;
    background: transparent;
}
.lazy-hierarchy__mode input:checked + .lazy-hierarchy__radio-dot {
    border-color: var(--lazy-hierarchy-primary);
    background: var(--lazy-hierarchy-primary);
}
.lazy-hierarchy__mode input:checked + .lazy-hierarchy__radio-dot::after { background: #fff; }
.lazy-hierarchy__radio-control:focus-within { box-shadow: 0 0 0 3px var(--lazy-hierarchy-primary-soft-hover); }
.lazy-hierarchy__mode-label { margin: 0; color: inherit; font-size: 0.75rem; line-height: 1; }
.lazy-hierarchy__mode--active { color: var(--lazy-hierarchy-primary); font-weight: 700; }

.lazy-hierarchy__body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0.45rem 0 0.75rem; }

.lazy-hierarchy__status {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.65rem;
    padding: 1.1rem 0.65rem;
    color: #64748b;
    font-size: 0.8125rem;
    text-align: center;
}
.lazy-hierarchy__status--error { color: #b42318; }
.lazy-hierarchy__status-label { display: inline-flex; align-items: center; gap: 0.5rem; }

.lazy-hierarchy__spinner {
    width: 0.9rem;
    height: 0.9rem;
    border: 2px solid currentColor;
    border-right-color: transparent;
    border-radius: 9999px;
    animation: lazy-hierarchy-spin 0.65s linear infinite;
}

.lazy-hierarchy__sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

@keyframes lazy-hierarchy-spin { to { transform: rotate(360deg); } }

.lazy-hierarchy__skeleton-row {
    width: 100%;
    min-height: 2.35rem;
    display: flex;
    align-items: center;
    gap: 0.45rem;
}
.lazy-hierarchy__skeleton-name,
.lazy-hierarchy__skeleton-pill {
    display: inline-block;
    height: 0.8rem;
    border-radius: 9999px;
    background: linear-gradient(90deg, #eef4f1 0%, #f8faf9 50%, #eef4f1 100%);
    background-size: 180% 100%;
    animation: lazy-hierarchy-skeleton 1.1s ease-in-out infinite;
}
.lazy-hierarchy__skeleton-name { flex: 1 1 auto; }
.lazy-hierarchy__skeleton-pill { width: 4.2rem; }
@keyframes lazy-hierarchy-skeleton {
    0% { background-position: 100% 0; }
    100% { background-position: -100% 0; }
}

.lazy-hierarchy-node__row {
    min-height: 2.55rem;
    display: flex;
    align-items: center;
    gap: 0.45rem;
    padding-top: 0.2rem;
    padding-right: 1rem;
    padding-bottom: 0.2rem;
    color: var(--lazy-hierarchy-primary);
}
.lazy-hierarchy-node--current > .lazy-hierarchy-node__row { background: var(--lazy-hierarchy-primary-soft); }
.lazy-hierarchy-node--current > .lazy-hierarchy-node__row:hover { background: var(--lazy-hierarchy-primary-soft-hover); }
.lazy-hierarchy-node__row:hover { background: #f8faf9; }

.lazy-hierarchy-node__content { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.45rem; }

.lazy-hierarchy-node__toggle,
.lazy-hierarchy-node__toggle-spacer {
    width: 1rem;
    height: 1.35rem;
    flex: 0 0 1rem;
}
.lazy-hierarchy-node__toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 0;
    background: transparent;
    color: var(--lazy-hierarchy-primary-dark);
    line-height: 1;
    cursor: pointer;
}
.lazy-hierarchy-node__chevron {
    width: 0.9rem;
    height: 0.9rem;
    flex: 0 0 0.9rem;
    transition: transform 0.2s ease;
}
.lazy-hierarchy-node__chevron--open { transform: rotate(90deg); }

.lazy-hierarchy-node__loading {
    display: flex;
    align-items: center;
    padding: 0.25rem 0.55rem;
    color: #64748b;
    font-size: 0.75rem;
}

.lazy-hierarchy__show-more {
    display: flex;
    width: calc(100% - 1rem);
    margin: 0.55rem 0.5rem 0.35rem;
    border: 1px solid #dbe4df;
    border-radius: 6px;
    background: #fff;
    color: var(--lazy-hierarchy-primary);
    cursor: pointer;
    font-size: 0.75rem;
    font-weight: 700;
    padding: 0.5rem;
}
.lazy-hierarchy__show-more:hover { background: #f5fbf7; }

.lazy-hierarchy button:disabled { cursor: wait; opacity: 0.65; }

@media (max-width: 640px) {
    .lazy-hierarchy__panel {
        top: calc(100% + 0.35rem);
        right: -0.25rem;
        width: calc(100vw - 1rem);
    }
    .lazy-hierarchy__top { flex-direction: column; }
    .lazy-hierarchy__modes { flex-direction: row; }
}
</style>
```

**Step 2: Run bundler sanity check**

Run: `cd "C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils" && npx webpack --mode development 2>&1 | tail -30`
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`).

**Step 3: Verify the new file is syntactically valid by lint**

Run: if ESLint is configured (`.eslintrc` present), `npx eslint components/VlLazyHierarchy.vue`.
Expected: no parse errors. Style warnings acceptable.

---

## Task 2 — Trim `LazyHierarchy.php` to its generic surface

**Files:**
- Modify: `C:/Users/jkend/Documents/Projects/kompo/utils/src/Kompo/Elements/LazyHierarchy.php`

**Step 1: Remove `hierarchyAction()` method**

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.

**Step 2: Remove the initial `'classes' => []` / `'labels' => []` empty defaults that are now redundant**

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.

**Step 3: Confirm the full file reads**

```php
<?php

namespace Condoedge\Utils\Kompo\Elements;

use Condoedge\Utils\Services\LazyHierarchy\LazyHierarchyRegistry;
use Kompo\Elements\Block;
use Kompo\Routing\RouteFinder;

class LazyHierarchy extends Block
{
    public $vueComponent = 'LazyHierarchy';

    public function initialize($label = '')
    {
        parent::initialize($label);

        $this->config([
            'rootKey' => 'root',
            'parentParam' => 'parent_id',
            'cursorParam' => 'cursor',
            'params' => [],
            'limit' => 20,
            'lookahead' => 80,
            'autoLoad' => true,
            'deferLoad' => false,
            'displayMode' => 'inline',
            'classes' => [],
            'labels' => [],
        ]);
    }

    public function source(string $sourceClass, array $store = [])
    {
        $sourceId = app(LazyHierarchyRegistry::class)->storeSource($sourceClass, $store);

        return $this
            ->hierarchySource('utils.lazy-hierarchy.bootstrap', 'utils.lazy-hierarchy.nodes')
            ->hierarchyParam('_lazyHierarchyId', $sourceId)
            ->config(['sourceClass' => $sourceClass]);
    }

    public function hierarchySource(string $bootstrapRoute, ?string $nodesRoute = null, $bootstrapParameters = null, $nodesParameters = null)
    {
        return $this->hierarchyUrls(
            RouteFinder::guessRoute($bootstrapRoute, $bootstrapParameters),
            RouteFinder::guessRoute($nodesRoute ?: $bootstrapRoute, $nodesParameters),
        );
    }

    public function hierarchyUrls(string $bootstrapUrl, string $nodesUrl)
    {
        return $this->config(['bootstrapUrl' => $bootstrapUrl, 'nodesUrl' => $nodesUrl]);
    }

    public function hierarchyParams(array $params)
    {
        return $this->config(['params' => array_replace($this->config('params') ?: [], $params)]);
    }

    public function hierarchyParam(string $key, mixed $value)
    {
        return $this->hierarchyParams([$key => $value]);
    }

    public function hierarchyPaging(int $limit = 20, int $lookahead = 80)
    {
        return $this->config(['limit' => $limit, 'lookahead' => $lookahead]);
    }

    public function hierarchyParamNames(string $parentParam = 'parent_id', string $cursorParam = 'cursor')
    {
        return $this->config(['parentParam' => $parentParam, 'cursorParam' => $cursorParam]);
    }

    public function hierarchyRootKey(string $rootKey)
    {
        return $this->config(['rootKey' => $rootKey]);
    }

    public function hierarchyIndent(float $base = 1, float $step = 0.9, int $maxDepth = 5)
    {
        return $this->config(['baseIndent' => $base, 'indentStep' => $step, 'maxIndentDepth' => $maxDepth]);
    }

    public function hierarchyLabels(array $labels)
    {
        return $this->config(['labels' => array_replace($this->config('labels') ?: [], $labels)]);
    }

    public function hierarchyClasses(array $classes)
    {
        return $this->config(['classes' => array_replace($this->config('classes') ?: [], $classes)]);
    }

    public function loadDeferred(bool $defer = true)
    {
        return $this->config(['autoLoad' => true, 'deferLoad' => $defer]);
    }

    public function loadImmediately()
    {
        return $this->loadDeferred(false);
    }

    public function manualLoad()
    {
        return $this->config(['autoLoad' => false]);
    }

    public function dropdown(?string $triggerLabel = null)
    {
        return $this->config(array_filter([
            'displayMode' => 'dropdown',
            'triggerLabel' => $triggerLabel,
            'deferLoad' => true,
        ], fn($value) => $value !== null));
    }

    public function inline()
    {
        return $this->config(['displayMode' => 'inline']);
    }

    public function searchable(?string $placeholder = null, string $param = 'search', int $debounce = 350)
    {
        return $this->config([
            'search' => [
                'enabled' => true,
                'placeholder' => $placeholder ?: __('Search'),
                'param' => $param,
                'debounce' => $debounce,
            ],
        ]);
    }

    public function modes(array $modes, string $param = 'mode', ?string $default = null)
    {
        return $this->config([
            'modes' => [
                'options' => $modes,
                'param' => $param,
                'default' => $default ?: array_key_first($modes),
            ],
        ]);
    }

    public function triggerLabel(?string $label)
    {
        return $this->config(['triggerLabel' => $label]);
    }
}
```

**Step 4: Grep for any surviving `hierarchyAction` / `lazyLoad` callers**

Run: `grep -r "hierarchyAction\|->lazyLoad(" "C:/Users/jkend/Documents/Projects/kompo/"`
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.

---

## Task 3 — Introduce `HierarchyNodeContext` value object

**Files:**
- Create: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/HierarchyNodeContext.php`

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`".

**Step 1: Write the class**

```php
<?php

namespace Kompo\Auth\Teams;

class HierarchyNodeContext
{
    public function __construct(
        public readonly string $id,
        public readonly int $teamId,
        public readonly ?int $parentId,
        public readonly string $teamName,
        public readonly ?string $parentName,
        public readonly bool $isCurrent,
        public readonly bool $isInCurrentPath,
        public readonly bool $isCommittee,
        public readonly bool $isSelectable,
        public readonly bool $hasChildren,
        public readonly int $childrenCount,
        public readonly int $committeeCount,
        public readonly string $levelLabel,
        public readonly string $levelKey,
        public readonly array $roles,
        public readonly ?array $switchRole,
    ) {}

    public function hasSwitchableRole(): bool
    {
        return !empty($this->roles) || $this->switchRole !== null;
    }

    public function isVisible(): bool
    {
        if ($this->isSelectable) {
            return $this->hasSwitchableRole() || $this->hasChildren;
        }

        return $this->hasChildren;
    }

    public function switchOnlyRole(): ?array
    {
        if ($this->switchRole === null) {
            return null;
        }

        foreach ($this->roles as $role) {
            if (($role['id'] ?? null) === ($this->switchRole['id'] ?? null)) {
                return null;
            }
        }

        return $this->switchRole;
    }
}
```

**Step 2: Verify PHP syntax**

Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/HierarchyNodeContext.php"`
Expected: `No syntax errors detected`.

---

## Task 4 — Rewrite `TeamRoleSwitcherNodeFactory.php` to emit `{id, parentId, hasChildren, isCurrent, render}`

**Files:**
- Rewrite: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeFactory.php`

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.

**Step 1: Replace the file**

```php
<?php

namespace Kompo\Auth\Teams;

class TeamRoleSwitcherNodeFactory
{
    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';

    private const LEVEL_PILL_BG = [
        'national' => 'bg-[rgb(255,180,0)]',
        'district' => 'bg-[rgb(0,146,67)]',
        'group' => 'bg-[rgb(0,44,222)]',
        'team' => 'bg-[rgb(0,44,222)]',
        'unit' => 'bg-[rgb(130,55,255)]',
        'committee' => 'bg-[rgb(255,180,0)]',
    ];

    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';

    private const ROLE_PILL_CURRENT = 'bg-[var(--lazy-hierarchy-primary)] text-white';
    private const ROLE_PILL_DEFAULT = 'text-[var(--lazy-hierarchy-primary)] hover:bg-[color-mix(in_srgb,var(--lazy-hierarchy-primary)_8%,white)]';

    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)]';

    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>';

    public function __construct(
        private TeamRoleSwitcherTeamRepository $teams,
    ) {}

    public function context(
        $team,
        array $access,
        string $mode,
        array $currentPathIds,
        int $childrenCount,
        int $committeeCount,
        $currentTeamRole = null,
    ): HierarchyNodeContext {
        $teamId = (int) $team->id;
        $isCommittee = $this->teams->isCommittee($team);
        $isSelectable = $this->teams->isSelectableForMode($team, $mode);

        return new HierarchyNodeContext(
            id: $this->nodeId($teamId),
            teamId: $teamId,
            parentId: $team->parent_team_id ? (int) $team->parent_team_id : null,
            teamName: (string) $team->team_name,
            parentName: $team->relationLoaded('parentTeam') ? $team->parentTeam?->team_name : null,
            isCurrent: $isSelectable && $currentTeamRole && $currentTeamRole->team_id == $teamId,
            isInCurrentPath: in_array($teamId, $currentPathIds, true),
            isCommittee: $isCommittee,
            isSelectable: $isSelectable,
            hasChildren: $childrenCount > 0,
            childrenCount: $childrenCount,
            committeeCount: $committeeCount,
            levelLabel: $this->teams->teamLevelLabel($team, $isCommittee),
            levelKey: $this->teams->teamLevelKey($team, $isCommittee),
            roles: $access['roles'] ?? [],
            switchRole: $access['switchRole'] ?? null,
        );
    }

    public function toPayload(HierarchyNodeContext $ctx, string $switchUrl): array
    {
        return [
            'id' => $ctx->id,
            'teamId' => $ctx->teamId,
            'parentId' => $ctx->parentId,
            'hasChildren' => $ctx->hasChildren,
            'isCurrent' => $ctx->isCurrent,
            'render' => $this->composeRender($ctx, $switchUrl),
        ];
    }

    public function nodeId(int $teamId): string
    {
        return 'team-' . $teamId;
    }

    private function composeRender(HierarchyNodeContext $ctx, string $switchUrl): array
    {
        $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');

        $meta = _Flex(
            $this->levelPill($ctx),
            ...$this->roleButtons($ctx, $switchUrl),
            $this->switchOnlyButton($ctx, $switchUrl),
        )
            ->class('lazy-hierarchy-node__meta flex items-center justify-end gap-[0.55rem] max-w-[50%] ml-auto');

        return _Flex($name, $meta)
            ->class('flex items-center w-full gap-[0.45rem]')
            ->jsonSerialize();
    }

    private function levelPill(HierarchyNodeContext $ctx)
    {
        $bg = self::LEVEL_PILL_BG[$ctx->levelKey] ?? 'bg-[var(--lazy-hierarchy-primary)]';

        return _Html(e($ctx->levelLabel))->class(self::LEVEL_PILL_BASE . ' ' . $bg);
    }

    private function roleButtons(HierarchyNodeContext $ctx, string $switchUrl): array
    {
        $elements = [];

        foreach ($ctx->roles as $role) {
            $elements[] = _Flex(
                $this->roleNameButton($ctx, $role, $switchUrl),
                $this->goButton($ctx, $role, $switchUrl),
            )->class('inline-flex items-center gap-[0.45rem]');
        }

        return $elements;
    }

    private function roleNameButton(HierarchyNodeContext $ctx, array $role, string $switchUrl)
    {
        $current = $role['isCurrent'] ?? false;
        $extra = $current ? self::ROLE_PILL_CURRENT : self::ROLE_PILL_DEFAULT;

        return _Button($role['label'] ?? '')
            ->class(self::ROLE_PILL_BASE . ' ' . $extra)
            ->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
            ->run("() => location.reload()");
    }

    private function goButton(HierarchyNodeContext $ctx, array $role, string $switchUrl)
    {
        return _Button()
            ->icon(self::GO_ICON_SVG)
            ->class(self::GO_BUTTON_BASE)
            ->attr(['title' => __('auth.switcher-switch-role'), 'aria-label' => __('auth.switcher-switch-role')])
            ->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
            ->run("() => location.reload()");
    }

    private function switchOnlyButton(HierarchyNodeContext $ctx, string $switchUrl)
    {
        $role = $ctx->switchOnlyRole();

        if ($role === null) {
            return null;
        }

        $title = isset($role['label']) && $role['label'] !== ''
            ? __('auth.switcher-switch-role') . ': ' . $role['label']
            : __('auth.switcher-switch-role');

        return _Button()
            ->icon(self::GO_ICON_SVG)
            ->class(self::GO_BUTTON_BASE . ' lazy-hierarchy-node__go--standalone')
            ->attr(['title' => $title, 'aria-label' => $title])
            ->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
            ->run("() => location.reload()");
    }
}
```

**Step 2: Verify PHP syntax**

Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeFactory.php"`
Expected: `No syntax errors detected`.

**Step 3: Handle the fact that `_Flex(..., null, …)` may include a null**

`_Flex` with a `null` argument may error. The `switchOnlyButton` can return `null`; adjust the spread in `composeRender` to filter nulls before passing to `_Flex`:

Replace in `composeRender`:
```php
$meta = _Flex(
    $this->levelPill($ctx),
    ...$this->roleButtons($ctx, $switchUrl),
    $this->switchOnlyButton($ctx, $switchUrl),
)
```
with:
```php
$metaChildren = array_filter([
    $this->levelPill($ctx),
    ...$this->roleButtons($ctx, $switchUrl),
    $this->switchOnlyButton($ctx, $switchUrl),
]);

$meta = _Flex(...$metaChildren)
```

**Step 4: Verify the `_Flex()->jsonSerialize()` trick works**

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.

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.

---

## Task 5 — Update `TeamRoleSwitcherNodeProvider.php` to use the split factory

**Files:**
- Modify: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeProvider.php`

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).

**Step 1: Replace the file's contents with:**

```php
<?php

namespace Kompo\Auth\Teams;

use Condoedge\Utils\Kompo\LazyHierarchy\LazyHierarchyPayload;
use Illuminate\Support\Collection;
use Kompo\Auth\Teams\Contracts\TeamHierarchyInterface;
use Kompo\Auth\Teams\Contracts\TeamRoleAccessResolverInterface;

class TeamRoleSwitcherNodeProvider
{
    private const ROOT_KEY = LazyHierarchyPayload::ROOT_KEY;
    private const DEFAULT_LIMIT = 20;
    private const MAX_LIMIT = 50;
    private const LOOKAHEAD_DEPTH = 1;
    private const DEFAULT_LOOKAHEAD_BUDGET = 80;
    private const MAX_SCAN_PAGES = 6;
    private const MAX_SEARCH_SCAN_PAGES = 30;

    public function __construct(
        private TeamRoleAccessResolverInterface $access,
        private TeamRoleSwitcherTeamRepository $teams,
        private TeamRoleSwitcherNodeFactory $nodes,
        private TeamHierarchyInterface $hierarchy,
        private string $switchUrl,
    ) {}

    public function bootstrap($user, int|string|null $profile, string $mode, int $limit, int $lookaheadBudget): array
    {
        $mode = $this->normalizeMode($mode);
        $limit = $this->normalizeLimit($limit);
        $lookaheadBudget = max($limit, min(120, $lookaheadBudget ?: self::DEFAULT_LOOKAHEAD_BUDGET));

        $payload = $this->emptyPayload($mode);
        $nodeBudget = $lookaheadBudget;
        $currentPathIds = $this->currentPathIds();

        $this->appendLevel(
            payload: $payload,
            user: $user,
            profile: $profile,
            mode: $mode,
            parentId: null,
            limit: $limit,
            cursor: null,
            lookaheadDepth: self::LOOKAHEAD_DEPTH,
            nodeBudget: $nodeBudget,
            currentPathIds: $currentPathIds,
        );

        $this->appendCurrentPath($payload, $user, $profile, $mode, $limit, $currentPathIds, $nodeBudget);

        return $payload->toArray();
    }

    public function children(
        $user,
        int|string|null $profile,
        string $mode,
        ?int $parentId,
        int $limit,
        ?int $cursor,
        int $lookaheadBudget,
    ): array {
        $mode = $this->normalizeMode($mode);
        $limit = $this->normalizeLimit($limit);
        $nodeBudget = max($limit, min(120, $lookaheadBudget ?: self::DEFAULT_LOOKAHEAD_BUDGET));
        $payload = $this->emptyPayload($mode, $parentId === null);

        $this->appendLevel(
            payload: $payload,
            user: $user,
            profile: $profile,
            mode: $mode,
            parentId: $parentId,
            limit: $limit,
            cursor: $cursor,
            lookaheadDepth: self::LOOKAHEAD_DEPTH,
            nodeBudget: $nodeBudget,
            currentPathIds: $this->currentPathIds(),
            append: (int) ($cursor ?? 0) > 0,
        );

        return $payload->toArray();
    }

    public function search($user, int|string|null $profile, string $mode, string $search, int $limit): array
    {
        $mode = $this->normalizeMode($mode);
        $limit = $this->normalizeLimit($limit);
        $payload = $this->emptyPayload($mode);
        $search = trim($search);

        if ($search === '') {
            return $this->bootstrap($user, $profile, $mode, $limit, self::DEFAULT_LOOKAHEAD_BUDGET);
        }

        $currentPathIds = $this->currentPathIds();
        $contexts = collect();
        $offset = 0;
        $scanPages = 0;
        $take = max($limit * 3, self::MAX_LIMIT);

        while ($contexts->count() < $limit && $scanPages < self::MAX_SEARCH_SCAN_PAGES) {
            $teams = $this->teams->search($mode, $search, $take, $offset);

            if ($teams->isEmpty()) {
                break;
            }

            $decorated = $this->decorateTeams($teams, $user, $profile, $mode, $currentPathIds)
                ->filter(fn(HierarchyNodeContext $ctx) => $ctx->isSelectable && $ctx->hasSwitchableRole());

            $contexts = $contexts->concat($decorated)->take($limit)->values();
            $offset += $teams->count();
            $scanPages++;

            if ($teams->count() < $take) {
                break;
            }
        }

        foreach ($contexts as $ctx) {
            $node = $this->nodes->toPayload($ctx, $this->switchUrl);
            $payload->addNode($node)->appendChild(self::ROOT_KEY, $node['id']);
        }

        $payload->setPaging(self::ROOT_KEY, null, $contexts->count(), $limit);

        return $payload->toArray();
    }

    private function appendLevel(
        LazyHierarchyPayload $payload,
        $user,
        int|string|null $profile,
        string $mode,
        ?int $parentId,
        int $limit,
        ?int $cursor,
        int $lookaheadDepth,
        int &$nodeBudget,
        array $currentPathIds,
        bool $append = false,
    ): void {
        if ($nodeBudget <= 0) {
            return;
        }

        $parentKey = $this->parentKey($parentId);
        $page = $this->visibleChildrenPage($user, $profile, $mode, $parentId, $limit, $cursor, $currentPathIds);
        $nodeIds = [];

        foreach ($page['contexts'] as $ctx) {
            if ($nodeBudget <= 0) {
                break;
            }

            $node = $this->nodes->toPayload($ctx, $this->switchUrl);
            $payload->addNode($node);
            $nodeIds[] = $node['id'];
            $nodeBudget--;
        }

        $payload
            ->setChildren($parentKey, $nodeIds, $append)
            ->setPaging($parentKey, $page['nextCursor'], $page['total'], $limit, $append);

        if ($lookaheadDepth <= 0) {
            return;
        }

        foreach ($page['contexts'] as $ctx) {
            if ($nodeBudget <= 0) {
                break;
            }

            if (!$ctx->hasChildren) {
                continue;
            }

            $this->appendLevel(
                payload: $payload,
                user: $user,
                profile: $profile,
                mode: $mode,
                parentId: $ctx->teamId,
                limit: $limit,
                cursor: null,
                lookaheadDepth: $lookaheadDepth - 1,
                nodeBudget: $nodeBudget,
                currentPathIds: $currentPathIds,
            );
        }
    }

    private function visibleChildrenPage(
        $user,
        int|string|null $profile,
        string $mode,
        ?int $parentId,
        int $limit,
        ?int $cursor,
        array $currentPathIds,
    ): array {
        $offset = max(0, (int) ($cursor ?? 0));
        $scanOffset = $offset;
        $visible = collect();
        $scanPages = 0;
        $total = $this->teams->childrenTotal($mode, $parentId);

        while ($visible->count() < $limit && $scanPages < self::MAX_SCAN_PAGES) {
            $take = max($limit * 2, self::DEFAULT_LIMIT);
            $rawTeams = $this->teams->children($mode, $parentId, $take, $scanOffset);

            if ($rawTeams->isEmpty()) {
                break;
            }

            $decorated = $this->decorateTeams($rawTeams, $user, $profile, $mode, $currentPathIds)
                ->filter(fn(HierarchyNodeContext $ctx) => $ctx->isVisible());

            $visible = $visible->concat($decorated)->take($limit)->values();
            $scanOffset += $rawTeams->count();
            $scanPages++;

            if ($rawTeams->count() < $take) {
                break;
            }
        }

        return [
            'contexts' => $visible,
            'nextCursor' => $scanOffset < $total ? $scanOffset : null,
            'total' => $total,
        ];
    }

    private function appendCurrentPath(
        LazyHierarchyPayload $payload,
        $user,
        int|string|null $profile,
        string $mode,
        int $limit,
        array $currentPathIds,
        int &$nodeBudget,
    ): void {
        if (empty($currentPathIds)) {
            return;
        }

        $teamsById = $this->teams->findMany($currentPathIds)->keyBy('id');

        if ($teamsById->isEmpty()) {
            return;
        }

        $currentTeam = $teamsById->get((int) end($currentPathIds));

        if (!$currentTeam || !$this->teams->isSelectableForMode($currentTeam, $mode)) {
            return;
        }

        $contextsByTeamId = $this->decorateTeams($teamsById->values(), $user, $profile, $mode, $currentPathIds)
            ->keyBy(fn(HierarchyNodeContext $ctx) => $ctx->teamId);

        $currentCtx = $contextsByTeamId->get((int) end($currentPathIds));

        if (!$currentCtx || !$currentCtx->hasSwitchableRole()) {
            return;
        }

        foreach ($currentPathIds as $index => $teamId) {
            $ctx = $contextsByTeamId->get($teamId);

            if (!$ctx) {
                continue;
            }

            $node = $this->nodes->toPayload($ctx, $this->switchUrl);
            $payload->addNode($node);

            $parentId = $index === 0 ? null : (int) $currentPathIds[$index - 1];
            $payload->prependChild($this->parentKey($parentId), $node['id']);

            if ($index < count($currentPathIds) - 1) {
                $payload->expand($node['id']);
                $this->appendLevel(
                    payload: $payload,
                    user: $user,
                    profile: $profile,
                    mode: $mode,
                    parentId: $teamId,
                    limit: $limit,
                    cursor: null,
                    lookaheadDepth: 0,
                    nodeBudget: $nodeBudget,
                    currentPathIds: $currentPathIds,
                );
            }
        }
    }

    private function decorateTeams(Collection $teams, $user, int|string|null $profile, string $mode, array $currentPathIds): Collection
    {
        if ($teams->isEmpty()) {
            return collect();
        }

        $currentTeamRole = function_exists('currentTeamRole') ? currentTeamRole() : null;
        $accessByTeam = $this->access->resolveForCandidates($teams, $user, $profile, $mode, $currentTeamRole);
        $visibleChildrenCounts = $mode === TeamAccessHierarchyBuilder::MODE_TEAMS
            ? $this->visibleDirectChildrenCounts($teams->pluck('id'), $user, $profile, $mode)
            : [];

        return $teams->map(function ($team) use ($accessByTeam, $visibleChildrenCounts, $mode, $currentPathIds, $currentTeamRole) {
            $teamId = (int) $team->id;
            $isSelectable = $this->teams->isSelectableForMode($team, $mode);
            $access = $isSelectable ? ($accessByTeam[$teamId] ?? []) : [];
            $childrenCount = $mode === TeamAccessHierarchyBuilder::MODE_TEAMS
                ? (int) ($visibleChildrenCounts[$teamId] ?? 0)
                : $this->teams->countDirectChildren($teamId, $mode);
            $committeeCount = $mode === TeamAccessHierarchyBuilder::MODE_COMMITTEES
                ? $this->teams->countDirectCommittees($teamId)
                : 0;

            return $this->nodes->context(
                team: $team,
                access: $access,
                mode: $mode,
                currentPathIds: $currentPathIds,
                childrenCount: $childrenCount,
                committeeCount: $committeeCount,
                currentTeamRole: $currentTeamRole,
            );
        })->values();
    }

    private function visibleDirectChildrenCounts(Collection $parentIds, $user, int|string|null $profile, string $mode): array
    {
        $parentIds = $parentIds->map(fn($id) => (int) $id)->filter()->unique()->values();

        if ($parentIds->isEmpty()) {
            return [];
        }

        $query = $this->teams->teamQuery()->whereIn('teams.parent_team_id', $parentIds->all());
        $this->teams->applyModeFilter($query, $mode);
        $children = $query->get();

        if ($children->isEmpty()) {
            return [];
        }

        $accessByChild = $this->access->resolveForCandidates($children, $user, $profile, $mode, includeCurrentRole: false);
        $activeRoleBranchIndex = $this->access->activeRoleBranchIndex($user, $profile, $mode);
        $counts = [];

        foreach ($children as $child) {
            $childId = (int) $child->id;
            $parentId = $child->parent_team_id ? (int) $child->parent_team_id : null;

            if (!$parentId) {
                continue;
            }

            $childAccess = $this->teams->isSelectableForMode($child, $mode) ? ($accessByChild[$childId] ?? []) : [];

            if (empty($childAccess['roles']) && empty($childAccess['switchRole']) && !$activeRoleBranchIndex->has($childId)) {
                continue;
            }

            $counts[$parentId] = ($counts[$parentId] ?? 0) + 1;
        }

        return $counts;
    }

    private function currentPathIds(): array
    {
        $currentTeamId = currentTeamId();

        if (!$currentTeamId) {
            return [];
        }

        return $this->hierarchy
            ->getAncestorTeamIds($currentTeamId)
            ->map(fn($id) => (int) $id)
            ->values()
            ->all();
    }

    private function emptyPayload(string $mode, bool $includeRoot = true): LazyHierarchyPayload
    {
        return LazyHierarchyPayload::make(['mode' => $mode], self::ROOT_KEY, $includeRoot);
    }

    private function normalizeMode(?string $mode): string
    {
        return $mode === TeamAccessHierarchyBuilder::MODE_COMMITTEES
            ? TeamAccessHierarchyBuilder::MODE_COMMITTEES
            : TeamAccessHierarchyBuilder::MODE_TEAMS;
    }

    private function normalizeLimit(int $limit): int
    {
        return max(1, min(self::MAX_LIMIT, $limit ?: self::DEFAULT_LIMIT));
    }

    private function parentKey(?int $parentId): string
    {
        return $parentId ? $this->nodes->nodeId($parentId) : self::ROOT_KEY;
    }
}
```

**Step 2: The provider now takes `$switchUrl` in its constructor**

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.

**Step 3: Verify PHP syntax**

Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeProvider.php"`
Expected: `No syntax errors detected`.

---

## Task 6 — Simplify `TeamRoleSwitcherElement.php` and wire `$switchUrl` into the source store

**Files:**
- Rewrite: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherElement.php`
- Modify: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherHierarchySource.php`

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.

**Step 1: Rewrite the element**

```php
<?php

namespace Kompo\Auth\Teams;

use Condoedge\Utils\Kompo\Elements\LazyHierarchy;

class TeamRoleSwitcherElement extends LazyHierarchy
{
    public function initialize($label = '')
    {
        parent::initialize($label);

        $currentTeamRole = currentTeamRole();
        $profile = $currentTeamRole?->roleRelation?->profile ?? 1;
        $switchUrl = route('team-role-switcher.switch');

        $this
            ->source(TeamRoleSwitcherHierarchySource::class, ['switchUrl' => $switchUrl])
            ->hierarchyPaging(20, 80)
            ->dropdown($currentTeamRole?->getRoleName())
            ->searchable(__('auth.search-placeholder'))
            ->modes([
                TeamAccessHierarchyBuilder::MODE_TEAMS => __('auth.switcher-teams'),
                TeamAccessHierarchyBuilder::MODE_COMMITTEES => __('auth.switcher-committees'),
            ], 'mode', TeamAccessHierarchyBuilder::MODE_TEAMS)
            ->hierarchyParam('profile', $profile)
            ->hierarchyLabels([
                'searchPlaceholder' => __('auth.search-placeholder'),
                'loading' => __('auth.switcher-loading'),
                'empty' => __('auth.switcher-empty'),
                'error' => __('auth.switcher-error'),
                'showMore' => __('auth.switcher-show-more'),
            ])
            ->class('kompo-team-role-switcher-shell');
    }
}
```

**Step 2: Update `TeamRoleSwitcherHierarchySource` to pass `$switchUrl` from its `$store` into a fresh provider instance**

```php
<?php

namespace Kompo\Auth\Teams;

use Condoedge\Utils\Contracts\LazyHierarchySourceInterface;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class TeamRoleSwitcherHierarchySource implements LazyHierarchySourceInterface
{
    private TeamRoleSwitcherNodeProvider $provider;

    public function __construct(
        TeamRoleAccessResolverInterface $access,
        TeamRoleSwitcherTeamRepository $teams,
        TeamRoleSwitcherNodeFactory $nodes,
        TeamHierarchyInterface $hierarchy,
        protected array $store = [],
    ) {
        $switchUrl = (string) ($store['switchUrl'] ?? route('team-role-switcher.switch'));

        $this->provider = new TeamRoleSwitcherNodeProvider(
            $access,
            $teams,
            $nodes,
            $hierarchy,
            $switchUrl,
        );
    }

    public function bootstrap(Request $request): array
    {
        $data = $this->validatedListRequest($request);

        if ($data['search'] !== '') {
            return $this->provider->search(
                $request->user(),
                $data['profile'],
                $data['mode'],
                $data['search'],
                $data['limit'],
            );
        }

        return $this->provider->bootstrap(
            $request->user(),
            $data['profile'],
            $data['mode'],
            $data['limit'],
            $data['lookahead'],
        );
    }

    public function children(Request $request): array
    {
        $data = $this->validatedListRequest($request, [
            'parent_id' => ['nullable', 'integer', 'min:1', 'exists:teams,id'],
            'cursor' => ['nullable', 'integer', 'min:0'],
        ]);

        return $this->provider->children(
            $request->user(),
            $data['profile'],
            $data['mode'],
            $data['parent_id'] ?? null,
            $data['limit'],
            $data['cursor'] ?? null,
            $data['lookahead'],
        );
    }

    protected function validatedListRequest(Request $request, array $extraRules = []): array
    {
        abort_unless($request->user(), 403);

        $data = $request->validate(array_merge([
            'mode' => ['nullable', Rule::in([
                TeamAccessHierarchyBuilder::MODE_TEAMS,
                TeamAccessHierarchyBuilder::MODE_COMMITTEES,
            ])],
            'profile' => ['nullable', 'string', 'max:50'],
            'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
            'lookahead' => ['nullable', 'integer', 'min:20', 'max:120'],
            'search' => ['nullable', 'string', 'max:100'],
        ], $extraRules));

        return [
            'mode' => $data['mode'] ?? TeamAccessHierarchyBuilder::MODE_TEAMS,
            'profile' => $data['profile'] ?? currentTeamRole()?->roleRelation?->profile ?? 1,
            'limit' => (int) ($data['limit'] ?? 20),
            'lookahead' => (int) ($data['lookahead'] ?? 80),
            'search' => trim((string) ($data['search'] ?? '')),
        ] + $data;
    }
}
```

Add the needed `use` statements at the top:
```php
use Kompo\Auth\Teams\Contracts\TeamHierarchyInterface;
use Kompo\Auth\Teams\Contracts\TeamRoleAccessResolverInterface;
```

**Step 3: Verify the `LazyHierarchyRegistry` instantiates the source with the store**

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.

**Step 4: PHP lint all three files**

Run:
```
php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherElement.php"
php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherHierarchySource.php"
php -l "C:/Users/jkend/Documents/Projects/kompo/utils/src/Services/LazyHierarchy/LazyHierarchyRegistry.php"
```
Expected: all three report `No syntax errors detected`.

---

## Task 7 — Feature test: payload shape & render composition

**Files:**
- Create: `C:/Users/jkend/Documents/Projects/kompo/auth/tests/Feature/Teams/TeamRoleSwitcherHierarchyPayloadTest.php`

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.

**Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature\Teams;

use Tests\TestCase;

class TeamRoleSwitcherHierarchyPayloadTest extends TestCase
{
    public function test_bootstrap_returns_root_nodes_with_render_trees(): void
    {
        [$user, $nationalTeam] = $this->seedNationalAdmin();
        $this->actingAs($user);

        $response = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
            '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
            'mode' => 'teams',
        ]));

        $response->assertOk();
        $payload = $response->json();

        $this->assertArrayHasKey('nodes', $payload);
        $this->assertArrayHasKey('children', $payload);
        $this->assertArrayHasKey('paging', $payload);

        $rootIds = $payload['children']['root'] ?? [];
        $this->assertNotEmpty($rootIds, 'Root must have at least one node');

        $node = $payload['nodes'][$rootIds[0]];
        $this->assertArrayHasKey('id', $node);
        $this->assertArrayHasKey('parentId', $node);
        $this->assertArrayHasKey('hasChildren', $node);
        $this->assertArrayHasKey('isCurrent', $node);
        $this->assertArrayHasKey('render', $node);

        $render = $node['render'];
        $this->assertSame('Flex', $render['vueComponent']);
        $this->assertIsArray($render['elements']);
        $this->assertGreaterThanOrEqual(2, count($render['elements']), 'Render must contain at least name + meta');
    }

    public function test_national_row_contains_role_name_button(): void
    {
        [$user, $nationalTeam] = $this->seedNationalAdmin();
        $this->actingAs($user);

        $payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
            '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
            'mode' => 'teams',
        ]))->json();

        $node = $this->findNodeByTeamId($payload, $nationalTeam->id);
        $this->assertNotNull($node);

        $buttonLabels = $this->collectButtonLabels($node['render']);
        $this->assertContains('Super admin', $buttonLabels, 'National row must render a "Super admin" role-name button');
    }

    public function test_unit_row_has_only_go_button_when_role_inherited(): void
    {
        [$user, , $unitTeam] = $this->seedNationalAdmin();
        $this->actingAs($user);

        $payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
            '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
            'mode' => 'teams',
        ]))->json();

        $node = $this->findNodeByTeamId($payload, $unitTeam->id);
        $this->assertNotNull($node);

        $buttonLabels = $this->collectButtonLabels($node['render']);
        $this->assertEquals(
            [''],
            $buttonLabels,
            'Unit row must render exactly one button with empty label (go-only variant)'
        );
    }

    public function test_current_node_has_is_current_true(): void
    {
        [$user, , $unitTeam] = $this->seedNationalAdmin();
        $this->actingAs($user);
        $this->setCurrentTeam($user, $unitTeam);

        $payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
            '_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
            'mode' => 'teams',
        ]))->json();

        $node = $this->findNodeByTeamId($payload, $unitTeam->id);
        $this->assertTrue($node['isCurrent'], 'The current team must have isCurrent=true');
    }

    // --- helpers: seed the minimal hierarchy + register a source -------

    private function seedNationalAdmin(): array
    {
        // Produces a National → District → Group → Unit chain and a user with
        // the Super admin role on National. Return [$user, $national, $unit].
        // Implementation specific to the project's factory/fixture patterns.
        // TODO: adapt to existing test helpers in auth/tests/TestCase.php
        $this->markTestIncomplete('Implement seeder using existing auth/tests/ fixtures');
    }

    private function registerTeamRoleSwitcherSource(): string
    {
        // Mirror what TeamRoleSwitcherElement::initialize() does to stash the
        // source in LazyHierarchyRegistry and return the opaque id.
        // TODO: call app(LazyHierarchyRegistry::class)->storeSource(...)
        $this->markTestIncomplete('Wire registerTeamRoleSwitcherSource helper');
    }

    private function setCurrentTeam($user, $team): void
    {
        // Set the active team-role for the user so currentTeam()/currentTeamRole()
        // resolve to this team during the request.
        $this->markTestIncomplete('Wire setCurrentTeam helper');
    }

    private function findNodeByTeamId(array $payload, int $teamId): ?array
    {
        $nodeId = 'team-' . $teamId;
        return $payload['nodes'][$nodeId] ?? null;
    }

    private function collectButtonLabels(array $element): array
    {
        $labels = [];
        $this->walkElements($element, function ($el) use (&$labels) {
            if (($el['vueComponent'] ?? null) === 'Button') {
                $labels[] = (string) ($el['label'] ?? '');
            }
        });
        return $labels;
    }

    private function walkElements(array $element, callable $visit): void
    {
        $visit($element);
        foreach ($element['elements'] ?? [] as $child) {
            if (is_array($child)) {
                $this->walkElements($child, $visit);
            }
        }
    }
}
```

**Step 2: Run the failing test**

Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --filter TeamRoleSwitcherHierarchyPayloadTest`
Expected: the tests are marked incomplete until seeders are wired, which is acceptable — the structure is in place.

**Step 3: Fill in the incomplete seeders using existing fixtures**

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`.

**Step 4: Run the tests to green**

Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --filter TeamRoleSwitcherHierarchyPayloadTest`
Expected: all four tests pass.

**Step 5: Run the full auth feature suite to catch regressions**

Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --testsuite Feature`
Expected: previously-passing tests still pass; no new failures.

---

## Task 8 — Build & manual browser verification

**Files:** none

**Step 1: Rebuild the front-end bundle**

Run: `cd "C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils" && npm run build` (or the project's equivalent; check `package.json`).
Expected: build succeeds with no errors.

**Step 2: Boot the consuming application**

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.

**Step 3: Smoke-test the dropdown**

Walk through every row type in `docs/plans/2026-04-18-vl-lazy-hierarchy-generic-render.md` Design Summary and confirm:

- Dropdown trigger shows `currentTeamRole->getRoleName()` + chevron.
- Opening the panel reveals search + "Teams" / "Committees" radios with Teams selected by default.
- Root level renders at least "National" with:
  - Yellow `National` pill, `Super admin` role-name pill to its right, and a go (person-with-arrow) icon button.
  - 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.
- 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.
- The current team row has the highlighted (soft-green) background.
- Switching to "Committees" reloads the tree with yellow `Committee` pills.
- Typing in the search box debounces and filters with parent names shown under matches.
- Loading states and skeleton rows appear briefly during fetches.

**Step 4: Compare side-by-side with the original**

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.

**Step 5: Regression scan for other consumers**

Run: `grep -r "VlLazyHierarchy\|_LazyHierarchy(" "C:/Users/jkend/Documents/Projects/kompo/" --include="*.php" --include="*.vue" --include="*.js" --include="*.blade.php"`
Confirm only `TeamRoleSwitcherElement`, `MenuRolesSwitcherDropdown`, and the helper in `utils/src/Helpers/elements/components.php` reference the element.

---

## Done Criteria

- `VlLazyHierarchy.vue` contains no references to `roles`, `committee`, `level`, `switchRole`, or any domain concept.
- `LazyHierarchy.php` has no `hierarchyAction` method.
- `TeamRoleSwitcherNodeFactory.php` produces `{id, parentId, hasChildren, isCurrent, render}` payload nodes with a composed `_Flex` render tree.
- All feature tests in Task 7 pass.
- Manual browser walk-through in Task 8 matches the provided reference image.
- `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.)
