Skip to content

Commit 17e43f4

Browse files
gmoonclaude
andcommitted
Fix UI/UX audit findings: accessibility, tokens, hooks, design system page
- Improve textMuted contrast (alpha 0.45 → 0.55) for WCAG AA compliance - Normalize hex color tokens to HSL; add fontSizes scale - Add injectGlobalStyles() for focus-visible rings and reduced-motion - Add useReducedMotion() and useHoverLift() hooks; deprecate hoverLiftHandlers - Add ctaLink prop to Header; add data-fzui and 44px touch target to CopyButton - Migrate ProjectCard and GettingStartedPage to useHoverLift hook - Add reduced-motion overrides to Hero keyframes - Add /design-system living style guide page - Embed full design system documentation as JSDoc in index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 869b946 commit 17e43f4

12 files changed

Lines changed: 758 additions & 30 deletions

File tree

packages/ui/src/components/CopyButton.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { useState, useEffect, useRef } from 'react'
22
import { colors, fonts } from '../tokens'
3+
import { injectGlobalStyles } from '../styles'
34

45
export function CopyButton({ text }: { text: string }) {
6+
injectGlobalStyles()
57
const [copied, setCopied] = useState(false)
68
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
79

@@ -23,6 +25,7 @@ export function CopyButton({ text }: { text: string }) {
2325
return (
2426
<button
2527
onClick={handleCopy}
28+
data-fzui
2629
style={{
2730
position: 'absolute',
2831
top: '0.5rem',
@@ -32,6 +35,10 @@ export function CopyButton({ text }: { text: string }) {
3235
border: 'none',
3336
borderRadius: '4px',
3437
padding: '0.3rem 0.6rem',
38+
minHeight: '2.75rem',
39+
display: 'inline-flex',
40+
alignItems: 'center',
41+
justifyContent: 'center',
3542
fontSize: '0.75rem',
3643
fontFamily: fonts.system,
3744
cursor: 'pointer',

packages/ui/src/components/Footer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { colors, fonts } from '../tokens'
2+
import { injectGlobalStyles } from '../styles'
23

34
export interface FooterProps {
45
repoUrl?: string
@@ -23,8 +24,9 @@ const styles = {
2324
}
2425

2526
export function Footer({ repoUrl, repoLabel, orgName }: FooterProps) {
27+
injectGlobalStyles()
2628
return (
27-
<footer style={styles.footer}>
29+
<footer style={styles.footer} data-fzui>
2830
<p>
2931
&copy; {new Date().getFullYear()} {orgName ?? 'Forkzero'}.{' '}
3032
{repoUrl ? (

packages/ui/src/components/Header.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { colors, fonts } from '../tokens'
2+
import { injectGlobalStyles } from '../styles'
23

34
export interface HeaderLink {
45
label: string
@@ -9,6 +10,7 @@ export interface HeaderProps {
910
minimal?: boolean
1011
navLinks?: HeaderLink[]
1112
githubUrl?: string
13+
ctaLink?: { label: string; href: string }
1214
}
1315

1416
export interface PoweredByHeaderProps {
@@ -54,6 +56,18 @@ const styles = {
5456
fontFamily: fonts.system,
5557
transition: 'color 0.2s',
5658
},
59+
ctaLink: {
60+
color: colors.accentBlue,
61+
textDecoration: 'none',
62+
fontSize: '0.9rem',
63+
fontFamily: fonts.system,
64+
fontWeight: 600,
65+
background: 'rgba(0, 255, 255, 0.1)',
66+
padding: '0.4rem 0.8rem',
67+
borderRadius: '6px',
68+
border: `1px solid ${colors.accentBlue}`,
69+
transition: 'background 0.2s',
70+
},
5771
navLinkGh: {
5872
color: '#ffffff',
5973
textDecoration: 'none',
@@ -67,9 +81,10 @@ const styles = {
6781
},
6882
}
6983

70-
export function Header({ minimal, navLinks, githubUrl }: HeaderProps) {
84+
export function Header({ minimal, navLinks, githubUrl, ctaLink }: HeaderProps) {
85+
injectGlobalStyles()
7186
return (
72-
<header style={styles.header}>
87+
<header style={styles.header} data-fzui>
7388
<a href="/" style={styles.logo}>
7489
<span style={styles.logoBold}>FORK</span>
7590
<span style={styles.logoThin}>ZERO</span>
@@ -81,6 +96,11 @@ export function Header({ minimal, navLinks, githubUrl }: HeaderProps) {
8196
{link.label}
8297
</a>
8398
))}
99+
{ctaLink && (
100+
<a href={ctaLink.href} style={styles.ctaLink}>
101+
{ctaLink.label}
102+
</a>
103+
)}
84104
{githubUrl && (
85105
<a href={githubUrl} target="_blank" rel="noopener noreferrer" style={styles.navLinkGh}>
86106
GitHub
@@ -93,8 +113,9 @@ export function Header({ minimal, navLinks, githubUrl }: HeaderProps) {
93113
}
94114

95115
export function PoweredByHeader({ poweredByUrl, poweredByLabel }: PoweredByHeaderProps) {
116+
injectGlobalStyles()
96117
return (
97-
<header style={{ ...styles.header, padding: '0.75rem 2rem' }}>
118+
<header style={{ ...styles.header, padding: '0.75rem 2rem' }} data-fzui>
98119
<a href="/" style={styles.logo}>
99120
<span style={styles.logoBold}>FORK</span>
100121
<span style={styles.logoThin}>ZERO</span>

packages/ui/src/index.ts

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,161 @@
1-
export { colors, shadows, radius, fonts, gradient } from './tokens'
1+
/**
2+
* @forkzero/ui — Shared design system for Forkzero applications.
3+
*
4+
* ## Quick start
5+
*
6+
* ```ts
7+
* import {
8+
* colors, fonts, fontSizes, shadows, radius, gradient,
9+
* pageWrapper, cardBase, codeBlock, inlineCode, sectionTitle,
10+
* containerNarrow, containerWide,
11+
* useHoverLift, useReducedMotion, injectGlobalStyles,
12+
* Header, PoweredByHeader, Footer, CopyButton,
13+
* } from '@forkzero/ui'
14+
* ```
15+
*
16+
* ## Tokens
17+
*
18+
* ### Colors (`colors`)
19+
*
20+
* | Token | Purpose |
21+
* |-----------------|--------------------------------------------------|
22+
* | `bgPrimary` | Main page background |
23+
* | `bgSecondary` | Slightly darker background (page wrapper default) |
24+
* | `bgCard` | Card / elevated surface background |
25+
* | `bgDeep` | Deepest background (code blocks, inset areas) |
26+
* | `bgGlass` | Semi-transparent header backdrop |
27+
* | `textPrimary` | Primary text (headings, labels) |
28+
* | `textOnAccent` | Text on accent-colored backgrounds |
29+
* | `textSecondary` | Body text, descriptions |
30+
* | `textMuted` | Captions, hints, metadata — WCAG AA compliant |
31+
* | `borderColor` | Subtle borders on cards and dividers |
32+
* | `accentBlue` | Primary accent — links, CTAs, focus rings |
33+
* | `accentGreen` | Success states, available badges |
34+
* | `accentYellow` | Warnings, requirement layer |
35+
* | `accentRed` | Errors, destructive actions |
36+
* | `accentPurple` | Thesis layer, secondary accent |
37+
*
38+
* Always use tokens — never hardcode color values. For opacity variants
39+
* use template literals: `` `${colors.accentGreen}14` `` (hex alpha suffix).
40+
*
41+
* ### Typography (`fonts`, `fontSizes`)
42+
*
43+
* - `fonts.system` — UI text, headings, body copy
44+
* - `fonts.mono` — Code blocks, technical labels, CLI output
45+
* - `fontSizes` — Scale from `xs` (0.75rem) to `3xl` (2rem)
46+
*
47+
* ### Shadows (`shadows`)
48+
*
49+
* - `shadows.sm` — Subtle cards, badges
50+
* - `shadows.md` — Default card elevation (used by `cardBase`)
51+
* - `shadows.lg` — Hover-lifted / emphasized cards
52+
*
53+
* ### Other
54+
*
55+
* - `radius` — Standard border radius (8px)
56+
* - `gradient` — Hero/section gradient background
57+
*
58+
* ## Style objects
59+
*
60+
* Pre-composed `CSSProperties` objects. Spread and extend as needed:
61+
*
62+
* ```ts
63+
* const myCard = { ...cardBase, padding: '1.5rem', marginBottom: '1rem' }
64+
* ```
65+
*
66+
* | Object | Use for |
67+
* |-------------------|--------------------------------------------|
68+
* | `pageWrapper` | Top-level page `<div>` (bg, min-height, font) |
69+
* | `cardBase` | Any card surface (bg, radius, shadow, border) |
70+
* | `codeBlock` | `<pre>` code blocks |
71+
* | `inlineCode` | `<code>` inline snippets |
72+
* | `sectionTitle` | `<h2>` section headings |
73+
* | `containerNarrow` | Centered content column (max 800px) |
74+
* | `containerWide` | Centered content column (max 1200px) |
75+
*
76+
* ## Hooks
77+
*
78+
* ### `useHoverLift(defaultShadow?)`
79+
*
80+
* Returns `{ style, handlers }`. Apply both to a card/link element for a
81+
* lift-on-hover effect. Automatically disabled when the user prefers
82+
* reduced motion. Replaces the deprecated `hoverLiftHandlers` function.
83+
*
84+
* ```tsx
85+
* function Card() {
86+
* const { style, handlers } = useHoverLift(shadows.md)
87+
* return <div style={{ ...cardBase, ...style }} {...handlers}>...</div>
88+
* }
89+
* ```
90+
*
91+
* For elements rendered in a loop, wrap in a component so the hook is
92+
* called per-instance (hooks cannot be called conditionally or in loops).
93+
*
94+
* ### `useReducedMotion()`
95+
*
96+
* Returns `boolean` — `true` when the user's OS prefers reduced motion.
97+
* Use to conditionally skip custom animations.
98+
*
99+
* ### `injectGlobalStyles()`
100+
*
101+
* Call once (idempotent) to inject global CSS:
102+
* - `:focus-visible` ring (2px solid accentBlue, 2px offset) on elements
103+
* inside `[data-fzui]` containers
104+
* - `prefers-reduced-motion: reduce` suppression of animations/transitions
105+
* inside `[data-fzui]` containers
106+
*
107+
* All built-in components (Header, Footer, CopyButton) call this
108+
* automatically. When building custom components, call it and add
109+
* `data-fzui` to your root element.
110+
*
111+
* ## Components
112+
*
113+
* ### `<Header>`
114+
*
115+
* Props:
116+
* - `navLinks?: { label: string; href: string }[]` — nav items
117+
* - `githubUrl?: string` — renders a GitHub pill button
118+
* - `ctaLink?: { label: string; href: string }` — accent CTA pill
119+
* between nav links and GitHub button
120+
* - `minimal?: boolean` — logo only, no nav
121+
*
122+
* ### `<PoweredByHeader>`
123+
*
124+
* Compact header for embedded/sub-apps.
125+
* Props: `poweredByUrl?`, `poweredByLabel?`
126+
*
127+
* ### `<Footer>`
128+
*
129+
* Props: `repoUrl?`, `repoLabel?`, `orgName?`
130+
*
131+
* ### `<CopyButton>`
132+
*
133+
* Props: `text: string` — the value copied to clipboard.
134+
* Position inside a `position: relative` container.
135+
* Has a 44px min touch target for mobile accessibility.
136+
*
137+
* ## Accessibility checklist
138+
*
139+
* - Add `data-fzui` to root elements of custom components
140+
* - Call `injectGlobalStyles()` in component render (idempotent)
141+
* - Use `useHoverLift` (not `hoverLiftHandlers`) for motion-safe hover
142+
* - Buttons/links must be at least 44px in the smallest dimension
143+
* - Use `textMuted` (not lower-alpha values) for de-emphasized text
144+
*
145+
* ## Branding
146+
*
147+
* - Write "Forkzero" in prose, never "ForkZero"
148+
* - Logo renders as "FORK" (bold) + "ZERO" (thin) via Header component
149+
*
150+
* ## Do not
151+
*
152+
* - Hardcode color hex/hsl values — always use `colors.*`
153+
* - Use `hoverLiftHandlers` — it is deprecated, use `useHoverLift`
154+
* - Skip `data-fzui` on component roots — focus rings won't work
155+
* - Set touch targets below 44px
156+
*/
157+
158+
export { colors, shadows, radius, fonts, fontSizes, gradient } from './tokens'
2159
export {
3160
codeBlock,
4161
inlineCode,
@@ -10,6 +167,9 @@ export {
10167
LATTICE_LAYERS,
11168
LATTICE_EDGES,
12169
hoverLiftHandlers,
170+
useHoverLift,
171+
useReducedMotion,
172+
injectGlobalStyles,
13173
} from './styles'
14174
export { Header } from './components/Header'
15175
export type { HeaderLink, HeaderProps, PoweredByHeaderProps } from './components/Header'

packages/ui/src/styles.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState, useEffect } from 'react'
12
import type { CSSProperties, MouseEvent } from 'react'
23
import { colors, fonts, shadows, radius } from './tokens'
34

@@ -65,8 +66,76 @@ export const LATTICE_LAYERS = [
6566

6667
export const LATTICE_EDGES = ['supports', 'derives', 'satisfies'] as const
6768

68-
// --- Hover lift helper ---
69+
// --- Global styles injection ---
6970

71+
let globalStylesInjected = false
72+
73+
export function injectGlobalStyles(): void {
74+
if (typeof document === 'undefined' || globalStylesInjected) return
75+
globalStylesInjected = true
76+
77+
const style = document.createElement('style')
78+
style.setAttribute('data-fzui-global', '')
79+
style.textContent = `
80+
[data-fzui] :focus-visible {
81+
outline: 2px solid ${colors.accentBlue};
82+
outline-offset: 2px;
83+
}
84+
@media (prefers-reduced-motion: reduce) {
85+
[data-fzui] *,
86+
[data-fzui] *::before,
87+
[data-fzui] *::after {
88+
animation-duration: 0.01ms !important;
89+
animation-iteration-count: 1 !important;
90+
transition-duration: 0.01ms !important;
91+
}
92+
}
93+
`
94+
document.head.appendChild(style)
95+
}
96+
97+
// --- Reduced motion hook ---
98+
99+
export function useReducedMotion(): boolean {
100+
const [reduced, setReduced] = useState(() => {
101+
if (typeof window === 'undefined') return false
102+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
103+
})
104+
105+
useEffect(() => {
106+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
107+
const handler = (e: MediaQueryListEvent) => setReduced(e.matches)
108+
mq.addEventListener('change', handler)
109+
return () => mq.removeEventListener('change', handler)
110+
}, [])
111+
112+
return reduced
113+
}
114+
115+
// --- Hover lift hook ---
116+
117+
export function useHoverLift(defaultShadow: string = shadows.md) {
118+
const reducedMotion = useReducedMotion()
119+
const [hovered, setHovered] = useState(false)
120+
121+
const style: CSSProperties =
122+
hovered && !reducedMotion
123+
? { transform: 'translateY(-2px)', boxShadow: shadows.lg }
124+
: { transform: 'translateY(0)', boxShadow: defaultShadow }
125+
126+
const handlers = {
127+
onMouseEnter: () => setHovered(true),
128+
onMouseLeave: () => setHovered(false),
129+
}
130+
131+
return { style, handlers }
132+
}
133+
134+
// --- Hover lift helper (deprecated) ---
135+
136+
/**
137+
* @deprecated Use the `useHoverLift` hook instead. This function mutates the DOM directly.
138+
*/
70139
export function hoverLiftHandlers(defaultShadow: string) {
71140
return {
72141
onMouseEnter: (e: MouseEvent<HTMLElement>) => {

0 commit comments

Comments
 (0)