Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/clear-avatar-initial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
'svforge': patch
---
Inline Dashboard avatar initials in the reactive markup without changing empty-name or variant behavior.
50 changes: 50 additions & 0 deletions graphify-out/graph.json
Original file line number Diff line number Diff line change
Expand Up @@ -21916,6 +21916,14 @@
"_callable": true,
"_origin": "ast"
},
{
"id": "tests_avatar_initial_test",
"label": "avatar-initial.test.ts",
"file_type": "code",
"source_file": "tests/avatar-initial.test.ts",
"source_location": "L1",
"_origin": "ast"
},
{
"id": "tests_avoid_patterns_test",
"label": "avoid-patterns.test.ts",
Expand Down Expand Up @@ -26379,6 +26387,15 @@
"source_location": "L1",
"_origin": "ast"
},
{
"id": "changeset_clear_avatar_initial",
"label": "clear-avatar-initial.md",
"file_type": "document",
"node_kind": "page",
"source_file": ".changeset/clear-avatar-initial.md",
"source_location": "L1",
"_origin": "ast"
},
{
"id": "changeset_plain_base_class_derivations",
"label": "plain-base-class-derivations.md",
Expand Down Expand Up @@ -74057,6 +74074,39 @@
"source_file": "tests/auth-schema-check.test.ts",
"_origin": "ast"
},
{
"source": "tests_avatar_initial_test",
"target": "ref_vitest",
"relation": "imports_from",
"context": "import",
"confidence": "EXTRACTED",
"source_file": "tests/avatar-initial.test.ts",
"source_location": "L2",
"weight": 1,
"_origin": "ast"
},
{
"source": "tests_avatar_initial_test",
"target": "ref_testing_library_svelte",
"relation": "imports_from",
"context": "import",
"confidence": "EXTRACTED",
"source_file": "tests/avatar-initial.test.ts",
"source_location": "L3",
"weight": 1,
"_origin": "ast"
},
{
"source": "tests_avatar_initial_test",
"target": "packages_svforge_templates_dashboard_src_lib_components_svforge_ui_avatarinitial",
"relation": "imports_from",
"context": "import",
"confidence": "EXTRACTED",
"source_file": "tests/avatar-initial.test.ts",
"source_location": "L4",
"weight": 1,
"_origin": "ast"
},
{
"source": "tests_avoid_patterns_test",
"target": "ref_vitest",
Expand Down
12 changes: 10 additions & 2 deletions graphify-out/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@
"semantic_hash": ""
},
"packages/svforge/src/templates.ts": {
"ast_hash": "3136b546212ced01f45ec40895b95434",
"ast_hash": "af298d7862569aeb90dc421a2df9b5df",
"semantic_hash": ""
},
"packages/svforge/src/upgrade.ts": {
Expand Down Expand Up @@ -740,7 +740,7 @@
"semantic_hash": ""
},
"packages/svforge/templates/dashboard/src/lib/components/svforge/ui/AvatarInitial.svelte": {
"ast_hash": "2429a1b2d87321a7bcc4205edeadd459",
"ast_hash": "6824a407a2889c63bdceeb85bd4e5614",
"semantic_hash": ""
},
"packages/svforge/templates/dashboard/src/lib/components/svforge/ui/Feedback.svelte": {
Expand Down Expand Up @@ -2110,5 +2110,13 @@
".changeset/plain-base-class-derivations.md": {
"ast_hash": "7778c6938a8841c5a60545fb9b168b42",
"semantic_hash": ""
},
"tests/avatar-initial.test.ts": {
"ast_hash": "53a0adb8832b105297d25494fd9bb3dc",
"semantic_hash": ""
},
".changeset/clear-avatar-initial.md": {
"ast_hash": "75e717999620fe7c44c536ab7aaf9e77",
"semantic_hash": ""
}
}
2 changes: 1 addition & 1 deletion packages/svforge/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const dashboardFiles = {
"/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.js';\nimport { paraglideMiddleware } from '#lib/paraglide/server.js';\nimport { auth } from '#lib/server/auth.js';\nimport { db } from '#lib/server/db/index.js';\nimport { user } from '#lib/server/db/schema.js';\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": "<script lang=\"ts\">\n\timport { cn } from '#lib/utils/cn.js';\n\timport * as m from '#lib/paraglide/messages.js';\n\timport type { Snippet } from 'svelte';\n\timport type { HTMLAttributes } from 'svelte/elements';\n\timport { Users, Gear, ChartBar, SignOut, Menu, X } from '#lib/icons/index.js';\n\timport { Button } from '#lib/components/svforge/primitives/index.js';\n\timport ThemeToggle from '#lib/components/svforge/ui/ThemeToggle.svelte';\n\timport { Dialog, Portal } from '@skeletonlabs/skeleton-svelte';\n\n\ttype NavItem = { href: string; label: string; icon: typeof ChartBar };\n\n\tinterface Props extends HTMLAttributes<HTMLElement> {\n\t\titems?: NavItem[];\n\t\tcurrentPath?: string;\n\t\tuser?: { name: string; email: string } | null;\n\t\tonSignOut?: () => void;\n\t\tclass?: string;\n\t\tchildren: Snippet;\n\t}\n\n\tlet {\n\t\titems = [\n\t\t\t{ href: '/admin', label: m.layout_dashboard(), icon: ChartBar },\n\t\t\t{ href: '/admin/users', label: m.layout_users(), icon: Users },\n\t\t\t{ href: '/admin/settings', label: m.layout_settings(), icon: Gear }\n\t\t],\n\t\tcurrentPath = '',\n\t\tuser = null,\n\t\tonSignOut,\n\t\tclass: className,\n\t\tchildren\n\t}: Props = $props();\n\n\tlet collapsed = $state(false);\n\tlet mobileOpen = $state(false);\n\n\tfunction navClass(href: string) {\n\t\treturn cn(\n\t\t\t'flex min-h-11 items-center gap-3 rounded-container px-3 py-2 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-700-300',\n\t\t\tcurrentPath === href\n\t\t\t\t? 'bg-primary-100-900 text-primary-950-50'\n\t\t\t\t: 'text-surface-700-300 hover:bg-surface-100-900'\n\t\t);\n\t}\n</script>\n\n<Dialog\n\topen={mobileOpen}\n\tonOpenChange={(details) => (mobileOpen = details.open)}\n\tcloseOnInteractOutside={true}\n\trestoreFocus={true}\n>\n<div class={cn('flex min-h-screen', className)}>\n\t<!-- Desktop Sidebar -->\n\t<aside class=\"hidden flex-col border-r border-surface-200-800 bg-surface-50-950 transition-all lg:flex {collapsed ? 'w-16' : 'w-56'}\">\n\t\t<div class=\"flex items-center justify-between border-b border-surface-200-800 p-3\">\n\t\t\t{#if !collapsed}\n\t\t\t\t<a href=\"/admin\" class=\"text-lg font-bold text-primary-900-100\">{m.layout_admin()}</a>\n\t\t\t{/if}\n\t\t\t<Button\n\t\t\t\ttype=\"button\"\n\t\t\t\tvariant=\"tonal\"\n\t\t\t\tcolor=\"surface\"\n\t\t\t\tclass=\"size-11 p-0\"\n\t\t\t\tonclick={() => (collapsed = !collapsed)}\n\t\t\t\taria-label={m.layout_toggle_sidebar()}\n\t\t\t\taria-expanded={!collapsed}\n\t\t\t>\n\t\t\t\t<Menu size={18} />\n\t\t\t</Button>\n\t\t</div>\n\n\t\t<nav class=\"flex-1 space-y-1 p-2\" aria-label={m.layout_menu()}>\n\t\t\t{#each items as item (item.href)}\n\t\t\t\t{@const Icon = item.icon}\n\t\t\t\t<a\n\t\t\t\t\thref={item.href}\n\t\t\t\t\tclass={navClass(item.href)}\n\t\t\t\t\taria-current={currentPath === item.href ? 'page' : undefined}\n\t\t\t\t>\n\t\t\t\t\t<Icon size={20} />\n\t\t\t\t\t{#if !collapsed}<span class=\"text-sm font-medium\">{item.label}</span>{/if}\n\t\t\t\t</a>\n\t\t\t{/each}\n\t\t</nav>\n\n\t\t<div class=\"border-t border-surface-200-800 p-2\">\n\t\t\t<ThemeToggle />\n\t\t</div>\n\t</aside>\n\n\t<!-- Main area -->\n\t<div class=\"flex flex-1 flex-col\">\n\t\t<!-- Top bar -->\n\t\t<header class=\"sticky top-0 z-50 flex items-center justify-between border-b border-surface-200-800 bg-surface-50-950/80 px-4 py-3 backdrop-blur-md\">\n\t\t\t<div class=\"flex items-center gap-3\">\n\t\t\t\t<Dialog.Trigger\n\t\t\t\t\tclass=\"btn-icon size-11 preset-tonal-surface focus-visible:ring-2 focus-visible:ring-primary-700-300 lg:hidden cursor-pointer\"\n\t\t\t\t\taria-label={m.layout_menu()}\n\t\t\t\t\tdata-testid=\"admin-mobile-menu-trigger\"\n\t\t\t\t>\n\t\t\t\t\t<Menu size={20} />\n\t\t\t\t</Dialog.Trigger>\n\t\t\t\t<h1 class=\"text-lg font-bold\">{m.layout_admin()}</h1>\n\t\t\t</div>\n\t\t\t<div class=\"flex items-center gap-3\">\n\t\t\t\t<ThemeToggle class=\"lg:hidden\" />\n\t\t\t\t{#if user}\n\t\t\t\t\t<span class=\"hidden text-sm text-surface-700-300 sm:block\">{user.name}</span>\n\t\t\t\t\t{#if onSignOut}\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tvariant=\"tonal\"\n\t\t\t\t\t\t\tcolor=\"surface\"\n\t\t\t\t\t\t\tclass=\"size-11 p-0\"\n\t\t\t\t\t\t\tonclick={onSignOut}\n\t\t\t\t\t\t\taria-label={m.layout_sign_out()}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<SignOut size={18} />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t{/if}\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</header>\n\n\n\t\t<!-- Content -->\n\t\t<main class=\"flex-1 overflow-auto p-4 sm:p-6\">\n\t\t\t{@render children()}\n\t\t</main>\n\t</div>\n</div>\n\t<Portal>\n\t\t<Dialog.Backdrop class=\"fixed inset-0 z-50 bg-surface-50-950/50 lg:hidden\" />\n\t\t<Dialog.Positioner class=\"fixed inset-0 z-50 flex justify-start lg:hidden\">\n\t\t\t<Dialog.Content\n\t\t\t\tclass=\"card preset-filled-surface-50-950 flex h-dvh w-72 max-w-[calc(100vw-1rem)] flex-col border-r border-surface-200-800 p-3 shadow-xl\"\n\t\t\t\tdata-testid=\"admin-mobile-drawer\"\n\t\t\t>\n\t\t\t\t<header class=\"mb-3 flex items-center justify-between border-b border-surface-200-800 pb-3\">\n\t\t\t\t\t<Dialog.Title class=\"h3\">{m.layout_admin()}</Dialog.Title>\n\t\t\t\t\t<Dialog.CloseTrigger\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclass=\"btn-icon size-11 hover:preset-tonal-surface focus-visible:ring-2 focus-visible:ring-primary-700-300 cursor-pointer\"\n\t\t\t\t\t\taria-label={m.layout_close_menu()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<X size={20} />\n\t\t\t\t\t</Dialog.CloseTrigger>\n\t\t\t\t</header>\n\t\t\t\t<nav class=\"flex-1 space-y-1\" aria-label={m.layout_menu()}>\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<a\n\t\t\t\t\t\t\thref={item.href}\n\t\t\t\t\t\t\tclass={navClass(item.href)}\n\t\t\t\t\t\t\taria-current={currentPath === item.href ? 'page' : undefined}\n\t\t\t\t\t\t\tonclick={() => (mobileOpen = false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Icon size={20} />\n\t\t\t\t\t\t\t<span class=\"text-sm font-medium\">{item.label}</span>\n\t\t\t\t\t\t</a>\n\t\t\t\t\t{/each}\n\t\t\t\t</nav>\n\t\t\t</Dialog.Content>\n\t\t</Dialog.Positioner>\n\t</Portal>\n</Dialog>\n",
"/lib/components/svforge/ui/AvatarInitial.svelte": "<script lang=\"ts\">\n import { cn } from '#lib/utils/cn.js';\n interface Props {\n name: string;\n size?: 'sm' | 'md' | 'lg';\n class?: string;\n }\n let { name, size = 'md', class: className }: Props = $props();\n const initial = $derived(name[0]?.toUpperCase() ?? '?');\n const sizes = { sm: 'w-7 h-7 text-xs', md: 'w-8 h-8 text-sm', lg: 'w-12 h-12 text-lg' };\n</script>\n\n<div class={cn('rounded-full bg-primary-100-900 flex items-center justify-center font-bold text-primary-950-50', sizes[size], className)}>\n {initial}\n</div>\n",
"/lib/components/svforge/ui/AvatarInitial.svelte": "<script lang=\"ts\">\n import { cn } from '#lib/utils/cn.js';\n interface Props {\n name: string;\n size?: 'sm' | 'md' | 'lg';\n class?: string;\n }\n let { name, size = 'md', class: className }: Props = $props();\n const sizes = { sm: 'w-7 h-7 text-xs', md: 'w-8 h-8 text-sm', lg: 'w-12 h-12 text-lg' };\n</script>\n\n<div class={cn('rounded-full bg-primary-100-900 flex items-center justify-center font-bold text-primary-950-50', sizes[size], className)}>\n {name[0]?.toUpperCase() ?? '?'}\n</div>\n",
"/lib/components/svforge/ui/Feedback.svelte": "<script lang=\"ts\">\n\timport { cn } from '#lib/utils/cn.js';\n\timport * as m from '#lib/paraglide/messages.js';\n\timport { Check, Warning, X } from '#lib/icons/index.js';\n\timport { Button } from '#lib/components/svforge/primitives/index.js';\n\n\tinterface Props {\n\t\ttype: 'success' | 'error';\n\t\tmessage: string;\n\t\tondismiss?: () => void;\n\t\tclass?: string;\n\t}\n\n\tlet { type, message, ondismiss, class: className }: Props = $props();\n</script>\n\n{#if message}\n\t<!-- #317: the Skeleton tonal presets own the semantic bg+fg pairing —\n\t never hand-pair bg-*-100-900 with text-*-700-300 (that re-implements\n\t preset-tonal-*). rounded-container + p-3 stay: plain <div> layout via\n\t Tailwind and the theme radius token. -->\n\t<div role={type === 'error' ? 'alert' : 'status'} aria-live={type === 'error' ? 'assertive' : 'polite'} class={cn('flex items-center gap-2 rounded-container p-3', type === 'success' ? 'preset-tonal-success' : 'preset-tonal-error', className)}>\n\t\t{#if type === 'success'}<Check size={18} />{:else}<Warning size={18} />{/if}\n\t\t<span class=\"text-sm\">{message}</span>\n\t\t{#if ondismiss}\n\t\t\t<Button\n\t\t\t\ttype=\"button\"\n\t\t\t\tvariant=\"ghost\"\n\t\t\t\tcolor=\"surface\"\n\t\t\t\tclass=\"ml-auto size-11 shrink-0 p-0\"\n\t\t\t\tonclick={ondismiss}\n\t\t\t\taria-label={m.common_dismiss()}\n\t\t\t><X size={18} /></Button>\n\t\t{/if}\n\t</div>\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",
"/lib/icons/core.ts": "export { default as Menu } from 'phosphor-svelte/lib/List';\nexport { default as X } from 'phosphor-svelte/lib/X';\nexport { default as SquaresFour } from 'phosphor-svelte/lib/SquaresFour';\nexport { default as Sun } from 'phosphor-svelte/lib/Sun';\nexport { default as Moon } from 'phosphor-svelte/lib/Moon';\nexport { default as Users } from 'phosphor-svelte/lib/Users';\nexport { default as Gear } from 'phosphor-svelte/lib/Gear';\nexport { default as ChartBar } from 'phosphor-svelte/lib/ChartBar';\nexport { default as SignOut } from 'phosphor-svelte/lib/SignOut';\nexport { default as UserPlus } from 'phosphor-svelte/lib/UserPlus';\nexport { default as EnvelopeSimple } from 'phosphor-svelte/lib/EnvelopeSimple';\nexport { default as Power } from 'phosphor-svelte/lib/Power';\nexport { default as Pencil } from 'phosphor-svelte/lib/Pencil';\nexport { default as Check } from 'phosphor-svelte/lib/Check';\nexport { default as Warning } from 'phosphor-svelte/lib/Warning';\nexport { default as Lock } from 'phosphor-svelte/lib/Lock';\nexport { default as Clock } from 'phosphor-svelte/lib/Clock';\nexport { default as Eye } from 'phosphor-svelte/lib/Eye';\nexport { default as EyeSlash } from 'phosphor-svelte/lib/EyeSlash';\n",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
class?: string;
}
let { name, size = 'md', class: className }: Props = $props();
const initial = $derived(name[0]?.toUpperCase() ?? '?');
const sizes = { sm: 'w-7 h-7 text-xs', md: 'w-8 h-8 text-sm', lg: 'w-12 h-12 text-lg' };
</script>

<div class={cn('rounded-full bg-primary-100-900 flex items-center justify-center font-bold text-primary-950-50', sizes[size], className)}>
{initial}
{name[0]?.toUpperCase() ?? '?'}
</div>
30 changes: 30 additions & 0 deletions tests/avatar-initial.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from '@testing-library/svelte';
import AvatarInitial from '../packages/svforge/templates/dashboard/src/lib/components/svforge/ui/AvatarInitial.svelte';

afterEach(cleanup);

describe('Dashboard AvatarInitial', () => {
it('renders the current name initial and reacts to name changes', async () => {
const { container, rerender } = render(AvatarInitial, {
props: { name: 'Ada Lovelace' }
});
const avatar = container.firstElementChild!;

expect(avatar.textContent).toBe('A');
expect(avatar.classList).toContain('w-8');

await rerender({ name: 'Grace Hopper', size: 'lg', class: 'team-avatar' });

expect(avatar.textContent).toBe('G');
expect(avatar.classList).toContain('w-12');
expect(avatar.classList).not.toContain('w-8');
expect(avatar.classList).toContain('team-avatar');
});

it('uses a question mark when the name is empty', () => {
const { container } = render(AvatarInitial, { props: { name: '' } });
expect(container.firstElementChild?.textContent).toBe('?');
});
});
Loading