9 Commits

Author SHA1 Message Date
524d0ee586 Merge pull request 'chore(f3.5): AppShell mockup parity — sidebar, topbar, plugin fixes' (#26) from chore/f3.5-appshell-mockup-parity into main
Reviewed-on: #26
2026-05-14 13:38:49 +02:00
71585e1bbc fix(appshell): wrap PrimeVue responsive elements to bypass specificity conflict
Tailwind's lg:hidden loses to PrimeVue's .p-button { display: inline-flex }
due to equal specificity but later cascade order. Resulted in the mobile
hamburger remaining visible on desktop, allowing the Drawer to open over
the already-visible permanent sidebar.

Fix: wrap mobile-only cluster (hamburger + title) in a plain <div lg:hidden>
so the wrapper owns the visibility toggle. The wrapper is not a PrimeVue
component, so no specificity competition.

The Drawer itself had the same anti-pattern (class="lg:hidden") and is
worse, because PrimeVue Drawer teleports to body — a wrapping div on the
parent does not isolate the teleported overlay, and a class on the Drawer
root loses to .p-drawer { display: flex } when visible. Converted to
v-if="!isLg" driven by useMediaQuery('(min-width: 1024px)'). Vue simply
does not render the component on lg+, so no display rule competes.

Audited all 5 layouts for the same anti-pattern:
- AppShell.vue — fixed (Button + Drawer described above)
- default.vue / OrganizerLayout.vue / PortalLayout.vue — delegate to
  AppShell; no PrimeVue elements with responsive classes
- blank.vue — plain <div>, no PrimeVue
- PublicLayout.vue — plain <main>, no PrimeVue

useMediaQuery is auto-imported via unplugin-auto-import's @vueuse/core
entry in vite.config.ts; explicit imports get stripped by the post-edit
ESLint --fix hook as redundant.

F3-introduced bug (commit 43915501); surfaced during F3.5 testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:36:00 +02:00
f218ac6e69 fix(primevue): switch installer to named export to stop double-registration
main.ts explicitly calls installPrimeVue(app) AFTER registerPlugins(app)
per the comment in main.ts ("so PrimeVue lives outside the Vuexy @core
machine"). The intent was a single registration site outside the
auto-discovery loop.

Bug: registerPlugins (src/@core/utils/plugins.ts) globs
plugins/*/index.{ts,js} eagerly and invokes the `default` export of
each match. plugins/primevue/index.ts was exporting installPrimeVue
as the default, so registerPlugins also picked it up and called it.
End result: PrimeVue and its three services (Toast, Confirmation,
Dialog) were each registered twice on every app boot. Visible
symptoms: duplicate Toast emissions on a single Toast.add() call,
and ConfirmationService callbacks firing twice for one user
confirmation.

Fix: convert `export default function installPrimeVue` to a NAMED
export, and update main.ts's import to `{ installPrimeVue }`. The
registerPlugins glob still picks up the module path but the
`pluginImportModule.default?.(app)` invocation becomes a no-op via
optional chaining (no default export to call). main.ts remains the
single registration site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:35:59 +02:00
b1443be414 fix(iconify): bootstrap Tabler icon set at runtime for @iconify/vue
The PrimeVue side of the parallel-mode app renders icons via
@iconify/vue's <Icon> component (src/components/Icon.vue). At runtime
@iconify/vue resolves an icon name like "tabler-eye" by looking up
its data in the in-memory icon registry; on a miss it falls back to
fetching https://api.iconify.design/tabler/eye.json. The CSP blocks
that origin, so every Tabler icon used in AppShell, SidebarHeader,
SidebarUserCard, and the migrated login form rendered as an empty
<svg viewBox="0 0 16 16"></svg>.

New plugins/iconify.ts loads the full Tabler set
(@iconify-json/tabler/icons.json, already in package.json as 1.2.23)
and registers it via addCollection() at module-load time. main.ts
side-effect-imports it before any other import so the registry is
warm before the first Icon mounts.

This is a NEW concern, separate from the existing plugins/iconify/
(index.ts + icons.css) which generates Vuexy-style i-tabler-* CSS
classes for Vuetify's VIcon adapter. The two systems must coexist
during F3–F6 parallel mode; the legacy directory can be deleted
alongside Vuetify when F6 lands.

Bundle cost: ~1.9 MB uncompressed JSON, ~400 KB gzipped in the main
chunk. Per-icon imports are a future optimisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:35:59 +02:00
29f3fdf2a3 fix(appshell): explicitly import SidebarHeader and SidebarUserCard
unplugin-vue-components' Components({ dirs }) in vite.config.ts only
scans src/components, src/@core/components, and src/views/demos. The
sub-components introduced in B1/B3 live under src/layouts/components/,
which is NOT in the auto-import scan path. Without an explicit script
import, Vue renders <SidebarHeader> and <SidebarUserCard> as unknown
HTML elements (no DOM output, no errors), which is why the topbar and
sidebar-bottom cards looked empty in browser inspection.

Adding the two imports inline with the existing Icon import keeps the
component graph explicit. The post-edit eslint --fix hook preserves
the imports because the template usages (already present from B1 and
B3) make vue-eslint-parser see them as used.

The original B1/B3 commits had the imports stripped by the hook
because the imports were added in a separate Edit *before* the
template usages — eslint --fix correctly removed them as unused at
that moment, and the next Edit added the template usage but not the
import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:35:58 +02:00
3df55b4d1c feat(appshell): topbar breadcrumb, notification bell, and help icon
Left side gains a desktop-only breadcrumb "Organisation / Page title"
using the current organisation from useAuthStore and a page title
resolved by:

  1. route.meta.title (if a page sets it explicitly), then
  2. matching the active route name against the navItems prop, then
  3. humanizing the route name as a last-resort fallback.

The chevron separator is suppressed when either side is empty, so
portal and pre-org users see just the page title. Mobile preserves
the existing hamburger + title text (the breadcrumb is hidden on
<lg to keep the topbar single-row).

Right side gains a notification bell and a help icon. The bell is a
visual placeholder (no badge) — clicking shows a PrimeVue Toast
"Notificaties komen binnenkort beschikbaar" until the notification
framework lands as a separate sprint.

The help icon would normally open https://docs.crewli.app in a new
tab, but the host currently serves with a TLS cert that does not
cover the name (ERR_TLS_CERT_ALTNAME_INVALID), so the click handler
falls back to a Toast. A TODO comment in the source records the
target URL and the one-line switch to make once the cert is fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:35:57 +02:00
f8fddc0e14 feat(appshell): add user-info card to sidebar bottom; remove topbar avatar
Consolidates the user menu into a single sidebar-bottom location.
SidebarUserCard.vue shows avatar (initial), full name, role (Dutch
label mapped from org pivot role or 'Super Admin' fallback) and a
chevron-up that opens a PrimeVue Menu with "Mijn Profiel" and
"Uitloggen". The Menu uses popup mode; PrimeVue v4's absolutePosition
logic auto-flips above the trigger when the panel would overflow the
viewport bottom — verify in Phase C.

AppShell loses the topbar avatar Button + Menu and the associated
state (userMenuRef, userInitial, userMenuItems, toggleUserMenu) plus
its imports (Avatar, Menu, useAuthStore, computed). The component is
now a pure layout shell with no auth-store coupling. The topbar's
right side is intentionally empty in this commit; B4 fills it with
breadcrumb / notification bell / help icon.

Layout: nav uses min-h-0 flex-1 overflow-y-auto so it shrinks under
viewport pressure and lets the user card stay pinned at the bottom
of the sidebar. Mobile Drawer's content pt-override sets the same
flex-column behaviour so the user card sits flush at the bottom of
the drawer overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:35:57 +02:00
4089a14bb8 feat(appshell): refine section label styling for sidebar nav
Section headings ("Beheer" / organisation name, "Platform") were
already uppercase + muted but read as bold paragraph dividers more
than as quiet group markers. Tighten letter-spacing, drop weight
from semibold to medium, lighten the color one step (surface-500 →
surface-400), and shrink text to 11px so the headings recede and
let the nav items themselves carry the visual weight.

Spacing nudged from mt-4/mb-2/px-2 → mt-6/mb-1/px-3: more breathing
room above each group, less below (the items already have py-2 on
top), and the heading left-edge now lines up with the icons of the
nav items beneath it (both at px-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:35:56 +02:00
8f3a404a42 feat(appshell): add org-switcher card and bump sidebar width to w-72
Introduces SidebarHeader.vue — a PrimeVue-only org-switcher that
replaces the centered Crewli wordmark at the top of the sidebar. The
component mirrors the legacy Vuetify OrganisationSwitcher (avatar with
org initials, organisation name, plan-tier placeholder, dropdown
chevron, PrimeVue Menu of available orgs) but cannot reuse it
directly per the R-10 layout-shell-isolation invariant.

Plan-tier shows a hardcoded "Pro" placeholder until the backend
Organisation resource exposes a plan field — tracked separately, not
in F3.5 scope. When the user has no active organisation (portal
users, fresh super_admin), the component degrades to the original
title block so PortalLayout continues to read "Crewli Portal".

Desktop sidebar width bumped w-64 → w-72 (256 → 288 px) to give the
org-switcher card breathing room and accommodate the user-info card
arriving in B3. Mobile Drawer width bumped 16rem → 18rem to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:35:56 +02:00
6 changed files with 391 additions and 70 deletions

View File

@@ -17,12 +17,13 @@
// (a RouterView from the wrapping layout file).
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import Drawer from 'primevue/drawer'
import Button from 'primevue/button'
import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import Icon from '@/components/Icon.vue'
import SidebarHeader from '@/layouts/components/SidebarHeader.vue'
import SidebarUserCard from '@/layouts/components/SidebarUserCard.vue'
import { useAuthStore } from '@/stores/useAuthStore'
interface NavHeading {
@@ -40,42 +41,23 @@ interface Props {
title?: string
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
title: 'Crewli',
})
const router = useRouter()
const route = useRoute()
const toast = useToast()
const authStore = useAuthStore()
const mobileNavOpen = ref(false)
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
const userInitial = computed(() => {
const name = authStore.user?.full_name ?? ''
return name.charAt(0).toUpperCase() || '?'
})
const userMenuItems = computed(() => [
{
label: authStore.user?.full_name ?? 'Gebruiker',
items: [
{
label: 'Mijn Profiel',
icon: 'tabler-user',
command: () => router.push({ name: 'account-settings' }),
},
{
label: 'Uitloggen',
icon: 'tabler-logout',
command: async () => {
await authStore.logout()
await router.push('/login')
},
},
],
},
])
// Tailwind's lg breakpoint, mirrored in script so Vue can own the
// visibility of PrimeVue elements that would otherwise lose a CSS
// specificity duel to .p-button / .p-drawer / etc. See the wrapper
// `<div class="lg:hidden">` around the topbar mobile cluster and the
// `v-if="!isLg"` on the Drawer.
const isLg = useMediaQuery('(min-width: 1024px)')
function isHeading(item: NavItem): item is NavHeading {
return 'heading' in item
@@ -86,26 +68,68 @@ function navigate(item: NavLink) {
router.push(item.to)
}
function toggleUserMenu(event: Event) {
userMenuRef.value?.toggle(event)
// Breadcrumb: "Organisation / Page title". Page title resolves from
// route.meta.title → matching navItems entry → humanized route name.
// Org name is omitted on portal / pre-org users (currentOrganisation
// is null) so the breadcrumb reads as just the page title there.
const orgName = computed(() => authStore.currentOrganisation?.name ?? '')
const pageTitle = computed(() => {
const metaTitle = route.meta.title
if (typeof metaTitle === 'string' && metaTitle.length > 0)
return metaTitle
const match = props.navItems.find(
(item): item is NavLink => 'to' in item && item.to.name === route.name,
)
if (match)
return match.title
const raw = String(route.name ?? '')
if (!raw)
return ''
return raw.charAt(0).toUpperCase() + raw.slice(1).replace(/-/g, ' ')
})
function onNotificationsClick() {
toast.add({
severity: 'info',
summary: 'Notificaties',
detail: 'Notificaties komen binnenkort beschikbaar.',
life: 3000,
})
}
// TODO(docs-url): https://docs.crewli.app currently serves with a TLS
// cert that does not cover the host (ERR_TLS_CERT_ALTNAME_INVALID),
// so a browser hit shows a security warning instead of the docs.
// When the cert is fixed, switch this handler to:
// window.open('https://docs.crewli.app', '_blank', 'noopener,noreferrer')
function onHelpClick() {
toast.add({
severity: 'info',
summary: 'Help',
detail: 'Documentatie komt binnenkort beschikbaar.',
life: 3000,
})
}
</script>
<template>
<div class="crewli-app-shell flex min-h-screen">
<!-- Desktop sidebar (lg+) -->
<aside class="hidden lg:flex w-64 flex-col border-r border-surface-200 bg-surface-0">
<div class="flex h-16 items-center justify-center border-b border-surface-200">
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
</div>
<nav class="flex-1 overflow-y-auto p-3">
<aside class="hidden lg:flex w-72 flex-col border-r border-surface-200 bg-surface-0">
<SidebarHeader :title="title" />
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
<template
v-for="(item, idx) in navItems"
:key="idx"
>
<div
v-if="isHeading(item)"
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
class="mt-6 mb-1 px-3 text-[11px] font-medium uppercase tracking-widest text-surface-400"
>
{{ item.heading }}
</div>
@@ -123,26 +147,36 @@ function toggleUserMenu(event: Event) {
</button>
</template>
</nav>
<SidebarUserCard />
</aside>
<!-- Mobile drawer (overlay, <lg) -->
<!--
Mobile drawer (overlay, <lg). v-if (not lg:hidden class) because
PrimeVue Drawer teleports to body, so a wrapping div or class
on the Drawer root can't beat .p-drawer's display rule in the
cascade Vue must simply not render the component on lg+.
-->
<Drawer
v-if="!isLg"
v-model:visible="mobileNavOpen"
position="left"
class="lg:hidden"
:pt="{ root: { style: { width: '16rem' } } }"
:pt="{
root: { style: { width: '18rem' } },
header: { class: 'p-0' },
content: { class: 'flex flex-col flex-1 min-h-0 p-0' },
}"
>
<template #header>
<span class="text-lg font-semibold text-primary-500">{{ title }}</span>
<SidebarHeader :title="title" />
</template>
<nav class="flex flex-col">
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
<template
v-for="(item, idx) in navItems"
:key="idx"
>
<div
v-if="isHeading(item)"
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
class="mt-6 mb-1 px-3 text-[11px] font-medium uppercase tracking-widest text-surface-400"
>
{{ item.heading }}
</div>
@@ -160,6 +194,7 @@ function toggleUserMenu(event: Event) {
</button>
</template>
</nav>
<SidebarUserCard />
</Drawer>
<!-- Main column -->
@@ -167,41 +202,70 @@ function toggleUserMenu(event: Event) {
<!-- Top bar -->
<header class="flex h-16 items-center justify-between border-b border-surface-200 bg-surface-0 px-4">
<div class="flex items-center gap-2">
<Button
class="lg:hidden"
severity="secondary"
text
rounded
aria-label="Menu openen"
@click="mobileNavOpen = true"
<!--
Mobile cluster: visibility owned by this plain DIV. The
PrimeVue Button alone with lg:hidden loses to
.p-button { display: inline-flex } in the cascade same
specificity, PrimeVue's stylesheet loads later.
-->
<div class="flex items-center gap-2 lg:hidden">
<Button
severity="secondary"
text
rounded
aria-label="Menu openen"
@click="mobileNavOpen = true"
>
<Icon
name="tabler-menu-2"
size="24"
/>
</Button>
<span class="text-base font-medium text-surface-700">{{ title }}</span>
</div>
<nav
class="hidden items-center gap-2 text-sm lg:flex"
aria-label="Kruimelpad"
>
<span
v-if="orgName"
class="text-surface-500"
>{{ orgName }}</span>
<Icon
name="tabler-menu-2"
size="24"
v-if="orgName && pageTitle"
name="tabler-chevron-right"
size="14"
class="text-surface-400"
/>
</Button>
<span class="text-base font-medium text-surface-700 lg:hidden">{{ title }}</span>
<span class="font-medium text-surface-900">{{ pageTitle }}</span>
</nav>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<Button
severity="secondary"
text
rounded
aria-label="Gebruikersmenu openen"
@click="toggleUserMenu"
aria-label="Notificaties"
@click="onNotificationsClick"
>
<Avatar
:label="userInitial"
shape="circle"
class="bg-primary-500 text-white"
<Icon
name="tabler-bell"
size="22"
/>
</Button>
<Button
severity="secondary"
text
rounded
aria-label="Help"
@click="onHelpClick"
>
<Icon
name="tabler-help"
size="22"
/>
</Button>
<Menu
ref="userMenuRef"
:model="userMenuItems"
:popup="true"
/>
</div>
</header>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
// SidebarHeader — top-of-sidebar org-switcher card introduced in F3.5
// per the AppShell mockup-parity sprint. PrimeVue-only rewrite of the
// legacy Vuetify OrganisationSwitcher (apps/app/src/components/layout/
// OrganisationSwitcher.vue), which cannot be reused inside AppShell
// per the R-10 layout-shell-isolation invariant.
//
// When the auth store has no active organisation (e.g. portal users,
// or a fresh super_admin without an org), the component degrades to a
// plain title block — the same visual the F3 AppShell shipped with.
import { computed, ref } from 'vue'
import Menu from 'primevue/menu'
import Avatar from 'primevue/avatar'
import Icon from '@/components/Icon.vue'
import { useAuthStore } from '@/stores/useAuthStore'
interface Props {
title?: string
}
withDefaults(defineProps<Props>(), {
title: 'Crewli',
})
const authStore = useAuthStore()
const menuRef = ref<InstanceType<typeof Menu> | null>(null)
const currentOrg = computed(() => authStore.currentOrganisation)
const organisations = computed(() => authStore.organisations)
const hasSwitcher = computed(() => !!currentOrg.value)
const hasMultiple = computed(() => organisations.value.length > 1)
const orgInitials = computed(() => {
const name = currentOrg.value?.name ?? ''
const parts = name.split(/\s+/).filter(Boolean).slice(0, 2)
const initials = parts.map(w => w[0]?.toUpperCase() ?? '').join('')
return initials || '?'
})
// Plan tier placeholder. Backend `Organisation` resource does not yet
// expose a plan field (see types/auth.ts:17-27); 'Pro' is hardcoded
// per Bert's A6.1 decision until that field lands.
const planLabel = 'Pro'
const menuItems = computed(() =>
organisations.value.map(org => ({
label: org.name,
command: () => authStore.setActiveOrganisation(org.id),
})),
)
function toggleMenu(event: Event) {
if (!hasMultiple.value)
return
menuRef.value?.toggle(event)
}
</script>
<template>
<div class="border-b border-surface-200">
<button
v-if="hasSwitcher"
type="button"
class="flex w-full items-center gap-3 px-3 py-3 text-left transition enabled:hover:bg-surface-100 disabled:cursor-default"
:disabled="!hasMultiple"
:aria-label="hasMultiple ? 'Organisatie wisselen' : currentOrg?.name"
:aria-haspopup="hasMultiple ? 'menu' : undefined"
@click="toggleMenu"
>
<Avatar
:label="orgInitials"
shape="circle"
class="shrink-0 bg-primary-500 text-white"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-surface-900">
{{ currentOrg?.name }}
</span>
<span class="text-xs text-surface-500">{{ planLabel }}</span>
</div>
<Icon
v-if="hasMultiple"
name="tabler-chevron-down"
size="18"
class="shrink-0 text-surface-500"
/>
</button>
<div
v-else
class="flex h-16 items-center justify-center px-3"
>
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
</div>
<Menu
ref="menuRef"
:model="menuItems"
:popup="true"
/>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
// SidebarUserCard — bottom-of-sidebar user identity + menu, introduced
// in F3.5 per Bert's A6.5 decision. Consolidates the user menu in a
// single location (sidebar bottom) and removes the topbar avatar
// dropdown, eliminating two paths to the same Logout / Profile actions.
//
// The PrimeVue Menu in popup mode auto-flips above the trigger when
// the panel would overflow the viewport bottom (per PrimeVue v4's
// DomHandler.absolutePosition logic). The card sits flush against the
// bottom of the sidebar, so the dropdown reliably opens upward.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Menu from 'primevue/menu'
import Avatar from 'primevue/avatar'
import Icon from '@/components/Icon.vue'
import { useAuthStore } from '@/stores/useAuthStore'
const router = useRouter()
const authStore = useAuthStore()
const menuRef = ref<InstanceType<typeof Menu> | null>(null)
const userInitial = computed(() => {
const name = authStore.user?.full_name ?? ''
return name.charAt(0).toUpperCase() || '?'
})
const userName = computed(() => authStore.user?.full_name ?? 'Gebruiker')
const roleLabels: Record<string, string> = {
super_admin: 'Super Admin',
org_admin: 'Beheerder',
org_member: 'Lid',
event_manager: 'Eventmanager',
staff_coordinator: 'Staf coördinator',
volunteer_coordinator: 'Vrijwilligers coördinator',
}
const roleLabel = computed(() => {
const orgRole = authStore.currentOrganisation?.role
if (orgRole)
return roleLabels[orgRole] ?? orgRole
if (authStore.isSuperAdmin)
return 'Super Admin'
return ''
})
const menuItems = computed(() => [
{
label: userName.value,
items: [
{
label: 'Mijn Profiel',
icon: 'tabler-user',
command: () => router.push({ name: 'account-settings' }),
},
{
label: 'Uitloggen',
icon: 'tabler-logout',
command: async () => {
await authStore.logout()
await router.push('/login')
},
},
],
},
])
function toggleMenu(event: Event) {
menuRef.value?.toggle(event)
}
</script>
<template>
<div class="border-t border-surface-200">
<button
type="button"
class="flex w-full items-center gap-3 px-3 py-3 text-left transition hover:bg-surface-100"
aria-label="Gebruikersmenu openen"
aria-haspopup="menu"
@click="toggleMenu"
>
<Avatar
:label="userInitial"
shape="circle"
class="shrink-0 bg-primary-500 text-white"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-surface-900">
{{ userName }}
</span>
<span
v-if="roleLabel"
class="truncate text-xs text-surface-500"
>{{ roleLabel }}</span>
</div>
<Icon
name="tabler-chevron-up"
size="18"
class="shrink-0 text-surface-500"
/>
</button>
<Menu
ref="menuRef"
:model="menuItems"
:popup="true"
/>
</div>
</template>

View File

@@ -1,3 +1,9 @@
// Iconify-Tabler runtime data — side-effect import must run before any
// component renders an Icon, so the @iconify/vue runtime resolves
// names locally instead of falling back to the api.iconify.design CDN
// (blocked by our CSP). See plugins/iconify.ts for the bootstrap.
import '@/plugins/iconify'
import { createApp } from 'vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { queryClientConfig } from '@/lib/query-client'
@@ -6,7 +12,7 @@ import { router } from '@/plugins/1.router'
import App from '@/App.vue'
import { registerPlugins } from '@core/utils/plugins'
import installPrimeVue from '@/plugins/primevue'
import { installPrimeVue } from '@/plugins/primevue'
// Styles
import '@styles/tailwind.css'

View File

@@ -0,0 +1,24 @@
// Iconify-Tabler runtime bootstrap — introduced in F3.5 to make
// @iconify/vue's <Icon> render real SVG paths from local data instead
// of fetching individual icons from https://api.iconify.design/ at
// runtime. The CSP blocks that origin, so the previous behaviour was
// an empty <svg viewBox="0 0 16 16"></svg> for every Tabler icon used
// in the PrimeVue side of the parallel-mode app.
//
// This file is a separate concern from src/plugins/iconify/index.ts +
// icons.css, which exists to serve the Vuetify side: Vuexy's @core
// `<VIcon icon="tabler-X" />` adapter renders via the i-tabler-* CSS
// classes those files inject, and Vuetify never touches the @iconify
// /vue runtime. The two systems must coexist until F6 retires
// Vuetify, after which the legacy plugins/iconify/ directory can be
// deleted alongside it.
//
// Imported as a side effect at the top of main.ts, so addCollection
// runs before any component renders an Icon. The whole Tabler set
// (~1.9 MB uncompressed JSON / ~400 KB gzipped) is loaded eagerly;
// per-icon imports are a future optimisation, not in F3.5 scope.
import { addCollection } from '@iconify/vue'
import tablerIcons from '@iconify-json/tabler/icons.json'
addCollection(tablerIcons)

View File

@@ -6,6 +6,16 @@
// Per RFC-WS-FRONTEND-PRIMEVUE AD-2: darkModeSelector matches Vuexy's
// `.dark` class convention so existing skin-toggle plumbing continues
// to work during the F3F6 parallel-mode window.
//
// Exported as a NAMED function (no `export default`) on purpose: the
// Vuexy registerPlugins() helper (src/@core/utils/plugins.ts) globs
// plugins/*/index.{ts,js} and invokes the `default` export of each
// match. A default export here would cause PrimeVue + its three
// services to register twice — once via registerPlugins, once via the
// explicit installPrimeVue(app) call in main.ts. The named export
// keeps the explicit installer in main.ts as the single registration
// site, which is the design intent (decouple PrimeVue from the Vuexy
// @core machine so F6 can delete @core/ without affecting PrimeVue).
import type { App } from 'vue'
import PrimeVue from 'primevue/config'
@@ -17,7 +27,7 @@ import nl from 'primelocale/nl.json'
import { CrewliPreset } from './theme'
import { ptDefaults } from './defaults'
export default function installPrimeVue(app: App) {
export function installPrimeVue(app: App) {
app.use(PrimeVue, {
theme: {
preset: CrewliPreset,