diff --git a/.changeset/bright-dialog-cursors.md b/.changeset/bright-dialog-cursors.md
new file mode 100644
index 00000000..f85a360f
--- /dev/null
+++ b/.changeset/bright-dialog-cursors.md
@@ -0,0 +1,4 @@
+---
+'svforge': patch
+---
+Show a pointer cursor on enabled dashboard dialog triggers.
diff --git a/graphify-out/graph.json b/graphify-out/graph.json
index 65b5f17b..065b5d9f 100644
--- a/graphify-out/graph.json
+++ b/graphify-out/graph.json
@@ -22848,6 +22848,23 @@
"_callable": true,
"_origin": "ast"
},
+ {
+ "id": "tests_dashboard_trigger_affordance_test",
+ "label": "dashboard-trigger-affordance.test.ts",
+ "file_type": "code",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L1",
+ "_origin": "ast"
+ },
+ {
+ "id": "tests_dashboard_trigger_affordance_test_triggerclasses",
+ "label": "triggerClasses()",
+ "file_type": "code",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L5",
+ "_callable": true,
+ "_origin": "ast"
+ },
{
"id": "tests_dashboard_vitest_test",
"label": "dashboard-vitest.test.ts",
@@ -26252,6 +26269,15 @@
"source_location": "L1",
"_origin": "ast"
},
+ {
+ "id": "changeset_bright_dialog_cursors",
+ "label": "bright-dialog-cursors.md",
+ "file_type": "document",
+ "node_kind": "page",
+ "source_file": ".changeset/bright-dialog-cursors.md",
+ "source_location": "L1",
+ "_origin": "ast"
+ },
{
"id": "changeset_tidy_button_classes",
"label": "tidy-button-classes.md",
@@ -77897,6 +77923,71 @@
"weight": 1,
"_origin": "ast"
},
+ {
+ "source": "tests_dashboard_trigger_affordance_test",
+ "target": "ref_vitest",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L1",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_dashboard_trigger_affordance_test",
+ "target": "ref_node_fs",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L2",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_dashboard_trigger_affordance_test",
+ "target": "tests_helpers",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L3",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_dashboard_trigger_affordance_test",
+ "target": "tests_helpers_dashboardtemplatefile",
+ "relation": "imports",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L3",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_dashboard_trigger_affordance_test",
+ "target": "tests_dashboard_trigger_affordance_test_triggerclasses",
+ "relation": "contains",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L5",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_dashboard_trigger_affordance_test",
+ "target": "tests_dashboard_trigger_affordance_test_triggerclasses",
+ "relation": "calls",
+ "context": "call",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/dashboard-trigger-affordance.test.ts",
+ "source_location": "L17",
+ "weight": 1,
+ "_origin": "ast"
+ },
{
"source": "tests_dashboard_vitest_test",
"target": "ref_vitest",
diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json
index 2de377f8..eeba4901 100644
--- a/graphify-out/manifest.json
+++ b/graphify-out/manifest.json
@@ -488,7 +488,7 @@
"semantic_hash": ""
},
"packages/svforge/src/templates.ts": {
- "ast_hash": "0a59e8810af269c5b766dd59df9db3f2",
+ "ast_hash": "8ea3c6d025e49ef0678e7d2994cc08a3",
"semantic_hash": ""
},
"packages/svforge/src/upgrade.ts": {
@@ -736,7 +736,7 @@
"semantic_hash": ""
},
"packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte": {
- "ast_hash": "da6a353749cb2cc6c04c7d42420f15c3",
+ "ast_hash": "11f5ca893c77243145fd2c1ca6347103",
"semantic_hash": ""
},
"packages/svforge/templates/dashboard/src/lib/components/svforge/ui/AvatarInitial.svelte": {
@@ -852,7 +852,7 @@
"semantic_hash": ""
},
"packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte": {
- "ast_hash": "69d51410d7d665e6350d2aa80036f62a",
+ "ast_hash": "bb95a5c9bc497e80cca0ddbb777d1088",
"semantic_hash": ""
},
"packages/svforge/templates/dashboard/src/routes/(app)/admin/users/page.server.test.ts": {
@@ -2074,5 +2074,13 @@
".changeset/tidy-button-classes.md": {
"ast_hash": "6f46a25079bb34d5ee7eb6aa6f14acab",
"semantic_hash": ""
+ },
+ "tests/dashboard-trigger-affordance.test.ts": {
+ "ast_hash": "517752d0f4e57b837cf0a8367ca7335a",
+ "semantic_hash": ""
+ },
+ ".changeset/bright-dialog-cursors.md": {
+ "ast_hash": "fdd08c0666fe945dc1f0baf093f852f8",
+ "semantic_hash": ""
}
}
diff --git a/packages/svforge/src/templates.ts b/packages/svforge/src/templates.ts
index f2d00499..745a011e 100644
--- a/packages/svforge/src/templates.ts
+++ b/packages/svforge/src/templates.ts
@@ -54,7 +54,7 @@ export const dashboardFiles = {
"/hooks.server.test.ts": "import { describe, expect, it, vi } from 'vitest';\n\nconst query = {\n\tselect: vi.fn(() => query),\n\tfrom: vi.fn(() => query),\n\twhere: vi.fn(() => query),\n\tlimit: vi.fn<() => any[]>()\n};\n\nvi.mock('$app/env', () => ({ building: false }));\nvi.mock('$lib/paraglide/runtime', () => ({ getTextDirection: vi.fn(() => 'ltr') }));\nvi.mock('$lib/paraglide/server', () => ({\n\tparaglideMiddleware: vi.fn(async (request, next) => next({ request, locale: 'en' }))\n}));\nvi.mock('$lib/server/auth', () => ({\n\tauth: {\n\t\tapi: {\n\t\t\tgetSession: vi.fn(async () => ({\n\t\t\t\tsession: { id: 'session-1', userId: 'disabled-user' },\n\t\t\t\tuser: { id: 'disabled-user', email: 'disabled@example.com' }\n\t\t\t}))\n\t\t}\n\t}\n}));\nvi.mock('$lib/server/db', () => ({ db: query }));\nvi.mock('$lib/server/db/schema', () => ({ user: { id: 'id', disabled: 'disabled' } }));\nvi.mock('drizzle-orm', () => ({ eq: vi.fn() }));\nvi.mock('better-auth/svelte-kit', () => ({\n\t// Faithful to the real handler: resolve is called with the transform object.\n\tsvelteKitHandler: vi.fn(({ event, resolve }) => resolve(event, { transformPageChunk: (f: any) => f.html ?? f }))\n}));\n\ndescribe('dashboard auth hook', () => {\n\tit('does not populate locals for a session owned by a disabled user', async () => {\n\t\tquery.limit.mockReturnValueOnce([{ disabled: true }]);\n\t\tconst { handle } = await import('./hooks.server');\n\t\tconst event: any = {\n\t\t\trequest: new Request('http://localhost/admin'),\n\t\t\tlocals: {}\n\t\t};\n\t\tconst resolve = vi.fn(async () => new Response('ok'));\n\n\t\tawait handle({ event, resolve } as any);\n\n\t\texpect(event.locals.session).toBeUndefined();\n\t\texpect(event.locals.user).toBeUndefined();\n\t\texpect(resolve).toHaveBeenCalledWith(\n\t\t\texpect.objectContaining({ request: event.request, locals: event.locals }),\n\t\t\texpect.any(Object)\n\t\t);\n\t});\n});\n",
"/hooks.server.ts": "import type { Handle } from '@sveltejs/kit/hooks';\n// `building` is used to skip auth during prerender/build (SvelteKit builds pages statically).\nimport { building } from '$app/env';\nimport { getTextDirection } from '$lib/paraglide/runtime';\nimport { paraglideMiddleware } from '$lib/paraglide/server';\nimport { auth } from '$lib/server/auth';\nimport { db } from '$lib/server/db';\nimport { user } from '$lib/server/db/schema';\nimport { eq } from 'drizzle-orm';\nimport { svelteKitHandler } from 'better-auth/svelte-kit';\n\n/**\n * Single composed handle (#280).\n *\n * Order matters:\n * 1. `paraglideMiddleware` negotiates the locale and rewrites `event.request`\n * (cookie / accept-language handling).\n * 2. Better Auth reads the session from the REWRITTEN request and populates\n * `locals.session` / `locals.user`; auth paths are routed to the auth\n * handler, every other request resolves through the app.\n * 3. The Paraglide transform rewrites the `%paraglide.lang%` /\n * `%paraglide.dir%` placeholders of app.html in the FINAL html — this must\n * survive the composition: the #268 fix (composing handleBetterAuth) had\n * dropped it, leaving the placeholders in the rendered HTML (#280).\n */\nexport const handle: Handle = async ({ event, resolve }) =>\n\tparaglideMiddleware(event.request, async ({ request, locale }) => {\n\t\tconst localizedEvent = { ...event, request };\n\n\t\tconst session = await auth.api.getSession({ headers: localizedEvent.request.headers });\n\t\tif (session) {\n\t\t\t// A disabled identity may be retained for domain/audit references, but\n\t\t\t// it must never retain access through an old or newly-created session.\n\t\t\tconst [currentUser] = await db\n\t\t\t\t.select({ disabled: user.disabled })\n\t\t\t\t.from(user)\n\t\t\t\t.where(eq(user.id, session.user.id))\n\t\t\t\t.limit(1);\n\t\t\tif (!currentUser?.disabled) {\n\t\t\t\tevent.locals.session = session.session;\n\t\t\t\tevent.locals.user = session.user;\n\t\t\t}\n\t\t}\n\n\t\treturn svelteKitHandler({\n\t\t\tevent: localizedEvent,\n\t\t\tauth,\n\t\t\tbuilding,\n\t\t\t// svelteKitHandler calls resolve(event) with a single argument — the\n\t\t\t// Paraglide transform is applied here so the i18n placeholders of\n\t\t\t// app.html are rewritten in the final html.\n\t\t\tresolve: (event) =>\n\t\t\t\tresolve(event, {\n\t\t\t\t\ttransformPageChunk: ({ html }) =>\n\t\t\t\t\t\thtml\n\t\t\t\t\t\t\t.replace('%paraglide.lang%', locale)\n\t\t\t\t\t\t\t.replace('%paraglide.dir%', getTextDirection(locale))\n\t\t\t\t})\n\t\t});\n\t});\n",
"/lib/client/auth.ts": "import { createAuthClient } from 'better-auth/svelte';\n\nexport const authClient = createAuthClient();\n",
- "/lib/components/svforge/layout/AdminLayout.svelte": "\n\n (mobileOpen = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n\n\t\n\t
\n\n\t\n\t
\n\t\t\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t \n\t\t\t\t \n\t\t\t\t
{m.layout_admin()} \n\t\t\t\n\t\t\t\n\t\t\t\t \n\t\t\t\t{#if user}\n\t\t\t\t\t{user.name} \n\t\t\t\t\t{#if onSignOut}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t \n\t\t\t\t\t\t \n\t\t\t\t\t{/if}\n\t\t\t\t{/if}\n\t\t\t
\n\t\t \n\n\n\t\t\n\t\t
\n\t\t\t{@render children()}\n\t\t \n\t
\n
\n\t\n\t\t \n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{m.layout_admin()} \n\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t \n\t\t\t\t\n\t\t\t\t\t{#each items as item (item.href)}\n\t\t\t\t\t\t{@const Icon = item.icon}\n\t\t\t\t\t\t (mobileOpen = false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t \n\t\t\t\t\t\t\t{item.label} \n\t\t\t\t\t\t \n\t\t\t\t\t{/each}\n\t\t\t\t \n\t\t\t \n\t\t \n\t \n \n",
+ "/lib/components/svforge/layout/AdminLayout.svelte": "\n\n (mobileOpen = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n\n\t\n\t
\n\n\t\n\t
\n\t\t\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t \n\t\t\t\t \n\t\t\t\t
{m.layout_admin()} \n\t\t\t\n\t\t\t\n\t\t\t\t \n\t\t\t\t{#if user}\n\t\t\t\t\t{user.name} \n\t\t\t\t\t{#if onSignOut}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t \n\t\t\t\t\t\t \n\t\t\t\t\t{/if}\n\t\t\t\t{/if}\n\t\t\t
\n\t\t \n\n\n\t\t\n\t\t
\n\t\t\t{@render children()}\n\t\t \n\t
\n
\n\t\n\t\t \n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{m.layout_admin()} \n\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t \n\t\t\t\t\n\t\t\t\t\t{#each items as item (item.href)}\n\t\t\t\t\t\t{@const Icon = item.icon}\n\t\t\t\t\t\t (mobileOpen = false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t \n\t\t\t\t\t\t\t{item.label} \n\t\t\t\t\t\t \n\t\t\t\t\t{/each}\n\t\t\t\t \n\t\t\t \n\t\t \n\t \n \n",
"/lib/components/svforge/ui/AvatarInitial.svelte": "\n\n
\n {initial}\n
\n",
"/lib/components/svforge/ui/Feedback.svelte": "\n\n{#if message}\n\t\n\t\n\t\t{#if type === 'success'} {:else} {/if}\n\t\t{message} \n\t\t{#if ondismiss}\n\t\t\t \n\t\t{/if}\n\t
\n{/if}\n",
"/lib/components/svforge/ui/index.ts": "export { default as Alert } from './Alert.svelte';\nexport { default as Card } from './Card.svelte';\nexport { default as Logo } from './Logo.svelte';\nexport { default as Seo } from './Seo.svelte';\nexport { default as Table } from './Table.svelte';\nexport { default as ThemeToggle } from './ThemeToggle.svelte';\nexport { generateSitemap } from './Sitemap';\nexport { default as AvatarInitial } from './AvatarInitial.svelte';\nexport { default as Feedback } from './Feedback.svelte';\n",
@@ -85,7 +85,7 @@ export const dashboardFiles = {
"/routes/(app)/admin/settings/+page.server.ts": "import { auth } from '$lib/server/auth';\nimport { changePasswordSchema } from '$lib/server/schemas';\nimport { fail, type Actions } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ locals }) => {\n\treturn {\n\t\tuser: locals.user!\n\t};\n};\n\nexport const actions: Actions = {\n\tchangePassword: async ({ request }) => {\n\t\tconst formData = await request.formData();\n\t\tconst parsed = changePasswordSchema.safeParse({\n\t\t\tcurrentPassword: formData.get('currentPassword'),\n\t\t\tnewPassword: formData.get('newPassword')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { message: parsed.error.issues[0]?.message ?? 'Invalid input' });\n\t\t}\n\n\t\tconst { currentPassword, newPassword } = parsed.data;\n\n\t\ttry {\n\t\t\tawait auth.api.changePassword({\n\t\t\t\theaders: request.headers,\n\t\t\t\tbody: { currentPassword, newPassword }\n\t\t\t});\n\t\t\treturn { success: true };\n\t\t} catch {\n\t\t\t// Generic message — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(400, { message: 'Failed to change password' });\n\t\t}\n\t}\n};\n",
"/routes/(app)/admin/settings/+page.svelte": "\n\n\n\t{m.settings_title()} \n \n\n\n\t
{m.settings_heading()} \n\n\t\n\t
\n\t\t{#snippet header()}\n\t\t\t{m.settings_profile()} \n\t\t{/snippet}\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{data.user?.name}
\n\t\t\t\t\t
{data.user?.email}
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t \n\n\t\n\t
\n\t\t{#snippet header()}\n\t\t\t\n\t\t\t\t \n\t\t\t\t
{m.settings_change_password()} \n\t\t\t\n\t\t{/snippet}\n\n\t\t{#if form?.message}\n\t\t\t \n\t\t{/if}\n\t\t{#if validationError}\n\t\t\t\n\t\t\t \n\t\t{/if}\n\n\t\t\n\t \n
\n",
"/routes/(app)/admin/users/+page.server.ts": "import { db } from '$lib/server/db';\nimport { user, session } from '$lib/server/db/schema';\nimport { desc, eq } from 'drizzle-orm';\nimport { fail, redirect, error, type Actions } from '@sveltejs/kit';\nimport { isAdmin } from '$lib/server/admin';\nimport { createCredentialUser, DuplicateEmailError } from '$lib/server/admin-users';\nimport { createInvitation, DuplicateInvitationError } from '$lib/server/invitations';\nimport {\n\tcreateUserSchema,\n\tupdateUserSchema,\n\ttoggleUserStatusSchema,\n\ttoggleVerifySchema,\n\tinviteSchema\n} from '$lib/server/schemas';\nimport type { PageServerLoad } from './$types';\nimport type { RequestEvent } from '@sveltejs/kit';\n\n/**\n * Require an authenticated admin user for a form action.\n * Returns the user id on success, or throws a 401/403 error.\n *\n * Every admin action must call this BEFORE reading formData or touching the db.\n */\nasync function requireAdmin(event: RequestEvent): Promise {\n\tif (!event.locals.user) {\n\t\tthrow error(401, { message: 'Authentication required' });\n\t}\n\tif (!(await isAdmin(event.locals.user.id))) {\n\t\tthrow error(403, { message: 'Admin access required' });\n\t}\n\treturn event.locals.user.id;\n}\n\nexport const load: PageServerLoad = async ({ locals }) => {\n\tif (!locals.user || !(await isAdmin(locals.user.id))) {\n\t\tthrow redirect(302, '/login');\n\t}\n\n\tconst users = await db\n\t\t.select({\n\t\t\tid: user.id,\n\t\t\tname: user.name,\n\t\t\temail: user.email,\n\t\t\temailVerified: user.emailVerified,\n\t\t\timage: user.image,\n\t\t\tdisabled: user.disabled,\n\t\t\tcreatedAt: user.createdAt\n\t\t})\n\t\t.from(user)\n\t\t.orderBy(desc(user.createdAt));\n\n\treturn { users };\n};\n\n/**\n * Form actions (#295) — golden reference for SvelteKit mutations.\n *\n * Every action returns a STABLE machine-readable `code` instead of English\n * copy: the UI maps codes to Paraglide FR/EN messages, so no visible text\n * comes from the server and the copy can never leak internal details.\n */\n\nexport const actions: Actions = {\n\tcreate: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = createUserSchema.safeParse({\n\t\t\tname: formData.get('name'),\n\t\t\temail: formData.get('email'),\n\t\t\tpassword: formData.get('password')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\tconst { name, email, password } = parsed.data;\n\n\t\ttry {\n\t\t\t// Contract-compliant credential user: lowercase email, accountId = userId,\n\t\t\t// user + credential account created atomically, admin session untouched.\n\t\t\t// (signUpEmail is NOT used: it sets a session cookie for the new user,\n\t\t\t// which would silently replace the admin's own session.)\n\t\t\tawait createCredentialUser({ name, email, password });\n\t\t} catch (error) {\n\t\t\tif (error instanceof DuplicateEmailError) {\n\t\t\t\treturn fail(400, { code: 'email_exists' });\n\t\t\t}\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'create_failed' });\n\t\t}\n\n\t\treturn { success: true, code: 'created' };\n\t},\n\n\tupdate: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = updateUserSchema.safeParse({\n\t\t\tid: formData.get('id'),\n\t\t\tname: formData.get('name'),\n\t\t\temail: formData.get('email')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\tconst { id, name, email } = parsed.data;\n\t\tconst normalizedEmail = email.toLowerCase();\n\n\t\t// Check email not taken by another user (case-insensitive, matching the\n\t\t// lowercase credential contract of the create helper — #292).\n\t\tconst [existing] = await db\n\t\t\t.select({ id: user.id })\n\t\t\t.from(user)\n\t\t\t.where(eq(user.email, normalizedEmail))\n\t\t\t.limit(1);\n\t\tif (existing && existing.id !== id) {\n\t\t\treturn fail(400, { code: 'email_taken' });\n\t\t}\n\n\t\ttry {\n\t\t\tawait db\n\t\t\t\t.update(user)\n\t\t\t\t.set({\n\t\t\t\t\tname,\n\t\t\t\t\temail: normalizedEmail,\n\t\t\t\t\tupdatedAt: new Date()\n\t\t\t\t})\n\t\t\t\t.where(eq(user.id, id));\n\t\t} catch {\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'update_failed' });\n\t\t}\n\n\t\treturn { success: true, code: 'updated' };\n\t},\n\n\ttoggleStatus: async (event) => {\n\t\tconst adminId = await requireAdmin(event);\n\t\tconst formData = await event.request.formData();\n\t\tconst parsed = toggleUserStatusSchema.safeParse({\n\t\t\tid: formData.get('id'),\n\t\t\tdisabled: formData.get('disabled') === 'true'\n\t\t});\n\n\t\tif (!parsed.success) return fail(400, { code: 'invalid_input' });\n\n\t\tconst { id, disabled } = parsed.data;\n\t\t// The only administrator must remain able to administer the project.\n\t\tif (disabled && id === adminId) return fail(400, { code: 'self_deactivate' });\n\n\t\tconst [target] = await db\n\t\t\t.select({ id: user.id, disabled: user.disabled })\n\t\t\t.from(user)\n\t\t\t.where(eq(user.id, id))\n\t\t\t.limit(1);\n\t\tif (!target) return fail(404, { code: 'not_found' });\n\n\t\ttry {\n\t\t\tawait db.transaction(async (tx) => {\n\t\t\t\tawait tx.update(user).set({ disabled, updatedAt: new Date() }).where(eq(user.id, id));\n\t\t\t\t// Revoking sessions immediately prevents a newly disabled user from\n\t\t\t\t// continuing to access protected routes. Accounts and the identity row stay.\n\t\t\t\tif (disabled) await tx.delete(session).where(eq(session.userId, id));\n\t\t\t});\n\t\t} catch {\n\t\t\treturn fail(500, { code: 'status_failed' });\n\t\t}\n\n\t\treturn { success: true, code: disabled ? 'deactivated' : 'reactivated' };\n\t},\n\n\ttoggleVerify: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = toggleVerifySchema.safeParse({\n\t\t\tid: formData.get('id'),\n\t\t\tverified: formData.get('verified') === 'true'\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\tconst { id, verified } = parsed.data;\n\n\t\ttry {\n\t\t\tawait db\n\t\t\t\t.update(user)\n\t\t\t\t.set({\n\t\t\t\t\temailVerified: !verified,\n\t\t\t\t\tupdatedAt: new Date()\n\t\t\t\t})\n\t\t\t\t.where(eq(user.id, id));\n\t\t} catch {\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'verify_failed' });\n\t\t}\n\n\t\treturn { success: true, code: verified ? 'unverified' : 'verified' };\n\t},\n\n\t/**\n\t * Pre-approve one email for self-registration (#318) — the `invite-only`\n\t * sign-up mode. In `closed` mode (the default) the invitation is inert:\n\t * the sign-up endpoint is disabled entirely. Either way an invited user\n\t * can never hold the admin role — only bootstrapFirstAdmin grants it.\n\t */\n\tinvite: async (event) => {\n\t\tawait requireAdmin(event);\n\t\tconst { request } = event;\n\t\tconst formData = await request.formData();\n\t\tconst parsed = inviteSchema.safeParse({\n\t\t\temail: formData.get('email')\n\t\t});\n\n\t\tif (!parsed.success) {\n\t\t\treturn fail(400, { code: 'invalid_input' });\n\t\t}\n\n\t\ttry {\n\t\t\tawait createInvitation({ email: parsed.data.email });\n\t\t} catch (error) {\n\t\t\tif (error instanceof DuplicateInvitationError) {\n\t\t\t\treturn fail(400, { code: 'already_invited' });\n\t\t\t}\n\t\t\t// Generic code — never leak e.message internals to the UI (#188).\n\t\t\treturn fail(500, { code: 'invite_failed' });\n\t\t}\n\n\t\treturn { success: true, code: 'invited' };\n\t}\n};\n",
- "/routes/(app)/admin/users/+page.svelte": "\n\n\n\t{m.users_title()} \n \n\n\n\t
\n\t\t
{m.users_heading()} \n\t\t
\n\t\t\t\n\t\t\t\t \n\t\t\t\t{m.users_invite_btn()}\n\t\t\t \n\t\t\t\n\t\t\t\t \n\t\t\t\t{m.users_add()}\n\t\t\t \n\t\t
\n\t
\n\n\t{#if feedback}\n\t\t
(feedback = null)} />\n\t{/if}\n\n\t \n\n\t\n\t\n\t\t{#snippet children({ row, col })}\n\t\t\t{#if col.key === 'name'}\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{row.name}
\n\t\t\t\t\t\t
{row.email}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t{:else if col.key === 'status'}\n\t\t\t\t{#if row.disabled}\n\t\t\t\t\t{m.users_disabled()} \n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t{:else if col.key === 'actions'}\n\t\t\t\t\n\t\t\t\t\t
openEdit(row)}\n\t\t\t\t\t\taria-label={m.users_edit()}\n\t\t\t\t\t>\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t\t
openStatus(row)}\n\t\t\t\t\t\tdisabled={row.id === currentUserId}\n\t\t\t\t\t\taria-label={row.disabled ? m.users_reactivate() : m.users_deactivate()}\n\t\t\t\t\t>\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t
\n\t\t\t{/if}\n\t\t{/snippet}\n\t
\n\n\t{#if filtered.length === 0}\n\t\t{m.users_none()} \n\t{/if}\n \n\n {\n\t\tif (!details.open) closeModal();\n\t}}\n>\n\t\n\t\t \n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{modal === 'create'\n\t\t\t\t\t\t\t? m.users_modal_create()\n\t\t\t\t\t\t\t: modal === 'edit'\n\t\t\t\t\t\t\t\t? m.users_modal_edit()\n\t\t\t\t\t\t\t\t: modal === 'invite'\n\t\t\t\t\t\t\t\t\t? m.users_invite_title()\n\t\t\t\t\t\t\t\t\t: statusTarget?.disabled\n\t\t\t\t\t\t\t\t\t\t? m.users_modal_reactivate()\n\t\t\t\t\t\t\t\t\t\t: m.users_modal_deactivate()}\n\t\t\t\t\t \n\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t
\n\n\t\t\t\t{#if modal === 'create' || modal === 'edit'}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'invite'}\n\t\t\t\t\t{m.users_invite_desc()} \n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'status' && statusTarget}\n\t\t\t\t\t\n\t\t\t\t\t\t{statusTarget.disabled\n\t\t\t\t\t\t\t? m.users_reactivate_confirm({ name: statusTarget.name })\n\t\t\t\t\t\t\t: m.users_deactivate_confirm({ name: statusTarget.name })}\n\t\t\t\t\t \n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t \n\t\t \n\t \n \n",
+ "/routes/(app)/admin/users/+page.svelte": "\n\n\n\t{m.users_title()} \n \n\n\n\t
\n\t\t
{m.users_heading()} \n\t\t
\n\t\t\t\n\t\t\t\t \n\t\t\t\t{m.users_invite_btn()}\n\t\t\t \n\t\t\t\n\t\t\t\t \n\t\t\t\t{m.users_add()}\n\t\t\t \n\t\t
\n\t
\n\n\t{#if feedback}\n\t\t
(feedback = null)} />\n\t{/if}\n\n\t \n\n\t\n\t\n\t\t{#snippet children({ row, col })}\n\t\t\t{#if col.key === 'name'}\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{row.name}
\n\t\t\t\t\t\t
{row.email}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t{:else if col.key === 'status'}\n\t\t\t\t{#if row.disabled}\n\t\t\t\t\t{m.users_disabled()} \n\t\t\t\t{:else}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t{:else if col.key === 'actions'}\n\t\t\t\t\n\t\t\t\t\t
openEdit(row)}\n\t\t\t\t\t\taria-label={m.users_edit()}\n\t\t\t\t\t>\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t\t
openStatus(row)}\n\t\t\t\t\t\tdisabled={row.id === currentUserId}\n\t\t\t\t\t\taria-label={row.disabled ? m.users_reactivate() : m.users_deactivate()}\n\t\t\t\t\t>\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t
\n\t\t\t{/if}\n\t\t{/snippet}\n\t
\n\n\t{#if filtered.length === 0}\n\t\t{m.users_none()} \n\t{/if}\n \n\n {\n\t\tif (!details.open) closeModal();\n\t}}\n>\n\t\n\t\t \n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{modal === 'create'\n\t\t\t\t\t\t\t? m.users_modal_create()\n\t\t\t\t\t\t\t: modal === 'edit'\n\t\t\t\t\t\t\t\t? m.users_modal_edit()\n\t\t\t\t\t\t\t\t: modal === 'invite'\n\t\t\t\t\t\t\t\t\t? m.users_invite_title()\n\t\t\t\t\t\t\t\t\t: statusTarget?.disabled\n\t\t\t\t\t\t\t\t\t\t? m.users_modal_reactivate()\n\t\t\t\t\t\t\t\t\t\t: m.users_modal_deactivate()}\n\t\t\t\t\t \n\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t
\n\n\t\t\t\t{#if modal === 'create' || modal === 'edit'}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'invite'}\n\t\t\t\t\t{m.users_invite_desc()} \n\t\t\t\t\t\n\t\t\t\t{:else if modal === 'status' && statusTarget}\n\t\t\t\t\t\n\t\t\t\t\t\t{statusTarget.disabled\n\t\t\t\t\t\t\t? m.users_reactivate_confirm({ name: statusTarget.name })\n\t\t\t\t\t\t\t: m.users_deactivate_confirm({ name: statusTarget.name })}\n\t\t\t\t\t \n\t\t\t\t\t\n\t\t\t\t{/if}\n\t\t\t \n\t\t \n\t \n \n",
"/routes/(app)/admin/users/page.server.test.ts": "import { describe, it, expect, vi, beforeEach } from 'vitest';\n\n// Mock the server modules\nvi.mock('$lib/server/db', () => {\n\tconst mockQuery = {\n\t\tselect: vi.fn(() => mockQuery),\n\t\tfrom: vi.fn(() => mockQuery),\n\t\twhere: vi.fn(() => mockQuery),\n\t\tlimit: vi.fn(() => []),\n\t\torderBy: vi.fn(() => []),\n\t\tinsert: vi.fn(() => ({ values: vi.fn() })),\n\t\tupdate: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })),\n\t\tdelete: vi.fn(() => ({ where: vi.fn() })),\n\t\ttransaction: vi.fn(async (cb: Function) => cb({\n\t\t\tdelete: vi.fn(() => ({ where: vi.fn() })),\n\t\t\tupdate: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) }))\n\t\t}))\n\t};\n\treturn { db: mockQuery };\n});\n\nvi.mock('$lib/server/db/schema', () => ({\n\tuser: { id: 'id', email: 'email', name: 'name', disabled: 'disabled' },\n\taccount: { userId: 'userId' },\n\tsession: { userId: 'userId' }\n}));\n\nvi.mock('$lib/server/admin', () => ({\n\tisAdmin: vi.fn()\n}));\n\nvi.mock('better-auth/crypto', () => ({\n\thashPassword: vi.fn(() => 'hashed-pw')\n}));\n\nvi.mock('drizzle-orm', () => ({\n\tdesc: vi.fn(),\n\teq: vi.fn()\n}));\n\n/**\n * Tests for the admin users page server — authorization, validation,\n * and user management actions.\n */\ndescribe('admin users +page.server — authorization', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks();\n\t});\n\n\tit('rejects non-admin users with 403', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(false);\n\n\t\tconst mod = await import('./+page.server');\n\t\tawait expect(\n\t\t\tmod.actions.create({\n\t\t\t\tlocals: { user: { id: 'nonadmin' } },\n\t\t\t\trequest: new Request('http://test', { method: 'POST' })\n\t\t\t} as any)\n\t\t).rejects.toThrow();\n\t});\n\n\tit('rejects anonymous users with 401', async () => {\n\t\tconst mod = await import('./+page.server');\n\t\tawait expect(\n\t\t\tmod.actions.update({\n\t\t\t\tlocals: { user: null },\n\t\t\t\trequest: new Request('http://test', { method: 'POST' })\n\t\t\t} as any)\n\t\t).rejects.toThrow();\n\t});\n});\n\ndescribe('admin users +page.server — validation', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks();\n\t});\n\n\tit('returns 400 for missing fields on create', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\t// Missing name and password\n\t\tformData.set('email', 'test@test.com');\n\n\t\tconst result = await mod.actions.create({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect((result as { status: number }).status).toBe(400);\n\t});\n});\n\ndescribe('admin users +page.server — user management', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks();\n\t});\n\n\tit('deactivates a user without deleting their identity and revokes every session', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\tconst { user } = await import('$lib/server/db/schema');\n\t\tconst { eq } = await import('drizzle-orm');\n\t\tvi.mocked(eq).mockImplementation((column: unknown, value: unknown) => ({ column, value }) as never);\n\t\tconst updateWhere = vi.fn();\n\t\tconst setUser = vi.fn(() => ({ where: updateWhere }));\n\t\tconst updateUser = vi.fn(() => ({ set: setUser }));\n\t\tconst deleteSession = vi.fn(() => ({ where: vi.fn() }));\n\t\t(db.select as any).mockReturnValueOnce({\n\t\t\tfrom: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => [{ id: 'user-1', disabled: false }]) })) }))\n\t\t});\n\t\t(db.transaction as any).mockImplementationOnce(async (callback: Function) =>\n\t\t\tcallback({ delete: deleteSession, update: updateUser })\n\t\t);\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'user-1');\n\t\tformData.set('disabled', 'true');\n\t\tconst result = await mod.actions.toggleStatus({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toMatchObject({ success: true, code: 'deactivated' });\n\t\texpect(updateUser).toHaveBeenCalledWith(user);\n\t\texpect(setUser).toHaveBeenCalledWith(expect.objectContaining({ disabled: true }));\n\t\texpect(updateWhere).toHaveBeenCalledWith({ column: user.id, value: 'user-1' });\n\t\texpect(deleteSession).toHaveBeenCalledTimes(1);\n\t});\n\n\tit('refuses self-deactivation before updating the user or deleting sessions', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'admin1');\n\t\tformData.set('disabled', 'true');\n\t\tconst result = await mod.actions.toggleStatus({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toMatchObject({ status: 400, data: { code: 'self_deactivate' } });\n\t\texpect(db.update).not.toHaveBeenCalled();\n\t\texpect(db.delete).not.toHaveBeenCalled();\n\t\texpect(db.transaction).not.toHaveBeenCalled();\n\t});\n\n\tit('reactivates a user without revoking sessions', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\tconst deleteSession = vi.fn(() => ({ where: vi.fn() }));\n\t\tconst updateUser = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) }));\n\t\t(db.select as any).mockReturnValueOnce({\n\t\t\tfrom: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => [{ id: 'user-1', disabled: true }]) })) }))\n\t\t});\n\t\t(db.transaction as any).mockImplementationOnce(async (callback: Function) =>\n\t\t\tcallback({ delete: deleteSession, update: updateUser })\n\t\t);\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'user-1');\n\t\tformData.set('disabled', 'false');\n\t\tconst result = await mod.actions.toggleStatus({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toMatchObject({ success: true, code: 'reactivated' });\n\t\texpect(deleteSession).not.toHaveBeenCalled();\n\t});\n\n\tit('allows admin to toggle verification (success)', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst { db } = await import('$lib/server/db');\n\t\t// Mock that user exists\n\t\t(db.select as any).mockReturnValueOnce({\n\t\t\tfrom: vi.fn(() => ({\n\t\t\t\twhere: vi.fn(() => ({ limit: vi.fn(() => []) })),\n\t\t\t\torderBy: vi.fn(() => [])\n\t\t\t}))\n\t\t});\n\n\t\tconst mod = await import('./+page.server');\n\t\tconst formData = new FormData();\n\t\tformData.set('id', 'user-1');\n\t\tformData.set('verified', 'false');\n\n\t\tconst result = await mod.actions.toggleVerify({\n\t\t\tlocals: { user: { id: 'admin1' } },\n\t\t\trequest: { formData: async () => formData }\n\t\t} as any);\n\n\t\texpect(result).toHaveProperty('success', true);\n\t});\n});\n",
"/routes/(app)/layout.server.test.ts": "import { describe, it, expect, vi } from 'vitest';\n\n// Mock the server modules so the test can run without a real database.\nvi.mock('$lib/server/db', () => ({\n\tdb: {\n\t\tselect: vi.fn(() => ({ from: vi.fn(() => ({ orderBy: vi.fn(() => []) })) })),\n\t\tinsert: vi.fn(() => ({ values: vi.fn() })),\n\t\tupdate: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })),\n\t\tdelete: vi.fn(() => ({ where: vi.fn() }))\n\t}\n}));\n\nvi.mock('$lib/server/admin', () => ({\n\tisAdmin: vi.fn()\n}));\n\n/**\n * Tests for the (app) layout auth guard.\n * Anonymous users must be redirected to /login.\n */\ndescribe('(app) layout auth guard', () => {\n\tit('redirects anonymous users to /login', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(false);\n\n\t\t// Simulate an unauthenticated request\n\t\tconst load = (await import('./+layout.server')).load;\n\n\t\tawait expect(\n\t\t\tload({\n\t\t\t\tlocals: { user: null, session: null },\n\t\t\t\turl: new URL('http://localhost/dashboard')\n\t\t\t} as any)\n\t\t).rejects.toThrow();\n\n\t\ttry {\n\t\t\tawait load({\n\t\t\t\tlocals: { user: null, session: null },\n\t\t\t\turl: new URL('http://localhost/dashboard')\n\t\t\t} as any);\n\t\t} catch (e) {\n\t\t\tconst err = e as { status: number; location: string };\n\t\t\texpect(err.status).toBe(302);\n\t\t\texpect(err.location).toMatch(/\\/login/);\n\t\t}\n\t});\n\n\tit('returns user data for authenticated users', async () => {\n\t\tconst { isAdmin } = await import('$lib/server/admin');\n\t\tvi.mocked(isAdmin).mockResolvedValue(true);\n\n\t\tconst load = (await import('./+layout.server')).load;\n\t\tconst result = await load({\n\t\t\tlocals: {\n\t\t\t\tuser: { id: 'u1', name: 'Admin', email: 'admin@test.com' },\n\t\t\t\tsession: { id: 's1', userId: 'u1' }\n\t\t\t},\n\t\t\turl: new URL('http://localhost/dashboard')\n\t\t} as any);\n\n\t\texpect(result).toHaveProperty('user');\n\t\texpect(result).toHaveProperty('session');\n\t});\n});\n",
"/routes/+layout.svelte": "\n\n{@render children()}\n\n\n\t{#each locales as locale (locale)}\n\t\t
{locale} \n\t{/each}\n
\n",
diff --git a/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte b/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte
index 62465d6f..ceb2f117 100644
--- a/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte
+++ b/packages/svforge/templates/dashboard/src/lib/components/svforge/layout/AdminLayout.svelte
@@ -96,7 +96,7 @@
@@ -142,7 +142,7 @@
{m.layout_admin()}
diff --git a/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte b/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte
index e78c4a64..be2bfe74 100644
--- a/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte
+++ b/packages/svforge/templates/dashboard/src/routes/(app)/admin/users/+page.svelte
@@ -274,7 +274,7 @@
: m.users_modal_deactivate()}
@@ -320,7 +320,7 @@
/>
{/if}
- {m.common_cancel()}
+ {m.common_cancel()}
{modal === 'create' ? m.users_create() : m.common_save()}
@@ -336,7 +336,7 @@
required
/>
- {m.common_cancel()}
+ {m.common_cancel()}
{m.users_invite_btn()}
@@ -350,7 +350,7 @@
- {m.common_cancel()}
+ {m.common_cancel()}
{statusTarget.disabled ? m.users_reactivate_btn() : m.users_deactivate_btn()}
diff --git a/tests/dashboard-trigger-affordance.test.ts b/tests/dashboard-trigger-affordance.test.ts
new file mode 100644
index 00000000..e96c3603
--- /dev/null
+++ b/tests/dashboard-trigger-affordance.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { dashboardTemplateFile } from './helpers';
+
+function triggerClasses(source: string): string[] {
+ return [...source.matchAll(/).)*\bclass="([^"]*)"(?:(?!>).)*>/gs)].map(
+ (match) => match[1]
+ );
+}
+
+describe('dashboard dialog trigger affordances (#594)', () => {
+ it('gives enabled dialog triggers a pointer cursor without replacing Skeleton triggers', () => {
+ const files = [
+ dashboardTemplateFile('lib', 'components', 'svforge', 'layout', 'AdminLayout.svelte'),
+ dashboardTemplateFile('routes', '(app)', 'admin', 'users', '+page.svelte')
+ ];
+ const classes = files.flatMap((file) => triggerClasses(readFileSync(file, 'utf-8')));
+
+ expect(classes.length).toBeGreaterThan(0);
+ expect(classes.every((className) => className.split(/\s+/).includes('cursor-pointer'))).toBe(true);
+ });
+});