polish(ui): founder feedback batch - inset sidebar hairline, calmer dialogs, Discord mark, chat skeleton (#1158)
- Sidebar: the hairline above the user block is inset to the content edges instead of running edge-to-edge (concept language). - User menu: the Discord community link renders the actual Discord mark (inlined simple-icons path, CC0) instead of lucide MessagesSquare. - Dialogs: open/close animation toned down, zoom 98 instead of 95 and 150ms instead of 200ms. - Settings modal: switching tabs no longer remounts the intercepted route (and replayed the whole open animation on every click). The rail now swaps sections via history.replaceState, which Next syncs into usePathname(), so the dialog stays mounted and tab switches are instant. Verified via Playwright: dialog DOM node survives three tab switches, URL tracks the section, Esc still closes back. - /chat loading: the shared dashboard skeleton stretched edge-to-edge in chat's full-bleed wrapper (chat's own layout is what suspends, so a chat/loading.tsx cannot catch it). The shared fallback is now route- aware and renders a two-pane chat silhouette for /chat. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
15300aa8e2
commit
bb551d1d59
@@ -1,3 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/**
|
||||
@@ -10,8 +13,43 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
* A greeting + hairline-separated rows reads honestly on Hem and stays neutral
|
||||
* on the plain list pages that fall back here (a page title + rows), which is
|
||||
* why it is not shaped like any one page's specific grid.
|
||||
*
|
||||
* /chat is the exception: MainContainer renders it full-bleed (no max-width,
|
||||
* no padding), so the column silhouette would stretch edge-to-edge and read
|
||||
* broken. The chat branch mirrors the two-pane chat shell instead:
|
||||
* conversation sidebar + empty conversation pane (see ChatLayout/ChatSidebar).
|
||||
*/
|
||||
export default function DashboardLoading() {
|
||||
const pathname = usePathname()
|
||||
|
||||
if (pathname.startsWith('/chat')) {
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<aside className="flex w-full flex-col border-r border-border bg-card/40 md:w-80 shrink-0">
|
||||
<div className="space-y-3 border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-full rounded-md" />
|
||||
</div>
|
||||
<div className="space-y-1 p-3">
|
||||
{['w-40', 'w-48', 'w-36', 'w-44'].map((w, i) => (
|
||||
<div key={i} className="space-y-1.5 px-2 py-2.5">
|
||||
<Skeleton className={`h-3.5 ${w}`} />
|
||||
<Skeleton className="h-2.5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="hidden min-w-0 flex-1 bg-background md:block" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Greeting hero (title + date line) */}
|
||||
|
||||
@@ -348,6 +348,8 @@ body {
|
||||
@utility fade-out-0 { --tw-exit-opacity: 0; }
|
||||
@utility zoom-in-95 { --tw-enter-scale: 0.95; }
|
||||
@utility zoom-out-95 { --tw-exit-scale: 0.95; }
|
||||
@utility zoom-in-98 { --tw-enter-scale: 0.98; }
|
||||
@utility zoom-out-98 { --tw-exit-scale: 0.98; }
|
||||
@utility slide-in-from-top { --tw-enter-translate-y: -100%; }
|
||||
@utility slide-in-from-top-1 { --tw-enter-translate-y: -0.25rem; }
|
||||
@utility slide-in-from-top-2 { --tw-enter-translate-y: -0.5rem; }
|
||||
|
||||
@@ -876,20 +876,24 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
|
||||
{/* Sticky user block (bottom-left): avatar, name, active company.
|
||||
Opens the upward user menu with the company-switcher flyout,
|
||||
account links and logout (concept PR 2). */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 border-t border-border/60',
|
||||
collapsed ? 'px-2 py-2' : 'px-3 py-2',
|
||||
)}
|
||||
>
|
||||
<UserMenu
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
isSandbox={isSandbox}
|
||||
collapsed={collapsed}
|
||||
onLogout={() => void handleLogout()}
|
||||
account links and logout (concept PR 2). The hairline above is
|
||||
inset to the content edges, not edge-to-edge (concept). */}
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-border/60',
|
||||
collapsed ? 'mx-2' : 'mx-3',
|
||||
)}
|
||||
/>
|
||||
<div className={cn(collapsed ? 'px-2 py-2' : 'px-3 py-2')}>
|
||||
<UserMenu
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
isSandbox={isSandbox}
|
||||
collapsed={collapsed}
|
||||
onLogout={() => void handleLogout()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
LogOut,
|
||||
MessagesSquare,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -30,6 +29,16 @@ import {
|
||||
// actually asks for that.
|
||||
const DISCORD_INVITE_URL = 'https://discord.gg/D9SxtTgvx'
|
||||
|
||||
// Lucide ships no brand marks, so the Discord logo is inlined (simple-icons
|
||||
// path, CC0). Sized and colored like the surrounding lucide icons.
|
||||
function DiscordLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface UserMenuProps {
|
||||
userName: string | null
|
||||
userEmail: string | null
|
||||
@@ -333,7 +342,7 @@ export default function UserMenu({
|
||||
onClick={close}
|
||||
className={menuRow}
|
||||
>
|
||||
<MessagesSquare className="h-4 w-4 flex-shrink-0" />
|
||||
<DiscordLogo className="h-4 w-4 flex-shrink-0" />
|
||||
{tNav('discord_community')}
|
||||
</a>
|
||||
<SupportLink variant="muted" className={menuRow} />
|
||||
|
||||
@@ -27,11 +27,15 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('settings_modal')
|
||||
|
||||
// Tab clicks inside the modal update the URL shallowly (see SettingsRail),
|
||||
// so the pathname is the live source of truth for the active section; the
|
||||
// sectionId route param only covers the very first intercepted render.
|
||||
// Bare /settings (or an unknown section) defaults to company, or to account
|
||||
// when there is no active company (the no-company escape hatch).
|
||||
const urlSection = pathname.split('/')[2] ?? sectionId
|
||||
const resolved =
|
||||
sectionId && SETTINGS_SECTIONS[sectionId]
|
||||
? sectionId
|
||||
urlSection && SETTINGS_SECTIONS[urlSection]
|
||||
? urlSection
|
||||
: company
|
||||
? 'company'
|
||||
: 'account'
|
||||
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
import { useSettingsNavItems } from './useSettingsNavItems'
|
||||
|
||||
interface SettingsRailProps {
|
||||
/** Layout context: 'page' navigates with push (real route), 'modal' replaces
|
||||
* the URL so section-switching keeps a single back-stack entry. */
|
||||
/** Layout context: 'page' navigates with push (real route), 'modal' swaps
|
||||
* the URL shallowly (history.replaceState) so section-switching keeps a
|
||||
* single back-stack entry AND never re-renders the intercepted modal
|
||||
* route: a router navigation would remount the Dialog and replay its
|
||||
* open animation on every tab click. */
|
||||
variant: 'page' | 'modal'
|
||||
/** 'rail' = grouped vertical list (desktop); 'select' = grouped dropdown (mobile). */
|
||||
display: 'rail' | 'select'
|
||||
@@ -38,7 +41,9 @@ export function SettingsRail({ variant, display, activeId }: SettingsRailProps)
|
||||
items[0]?.id
|
||||
|
||||
function navigate(href: string) {
|
||||
if (variant === 'modal') router.replace(href)
|
||||
// Shallow update: Next syncs usePathname() from the native History API,
|
||||
// so SettingsModal re-resolves the section without a route transition.
|
||||
if (variant === 'modal') window.history.replaceState(null, '', href)
|
||||
else router.push(href)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user