fix(ux): focus visibility, tap targets, form labels, surfaced errors

- S6: unlayered :focus-visible ring for form controls and links so
  keyboard focus is perceivable (was focus:outline-none + 1px border)
- S5: >=44px touch targets at the named hotspots (dashboard actions,
  list-row buttons, ops pills/icons, customize number inputs); dense
  rows use a 44px hit area without growing row height
- S8: 28 form fields associated via id/htmlFor; search inputs get
  aria-label
- S7: claim/complete/delete/link handlers now surface result.error via
  toast and stop instead of failing silently
This commit is contained in:
Moheeb Zara
2026-05-16 00:00:23 -07:00
parent ea49e2129d
commit 075b274204
11 changed files with 125 additions and 106 deletions
+17 -16
View File
@@ -110,13 +110,13 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
<form onSubmit={onSubmit} className="p-6 space-y-4"> <form onSubmit={onSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Name *</label> <label htmlFor="contact-name" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Name *</label>
<input type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} <input id="contact-name" type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label> <label htmlFor="contact-type" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
<select value={form.contact_type} onChange={e => setForm(f => ({ ...f, contact_type: e.target.value }))} <select id="contact-type" value={form.contact_type} onChange={e => setForm(f => ({ ...f, contact_type: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
{Object.keys(CATEGORY_COLORS).map(t => <option key={t}>{t}</option>)} {Object.keys(CATEGORY_COLORS).map(t => <option key={t}>{t}</option>)}
</select> </select>
@@ -124,31 +124,31 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email</label> <label htmlFor="contact-email" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email</label>
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} <input id="contact-email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label> <label htmlFor="contact-phone" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} <input id="contact-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Details / Notes</label> <label htmlFor="contact-details" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Details / Notes</label>
<textarea value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))} <textarea id="contact-details" value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))}
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" /> rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Group Label</label> <label htmlFor="contact-group-label" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Group Label</label>
<input type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))} <input id="contact-group-label" type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))}
placeholder="e.g. Equipment Vendors" placeholder="e.g. Equipment Vendors"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags</label> <label htmlFor="contact-tags" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags</label>
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} <input id="contact-tags" type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="laser, cnc, maintenance" placeholder="laser, cnc, maintenance"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
@@ -184,6 +184,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
</svg> </svg>
<input <input
type="text" type="text"
aria-label="Search contacts"
placeholder="Search contacts..." placeholder="Search contacts..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
@@ -220,14 +221,14 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button <button
onClick={() => openEdit(contact)} onClick={() => openEdit(contact)}
className="text-muted-foreground hover:text-primary transition" className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-primary transition"
aria-label="Edit contact" aria-label="Edit contact"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={() => handleDelete(contact.id)} onClick={() => handleDelete(contact.id)}
className="text-muted-foreground hover:text-destructive transition" className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-destructive transition"
aria-label="Delete contact" aria-label="Delete contact"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
+1 -1
View File
@@ -52,7 +52,7 @@ export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; area
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{[...areas].sort((a, b) => a.sort_order - b.sort_order).map(a => ( {[...areas].sort((a, b) => a.sort_order - b.sort_order).map(a => (
<li key={a.id} className={`py-3 flex items-center gap-3 flex-wrap ${a.is_archived ? 'opacity-50' : ''}`}> <li key={a.id} className={`py-3 flex items-center gap-3 flex-wrap ${a.is_archived ? 'opacity-50' : ''}`}>
<input type="number" defaultValue={a.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" /> <input type="number" defaultValue={a.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 min-h-[44px] focus:outline-none focus:border-primary" />
{a.icon && <span>{a.icon}</span>} {a.icon && <span>{a.icon}</span>}
<input type="text" defaultValue={a.name} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === a.name) return; const res = await updateArea({ areaId: a.id, name: v }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, name: v } : x)); toast.success('Saved') }} className="flex-1 min-w-[140px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" /> <input type="text" defaultValue={a.name} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === a.name) return; const res = await updateArea({ areaId: a.id, name: v }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, name: v } : x)); toast.success('Saved') }} className="flex-1 min-w-[140px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{a.code}</span> <span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{a.code}</span>
@@ -45,7 +45,7 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
{[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => ( {[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => (
<li key={s.id} className="border border-border rounded p-4"> <li key={s.id} className="border border-border rounded p-4">
<div className="flex items-center gap-3 mb-3 flex-wrap"> <div className="flex items-center gap-3 mb-3 flex-wrap">
<input type="number" defaultValue={s.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" /> <input type="number" defaultValue={s.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 min-h-[44px] focus:outline-none focus:border-primary" />
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">{s.step_type.replace(/_/g, ' ')}</span> <span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">{s.step_type.replace(/_/g, ' ')}</span>
<input type="text" defaultValue={s.title} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" /> <input type="text" defaultValue={s.title} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
{s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>} {s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
+2 -2
View File
@@ -100,7 +100,7 @@ export default async function DashboardPage() {
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between"> <div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
<PageTitle>Dashboard</PageTitle> <PageTitle>Dashboard</PageTitle>
<Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"> <Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 min-h-[44px] rounded hover:bg-primary/90 transition">
<IcoPlus className="w-3.5 h-3.5" /> <IcoPlus className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Quick Task</span> <span className="hidden sm:inline">Quick Task</span>
<span className="sm:hidden">Task</span> <span className="sm:hidden">Task</span>
@@ -211,7 +211,7 @@ export default async function DashboardPage() {
) : ( ) : (
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">OPEN</span> <span className="font-mono text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">OPEN</span>
)} )}
<Link href="/tasks" className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"> <Link href="/tasks" className="flex items-center font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition">
CLAIM CLAIM
</Link> </Link>
</div> </div>
+23 -24
View File
@@ -101,17 +101,15 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
async function handleApprove(memberId: string) { async function handleApprove(memberId: string) {
const result = await approveMember(memberId) const result = await approveMember(memberId)
if (!result.error) { if (result?.error) { toast.error(result.error); return }
setMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'current', approved: true } : m)) setMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'current', approved: true } : m))
}
} }
async function handleRemove(memberId: string) { async function handleRemove(memberId: string) {
if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return
const result = await removeMember(memberId) const result = await removeMember(memberId)
if (!result.error) { if (result?.error) { toast.error(result.error); return }
setMembers(prev => prev.filter(m => m.id !== memberId)) setMembers(prev => prev.filter(m => m.id !== memberId))
}
} }
function openEdit(m: Member) { function openEdit(m: Member) {
@@ -133,32 +131,32 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
<> <>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Full Name *</label> <label htmlFor="member-display-name" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Full Name *</label>
<input type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} <input id="member-display-name" type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email *</label> <label htmlFor="member-email" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email *</label>
<input type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} <input id="member-email" type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label> <label htmlFor="member-phone" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} <input id="member-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label> <label htmlFor="member-handle" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
<input type="text" value={form.handle} placeholder="@username" onChange={e => setForm(f => ({ ...f, handle: e.target.value }))} <input id="member-handle" type="text" value={form.handle} placeholder="@username" onChange={e => setForm(f => ({ ...f, handle: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tier</label> <label htmlFor="member-tier" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tier</label>
<select value={form.tier} onChange={e => setForm(f => ({ ...f, tier: e.target.value }))} <select id="member-tier" value={form.tier} onChange={e => setForm(f => ({ ...f, tier: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="plus">Plus</option> <option value="plus">Plus</option>
<option value="basic">Basic</option> <option value="basic">Basic</option>
@@ -167,8 +165,8 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
</select> </select>
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Role</label> <label htmlFor="member-role" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Role</label>
<select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))} <select id="member-role" value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="member">Member</option> <option value="member">Member</option>
<option value="board">Board</option> <option value="board">Board</option>
@@ -179,8 +177,8 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Joined At</label> <label htmlFor="member-joined-at" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Joined At</label>
<input type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: e.target.value }))} <input id="member-joined-at" type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
</div> </div>
<div className="flex items-center gap-3 pt-6"> <div className="flex items-center gap-3 pt-6">
@@ -238,6 +236,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
</svg> </svg>
<input <input
type="text" type="text"
aria-label="Search members"
placeholder="Search..." placeholder="Search..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
@@ -326,20 +325,20 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
{m.status === 'unverified' && ( {m.status === 'unverified' && (
<button <button
onClick={() => handleApprove(m.id)} onClick={() => handleApprove(m.id)}
className="font-mono text-[10px] border border-primary/30 text-primary bg-primary/5 px-2 py-0.5 rounded hover:bg-primary/10 transition" className="font-mono text-[10px] border border-primary/30 text-primary bg-primary/5 px-3 py-2 min-h-[44px] rounded hover:bg-primary/10 transition"
> >
APPROVE APPROVE
</button> </button>
)} )}
<button <button
onClick={() => openEdit(m)} onClick={() => openEdit(m)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition" className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
> >
EDIT EDIT
</button> </button>
<button <button
onClick={() => handleRemove(m.id)} onClick={() => handleRemove(m.id)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-red-300 hover:text-red-600 transition" className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-red-300 hover:text-red-600 transition"
> >
REMOVE REMOVE
</button> </button>
@@ -347,7 +346,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
<select <select
defaultValue="" defaultValue=""
onChange={e => { makeAreaLead(m.id, e.target.value); e.currentTarget.value = '' }} onChange={e => { makeAreaLead(m.id, e.target.value); e.currentTarget.value = '' }}
className="font-mono text-[10px] border border-border px-1.5 py-0.5 rounded bg-background text-muted-foreground hover:border-primary hover:text-primary transition focus:outline-none" className="font-mono text-[10px] border border-border px-2 py-2 min-h-[44px] rounded bg-background text-muted-foreground hover:border-primary hover:text-primary transition focus:outline-none"
title="Assign this member as an area lead" title="Assign this member as an area lead"
> >
<option value="">+ area lead</option> <option value="">+ area lead</option>
+5 -5
View File
@@ -397,7 +397,7 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
{canManageAcl && ( {canManageAcl && (
<button <button
onClick={() => setShowAcl(v => !v)} onClick={() => setShowAcl(v => !v)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition" className="flex items-center font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
title="Who can access this secret" title="Who can access this secret"
> >
Access Access
@@ -406,13 +406,13 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
<button <button
onClick={reveal} onClick={reveal}
disabled={loading} disabled={loading}
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition" className="flex items-center gap-1 font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
title={revealed ? 'Hide' : 'Reveal'} title={revealed ? 'Hide' : 'Reveal'}
> >
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />} {loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
{revealed ? 'Hide' : 'Reveal'} {revealed ? 'Hide' : 'Reveal'}
</button> </button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete"> <button onClick={handleDelete} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-red-500 transition" title="Delete">
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
@@ -497,10 +497,10 @@ function KbEntryRow({
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility} {VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
</span> </span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button onClick={handleEdit} className="text-muted-foreground hover:text-primary transition p-1" title="Edit"> <button onClick={handleEdit} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-primary transition" title="Edit">
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete"> <button onClick={handleDelete} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-red-500 transition" title="Delete">
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
+17 -18
View File
@@ -101,16 +101,15 @@ export function PaymentsClient({ payments: initialPayments, members, integration
async function handleLink(paymentId: string, memberId: string) { async function handleLink(paymentId: string, memberId: string) {
setLoading(true) setLoading(true)
const result = await linkPaymentToMember(paymentId, memberId) const result = await linkPaymentToMember(paymentId, memberId)
if (!result.error) { if (result?.error) { toast.error(result.error); setLoading(false); return }
const linkedMember = members.find(m => m.id === memberId) const linkedMember = members.find(m => m.id === memberId)
setPayments(prev => prev.map(p => p.id === paymentId ? { setPayments(prev => prev.map(p => p.id === paymentId ? {
...p, ...p,
member_id: memberId, member_id: memberId,
link_status: 'linked', link_status: 'linked',
space_members: linkedMember ? { display_name: linkedMember.display_name } : null, space_members: linkedMember ? { display_name: linkedMember.display_name } : null,
} : p)) } : p))
setLinkingPayment(null) setLinkingPayment(null)
}
setLoading(false) setLoading(false)
} }
@@ -273,29 +272,29 @@ export function PaymentsClient({ payments: initialPayments, members, integration
<form onSubmit={handleLogCash} className="p-6 space-y-4"> <form onSubmit={handleLogCash} className="p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Amount *</label> <label htmlFor="cash-amount" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Amount *</label>
<input type="number" step="0.01" min="0" required value={cashForm.amount} <input id="cash-amount" type="number" step="0.01" min="0" required value={cashForm.amount}
onChange={e => setCashForm(f => ({ ...f, amount: e.target.value }))} onChange={e => setCashForm(f => ({ ...f, amount: e.target.value }))}
placeholder="50.00" placeholder="50.00"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Date</label> <label htmlFor="cash-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Date</label>
<input type="date" value={cashForm.date} <input id="cash-date" type="date" value={cashForm.date}
onChange={e => setCashForm(f => ({ ...f, date: e.target.value }))} onChange={e => setCashForm(f => ({ ...f, date: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
</div> </div>
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Note / From *</label> <label htmlFor="cash-from-note" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Note / From *</label>
<input type="text" required value={cashForm.from_note} <input id="cash-from-note" type="text" required value={cashForm.from_note}
onChange={e => setCashForm(f => ({ ...f, from_note: e.target.value }))} onChange={e => setCashForm(f => ({ ...f, from_note: e.target.value }))}
placeholder="John Smith — March dues" placeholder="John Smith — March dues"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Link to Member</label> <label htmlFor="cash-member" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Link to Member</label>
<select value={cashForm.member_id} onChange={e => setCashForm(f => ({ ...f, member_id: e.target.value }))} <select id="cash-member" value={cashForm.member_id} onChange={e => setCashForm(f => ({ ...f, member_id: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="">— Unlinked —</option> <option value="">— Unlinked —</option>
{members.map(m => <option key={m.id} value={m.id}>{m.display_name} ({m.email})</option>)} {members.map(m => <option key={m.id} value={m.id}>{m.display_name} ({m.email})</option>)}
+16 -15
View File
@@ -3,6 +3,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions' import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions'
import { toast } from 'sonner'
import { useConfirm } from '@/components/ui/confirm' import { useConfirm } from '@/components/ui/confirm'
import type { Tables } from '@/types/database' import type { Tables } from '@/types/database'
import { PageTitle } from '@/components/ui/page-title' import { PageTitle } from '@/components/ui/page-title'
@@ -55,15 +56,15 @@ export function ProjectsClient({
async function handleStatusChange(projectId: string, newStatus: string) { async function handleStatusChange(projectId: string, newStatus: string) {
const result = await updateProjectStatus(projectId, newStatus) const result = await updateProjectStatus(projectId, newStatus)
if (!result.error) { if (result?.error) { toast.error(result.error); return }
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, status: newStatus } : p)) setProjects(prev => prev.map(p => p.id === projectId ? { ...p, status: newStatus } : p))
}
} }
async function handleDelete(projectId: string) { async function handleDelete(projectId: string) {
if (!(await confirm({ title: 'Delete project', description: 'This project will be permanently removed.', confirmText: 'Delete', destructive: true }))) return if (!(await confirm({ title: 'Delete project', description: 'This project will be permanently removed.', confirmText: 'Delete', destructive: true }))) return
const result = await deleteProject(projectId) const result = await deleteProject(projectId)
if (!result.error) setProjects(prev => prev.filter(p => p.id !== projectId)) if (result?.error) { toast.error(result.error); return }
setProjects(prev => prev.filter(p => p.id !== projectId))
} }
return ( return (
@@ -89,7 +90,7 @@ export function ProjectsClient({
<EmptyContent> <EmptyContent>
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 min-h-[44px] rounded hover:bg-primary/90 transition"
> >
<Plus className="w-3.5 h-3.5" /> New Project <Plus className="w-3.5 h-3.5" /> New Project
</button> </button>
@@ -172,34 +173,34 @@ export function ProjectsClient({
</div> </div>
<form onSubmit={handleCreate} className="p-6 space-y-4"> <form onSubmit={handleCreate} className="p-6 space-y-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label> <label htmlFor="project-title" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
<input type="text" required autoFocus value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} <input id="project-title" type="text" required autoFocus value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
placeholder="CNC Controller Upgrade" placeholder="CNC Controller Upgrade"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label> <label htmlFor="project-description" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} <textarea id="project-description" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" /> rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label> <label htmlFor="project-area" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
<select value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))} <select id="project-area" value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option value="">No area</option> <option value="">No area</option>
{AREAS.map(a => <option key={a}>{a}</option>)} {AREAS.map(a => <option key={a}>{a}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label> <label htmlFor="project-due-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
<input type="date" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))} <input id="project-due-date" type="date" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" /> className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
</div> </div>
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags (comma-separated)</label> <label htmlFor="project-tags" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags (comma-separated)</label>
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} <input id="project-tags" type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="electronics, hardware, cnc" placeholder="electronics, hardware, cnc"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" /> className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
</div> </div>
+27 -23
View File
@@ -3,6 +3,7 @@
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import { Plus, X, CheckCircle2 } from 'lucide-react' import { Plus, X, CheckCircle2 } from 'lucide-react'
import { createTask, claimTask, completeTask, deleteTask } from '@/lib/actions' import { createTask, claimTask, completeTask, deleteTask } from '@/lib/actions'
import { toast } from 'sonner'
import type { Tables } from '@/types/database' import type { Tables } from '@/types/database'
import { PageTitle } from '@/components/ui/page-title' import { PageTitle } from '@/components/ui/page-title'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
@@ -80,28 +81,25 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
async function handleClaim(taskId: string) { async function handleClaim(taskId: string) {
const result = await claimTask(taskId) const result = await claimTask(taskId)
if (!result.error) { if (result?.error) { toast.error(result.error); return }
const member = members.find(m => m.user_id === currentUserId) const member = members.find(m => m.user_id === currentUserId)
setTasks(prev => prev.map(t => setTasks(prev => prev.map(t =>
t.id === taskId t.id === taskId
? { ...t, status: 'claimed', claimed_by: currentUserId, claimed_by_name: member?.display_name } ? { ...t, status: 'claimed', claimed_by: currentUserId, claimed_by_name: member?.display_name }
: t : t
)) ))
}
} }
async function handleComplete(taskId: string) { async function handleComplete(taskId: string) {
const result = await completeTask(taskId) const result = await completeTask(taskId)
if (!result.error) { if (result?.error) { toast.error(result.error); return }
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'completed' } : t)) setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'completed' } : t))
}
} }
async function handleDelete(taskId: string) { async function handleDelete(taskId: string) {
const result = await deleteTask(taskId) const result = await deleteTask(taskId)
if (!result.error) { if (result?.error) { toast.error(result.error); return }
setTasks(prev => prev.filter(t => t.id !== taskId)) setTasks(prev => prev.filter(t => t.id !== taskId))
}
} }
const tabCounts = { const tabCounts = {
@@ -201,7 +199,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
{task.status === 'open' && ( {task.status === 'open' && (
<button <button
onClick={() => handleClaim(task.id)} onClick={() => handleClaim(task.id)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap" className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
> >
CLAIM CLAIM
</button> </button>
@@ -210,7 +208,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
{(task.status === 'claimed' || task.status === 'in_progress') && task.claimed_by === currentUserId && ( {(task.status === 'claimed' || task.status === 'in_progress') && task.claimed_by === currentUserId && (
<button <button
onClick={() => handleComplete(task.id)} onClick={() => handleComplete(task.id)}
className="font-mono text-[10px] border border-green-200 text-green-700 px-2 py-0.5 rounded hover:bg-green-50 transition whitespace-nowrap" className="font-mono text-[10px] border border-green-200 text-green-700 px-3 py-2 min-h-[44px] rounded hover:bg-green-50 transition whitespace-nowrap"
> >
DONE DONE
</button> </button>
@@ -218,7 +216,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<button <button
onClick={() => handleDelete(task.id)} onClick={() => handleDelete(task.id)}
className="text-muted-foreground hover:text-destructive transition flex-shrink-0" className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-destructive transition flex-shrink-0"
aria-label="Delete task" aria-label="Delete task"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
@@ -258,8 +256,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<form onSubmit={handleCreate} className="p-6 space-y-4"> <form onSubmit={handleCreate} className="p-6 space-y-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label> <label htmlFor="task-title" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
<input <input
id="task-title"
type="text" type="text"
value={form.title} value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
@@ -271,8 +270,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label> <label htmlFor="task-description" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
<textarea <textarea
id="task-description"
value={form.description} value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="Optional details..." placeholder="Optional details..."
@@ -283,8 +283,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label> <label htmlFor="task-type" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
<select <select
id="task-type"
value={form.type} value={form.type}
onChange={e => setForm(f => ({ ...f, type: e.target.value }))} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
@@ -294,8 +295,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
</select> </select>
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label> <label htmlFor="task-area" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
<select <select
id="task-area"
value={form.area} value={form.area}
onChange={e => setForm(f => ({ ...f, area: e.target.value }))} onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
@@ -308,8 +310,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label> <label htmlFor="task-recurrence" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label>
<select <select
id="task-recurrence"
value={form.recurrence} value={form.recurrence}
onChange={e => setForm(f => ({ ...f, recurrence: e.target.value }))} onChange={e => setForm(f => ({ ...f, recurrence: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
@@ -318,8 +321,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
</select> </select>
</div> </div>
<div> <div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label> <label htmlFor="task-due-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
<input <input
id="task-due-date"
type="date" type="date"
value={form.due_date} value={form.due_date}
onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
+15
View File
@@ -125,3 +125,18 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/*
* Keyboard focus visibility (WCAG 2.4.7). Many form controls and links use
* `focus:outline-none` with only a 1px border color shift, which is not a
* perceivable focus indicator. This rule is intentionally unlayered so it
* wins over Tailwind's utilities layer without !important. Scoped to form
* controls and links; buttons already get a focus-visible ring from
* components/ui/button. :where() keeps specificity at 0 so a component can
* still opt out with its own rule.
*/
:where(input, select, textarea, a[href]):focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
border-radius: 2px;
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function OpsAclEditor({ entityType, entityId, options, initial }: Props)
key={o.value} key={o.value}
type="button" type="button"
onClick={() => toggle(o.value, !on)} onClick={() => toggle(o.value, !on)}
className={`font-mono text-[10px] px-2 py-1 rounded border transition ${ className={`flex items-center font-mono text-[10px] px-3 py-2 min-h-[44px] rounded border transition ${
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary' on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
}`} }`}
> >