Sprint: debug, zichtbaarheid componenten (#165)

* feat(debug-store): Zustand store met hydration-flag voor debug-modus

* feat(status-bar): dev-only debug-toggle via geïsoleerde sub-component

* feat(globals.css): debug-mode overlay CSS voor data-debug-id elementen

* feat(shared): data-debug-id+label op navigatie-componenten

* feat(shared): data-debug-id+label op form/select-componenten

* feat(shared): data-debug-id+label op display-componenten
This commit is contained in:
Janpeter Visser 2026-05-08 08:55:43 +02:00 committed by GitHub
parent f7464db837
commit a16988b957
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 154 additions and 35 deletions

View file

@ -3,3 +3,25 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@import "./styles/theme.css"; @import "./styles/theme.css";
/* Debug-mode overlay (alleen actief wanneer body.debug-mode is gezet door dev-only toggle) */
body.debug-mode [data-debug-id] {
outline: 2px dashed var(--info);
outline-offset: 1px;
position: relative;
}
body.debug-mode [data-debug-id]:hover::after {
content: attr(data-debug-label);
position: absolute;
top: 0;
left: 0;
background: var(--info-container);
color: var(--info-container-foreground);
font-size: 10px;
line-height: 1.2;
padding: 2px 4px;
white-space: nowrap;
border-radius: 2px;
z-index: 9999;
pointer-events: none;
}

View file

@ -28,6 +28,7 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = '
} }
return ( return (
<span data-debug-id="activate-product-button" data-debug-label="ActivateProductButton — shared/activate-product-button.tsx">
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<button <button
onClick={() => !isDemo && handleActivate()} onClick={() => !isDemo && handleActivate()}
@ -37,5 +38,6 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = '
{label} {label}
</button> </button>
</DemoTooltip> </DemoTooltip>
</span>
) )
} }

View file

@ -24,5 +24,5 @@ export function AlertToast() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [alert]) }, [alert])
return null return <span data-debug-id="alert-toast" data-debug-label="AlertToast — shared/alert-toast.tsx" hidden />
} }

View file

@ -13,6 +13,8 @@ export function AppIcon({ size = 32, className }: AppIconProps) {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className} className={className}
aria-label="Scrum4Me" aria-label="Scrum4Me"
data-debug-id="app-icon"
data-debug-label="AppIcon — shared/app-icon.tsx"
> >
<defs> <defs>
<linearGradient id="s4m-bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"> <linearGradient id="s4m-bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">

View file

@ -9,6 +9,8 @@ export function CodeBadge({ code, className }: CodeBadgeProps) {
if (!code) return null if (!code) return null
return ( return (
<span <span
data-debug-id="code-badge"
data-debug-label="CodeBadge — shared/code-badge.tsx"
className={cn( className={cn(
'inline-flex items-center rounded-md border border-border bg-surface-container px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground', 'inline-flex items-center rounded-md border border-border bg-surface-container px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground',
className, className,

View file

@ -10,16 +10,18 @@ interface DemoTooltipProps {
// Wraps children with a "Niet beschikbaar in demo-modus" tooltip when show=true. // Wraps children with a "Niet beschikbaar in demo-modus" tooltip when show=true.
// Uses a span trigger so tooltip works on disabled elements. // Uses a span trigger so tooltip works on disabled elements.
export function DemoTooltip({ show, children }: DemoTooltipProps) { export function DemoTooltip({ show, children }: DemoTooltipProps) {
if (!show) return <>{children}</> if (!show) return <span data-debug-id="demo-tooltip" data-debug-label="DemoTooltip — shared/demo-tooltip.tsx">{children}</span>
return ( return (
<TooltipProvider> <span data-debug-id="demo-tooltip" data-debug-label="DemoTooltip — shared/demo-tooltip.tsx">
<Tooltip> <TooltipProvider>
<TooltipTrigger render={<span className="inline-flex" />}> <Tooltip>
{children} <TooltipTrigger render={<span className="inline-flex" />}>
</TooltipTrigger> {children}
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
</TooltipProvider> </Tooltip>
</TooltipProvider>
</span>
) )
} }

View file

@ -3,7 +3,11 @@
// Shows a warning banner on screens narrower than 1024px. // Shows a warning banner on screens narrower than 1024px.
export function MinWidthBanner() { export function MinWidthBanner() {
return ( return (
<div className="lg:hidden bg-warning/10 border-b border-warning/30 px-4 py-2 text-center text-xs text-warning"> <div
data-debug-id="min-width-banner"
data-debug-label="MinWidthBanner — shared/min-width-banner.tsx"
className="lg:hidden bg-warning/10 border-b border-warning/30 px-4 py-2 text-center text-xs text-warning"
>
Scrum4Me is ontworpen voor schermen van minimaal 1024px breed. Sommige functies zijn mogelijk niet goed bruikbaar op dit scherm. Scrum4Me is ontworpen voor schermen van minimaal 1024px breed. Sommige functies zijn mogelijk niet goed bruikbaar op dit scherm.
</div> </div>
) )

View file

@ -111,7 +111,11 @@ export function NavBar({
} }
return ( return (
<header className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0"> <header
data-debug-id="nav-bar"
data-debug-label="NavBar — shared/nav-bar.tsx"
className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0"
>
{/* Links: logo + nav */} {/* Links: logo + nav */}
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<Link href="/" className="flex items-center gap-2 font-medium text-foreground"> <Link href="/" className="flex items-center gap-2 font-medium text-foreground">

View file

@ -27,6 +27,7 @@ export function NotificationsBell({ currentUserId, isDemo }: NotificationsBellPr
) )
return ( return (
<span data-debug-id="notifications-bell" data-debug-label="NotificationsBell — shared/notifications-bell.tsx">
<NotificationsSheet <NotificationsSheet
currentUserId={currentUserId} currentUserId={currentUserId}
isDemo={isDemo} isDemo={isDemo}
@ -53,5 +54,6 @@ export function NotificationsBell({ currentUserId, isDemo }: NotificationsBellPr
</button> </button>
} }
/> />
</span>
) )
} }

View file

@ -8,7 +8,11 @@ interface PanelNavBarProps {
export function PanelNavBar({ title, actions, className }: PanelNavBarProps) { export function PanelNavBar({ title, actions, className }: PanelNavBarProps) {
return ( return (
<div className={cn('flex items-center justify-between px-4 py-2 border-b border-border bg-surface-container-low shrink-0', className)}> <div
data-debug-id="panel-nav-bar"
data-debug-label="PanelNavBar — shared/panel-nav-bar.tsx"
className={cn('flex items-center justify-between px-4 py-2 border-b border-border bg-surface-container-low shrink-0', className)}
>
<span className="text-sm font-medium text-foreground">{title}</span> <span className="text-sm font-medium text-foreground">{title}</span>
{actions && <div className="flex items-center gap-2">{actions}</div>} {actions && <div className="flex items-center gap-2">{actions}</div>}
</div> </div>

View file

@ -26,6 +26,7 @@ interface PbiStatusSelectProps {
export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectProps) { export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectProps) {
return ( return (
<span data-debug-id="pbi-status-select" data-debug-label="PbiStatusSelect — shared/pbi-status-select.tsx">
<Select <Select
value={value} value={value}
onValueChange={(v) => { if (v) onChange(v as PbiStatusApi) }} onValueChange={(v) => { if (v) onChange(v as PbiStatusApi) }}
@ -39,5 +40,6 @@ export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectP
<SelectItem value="done">Afgerond</SelectItem> <SelectItem value="done">Afgerond</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</span>
) )
} }

View file

@ -25,6 +25,7 @@ interface PrioritySelectProps {
export function PrioritySelect({ value, onChange, className }: PrioritySelectProps) { export function PrioritySelect({ value, onChange, className }: PrioritySelectProps) {
return ( return (
<span data-debug-id="priority-select" data-debug-label="PrioritySelect — shared/priority-select.tsx">
<Select <Select
value={String(value)} value={String(value)}
onValueChange={(v) => { if (v) onChange(parseInt(v)) }} onValueChange={(v) => { if (v) onChange(parseInt(v)) }}
@ -39,5 +40,6 @@ export function PrioritySelect({ value, onChange, className }: PrioritySelectPro
<SelectItem value="4">Laag</SelectItem> <SelectItem value="4">Laag</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</span>
) )
} }

View file

@ -11,5 +11,5 @@ export function SetCurrentProduct({ id, name }: { id: string; name: string }) {
return () => clearCurrentProduct() return () => clearCurrentProduct()
}, [id, name, setCurrentProduct, clearCurrentProduct]) }, [id, name, setCurrentProduct, clearCurrentProduct])
return null return <span data-debug-id="set-current-product" data-debug-label="SetCurrentProduct — shared/set-current-product.tsx" hidden />
} }

View file

@ -68,21 +68,24 @@ export function SprintSwitcher({
if (sprints.length === 0) { if (sprints.length === 0) {
return ( return (
<TooltipProvider> <span data-debug-id="sprint-switcher" data-debug-label="SprintSwitcher — shared/sprint-switcher.tsx">
<Tooltip> <TooltipProvider>
<TooltipTrigger <Tooltip>
className="text-xs text-muted-foreground/50 px-2 cursor-not-allowed select-none" <TooltipTrigger
aria-disabled="true" className="text-xs text-muted-foreground/50 px-2 cursor-not-allowed select-none"
> aria-disabled="true"
Geen sprints >
</TooltipTrigger> Geen sprints
<TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent>
</TooltipProvider> </Tooltip>
</TooltipProvider>
</span>
) )
} }
return ( return (
<span data-debug-id="sprint-switcher" data-debug-label="SprintSwitcher — shared/sprint-switcher.tsx">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={isPending} disabled={isPending}
@ -152,5 +155,6 @@ export function SprintSwitcher({
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</span>
) )
} }

View file

@ -0,0 +1,31 @@
'use client'
import { useEffect } from 'react'
import { useDebugStore } from '@/stores/debug-store'
export function DebugToggle() {
const { debugMode, _hydrated, hydrate, toggleDebugMode } = useDebugStore()
useEffect(() => {
hydrate(localStorage.getItem('scrum4me:debug-mode') === 'true')
}, [hydrate])
useEffect(() => {
if (!_hydrated) return
localStorage.setItem('scrum4me:debug-mode', String(debugMode))
document.body.classList.toggle('debug-mode', debugMode)
}, [debugMode, _hydrated])
return (
<button
type="button"
onClick={toggleDebugMode}
aria-label="Debug-modus togglen"
aria-pressed={debugMode}
data-active={debugMode}
className="ml-2 cursor-pointer rounded px-1 text-xs opacity-40 transition-opacity hover:opacity-100 data-[active=true]:text-info"
>
{'{ }'}
</button>
)
}

View file

@ -1,3 +1,7 @@
'use client'
import { DebugToggle } from './status-bar-debug-toggle'
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE
? new Date(process.env.NEXT_PUBLIC_BUILD_DATE).toLocaleDateString('nl-NL', { ? new Date(process.env.NEXT_PUBLIC_BUILD_DATE).toLocaleDateString('nl-NL', {
day: 'numeric', day: 'numeric',
@ -7,12 +11,17 @@ const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE
: '—' : '—'
const version = process.env.NEXT_PUBLIC_APP_VERSION ?? '0.0.0' const version = process.env.NEXT_PUBLIC_APP_VERSION ?? '0.0.0'
const isDev = process.env.NODE_ENV !== 'production'
export function StatusBar() { export function StatusBar() {
return ( return (
<footer className="shrink-0 border-t border-border bg-surface-container-low h-14 px-4 flex items-center justify-between text-sm text-muted-foreground select-none"> <footer
className="shrink-0 border-t border-border bg-surface-container-low h-14 px-4 flex items-center justify-between text-sm text-muted-foreground select-none"
data-debug-id="status-bar"
data-debug-label="StatusBar — shared/status-bar.tsx"
>
<span>© {new Date().getFullYear()} Scrum4Me</span> <span>© {new Date().getFullYear()} Scrum4Me</span>
<span>v{version} · gebouwd op {buildDate}</span> <span>v{version} · gebouwd op {buildDate}{isDev && <DebugToggle />}</span>
</footer> </footer>
) )
} }

View file

@ -34,14 +34,22 @@ const TYPE_STYLES: Record<string, { bg: string; label: string; labelColor: strin
export function StoryLog({ logs, repoUrl }: StoryLogProps) { export function StoryLog({ logs, repoUrl }: StoryLogProps) {
if (logs.length === 0) { if (logs.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground text-center py-4"> <p
data-debug-id="story-log"
data-debug-label="StoryLog — shared/story-log.tsx"
className="text-sm text-muted-foreground text-center py-4"
>
Nog geen activiteit. Gebruik de REST API om logs toe te voegen. Nog geen activiteit. Gebruik de REST API om logs toe te voegen.
</p> </p>
) )
} }
return ( return (
<div className="space-y-3"> <div
data-debug-id="story-log"
data-debug-label="StoryLog — shared/story-log.tsx"
className="space-y-3"
>
{logs.map(log => { {logs.map(log => {
const style = TYPE_STYLES[log.type] ?? TYPE_STYLES.IMPLEMENTATION_PLAN const style = TYPE_STYLES[log.type] ?? TYPE_STYLES.IMPLEMENTATION_PLAN
const isTestResult = log.type === 'TEST_RESULT' const isTestResult = log.type === 'TEST_RESULT'

View file

@ -23,11 +23,13 @@ export function UserAvatar({ userId, username, size = 'md', className }: UserAva
const initials = username.slice(0, 2).toUpperCase() const initials = username.slice(0, 2).toUpperCase()
return ( return (
<Avatar className={cn(SIZE_CLASSES[size], className)}> <span data-debug-id="user-avatar" data-debug-label="UserAvatar — shared/user-avatar.tsx">
<AvatarImage src={`/api/users/${userId}/avatar`} alt={username} /> <Avatar className={cn(SIZE_CLASSES[size], className)}>
<AvatarFallback className="bg-primary-container text-primary-container-foreground font-medium"> <AvatarImage src={`/api/users/${userId}/avatar`} alt={username} />
{initials} <AvatarFallback className="bg-primary-container text-primary-container-foreground font-medium">
</AvatarFallback> {initials}
</Avatar> </AvatarFallback>
</Avatar>
</span>
) )
} }

View file

@ -46,6 +46,7 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) {
} }
return ( return (
<span data-debug-id="user-menu" data-debug-label="UserMenu — shared/user-menu.tsx">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
@ -137,5 +138,6 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</span>
) )
} }

15
stores/debug-store.ts Normal file
View file

@ -0,0 +1,15 @@
import { create } from 'zustand'
type DebugStore = {
debugMode: boolean
_hydrated: boolean
hydrate: (value: boolean) => void
toggleDebugMode: () => void
}
export const useDebugStore = create<DebugStore>((set, get) => ({
debugMode: false,
_hydrated: false,
hydrate: (v) => set({ debugMode: v, _hydrated: true }),
toggleDebugMode: () => set({ debugMode: !get().debugMode }),
}))