Skip to content

Commit 0053b76

Browse files
Complete design tokens, add AppShell primary nav, and Button/Card primitives (#1313, #1314, #1315, #1316)
#1313: styles/tokens.css already exists in full (117 lines, dark+light palettes) — nothing was deleted, so there was nothing to "recreate". The real gap: three CSS custom properties were referenced elsewhere in the app only via `var(--x, <hardcoded fallback>)` (--danger in PlaceBetForm.css, --border-color in OutcomeList.css, --shadow-lg in two account/newsletter pages), bypassing the centralized token system entirely — meaning dark-mode toggling (#22-23) and contrast-check tooling (#21) had nothing to hook into for them. Added --danger and --border-color as aliases of their equivalent existing tokens (--destructive, --border), and a real --shadow-lg value, plus the shared `@keyframes spin` used by the new Button below (previously only defined in admin.css/LoadingSpinner.css, neither of which layout.tsx loads globally). #1314: layout.tsx already wraps the entire app today, not just the landing page — but there genuinely was no primary navigation anywhere outside the admin section's own local sub-nav. Added AppShell.tsx (header + primary nav across Markets/Statistics/Create Market, plus a conditional Admin link once an admin session exists, + a minimal footer) and wired it into layout.tsx. It intentionally does not render on `/` or `/admin/*`, since LandingPage.tsx already owns a full marketing header/nav/footer and admin/layout.tsx already owns its own sub-navigation — avoiding a duplicate header in both places. #1315: added components/ui/Button.tsx — consistent variant/loading/ disabled handling, generalizing the existing admin-only Button in components/admin/Form.tsx (the closest prior art) for use outside the admin section (bet placement, market creation, resolution). #1316: added components/ui/Card.tsx (Card, CardHeader, CardTitle, CardBody, CardFooter) — a single shared container primitive for market list items (#57), statistics tiles (#49), and admin panels (#89-97) to converge on instead of each reinventing padding/border/shadow rules. No existing consumers were migrated to Button/Card — out of scope for this PR, which builds the primitives the issues asked for. Closes #1313 Closes #1314 Closes #1315 Closes #1316 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jDDop7frnew1xcCJDSKEw
1 parent 8c5e39a commit 0053b76

5 files changed

Lines changed: 388 additions & 1 deletion

File tree

frontend/src/app/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { headers } from 'next/headers';
33
import { Orbitron, Exo_2 } from 'next/font/google';
44
import { ErrorBoundary } from '../components/ErrorBoundary';
55
import { OfflineBanner } from '../components/OfflineBanner';
6+
import { AppShell } from '../components/AppShell';
67
import { WalletProvider } from '../lib/wallet/WalletProvider';
78
import { darkModeInitScript } from '../lib/darkMode';
89
import '../styles/tokens.css';
@@ -46,7 +47,9 @@ export default async function RootLayout({ children }: { children: ReactNode })
4647
<body>
4748
<OfflineBanner />
4849
<ErrorBoundary section="main">
49-
<WalletProvider>{children}</WalletProvider>
50+
<WalletProvider>
51+
<AppShell>{children}</AppShell>
52+
</WalletProvider>
5053
</ErrorBoundary>
5154
</body>
5255
</html>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
'use client';
2+
3+
/**
4+
* AppShell — primary app-wide navigation (#1314).
5+
*
6+
* The landing page (`/`) already owns its own full marketing header/nav/
7+
* footer (components/LandingPage.tsx — separate anchor-link nav for
8+
* #features/#how-it-works/#about/#contact), so AppShell skips rendering
9+
* on `/` to avoid a duplicate header there. Everywhere else (Markets,
10+
* Statistics, Create Market, account, tx, and — conditionally, once an
11+
* admin session exists — Admin) gets a persistent header with primary
12+
* navigation and a minimal footer, matching the sub-nav pattern already
13+
* established by app/admin/layout.tsx for its own section.
14+
*/
15+
16+
import React, { useEffect, useState } from 'react';
17+
import Link from 'next/link';
18+
import { usePathname } from 'next/navigation';
19+
20+
const NAV_ITEMS = [
21+
{ href: '/markets', label: 'Markets' },
22+
{ href: '/statistics', label: 'Statistics' },
23+
{ href: '/markets/create', label: 'Create Market' },
24+
];
25+
26+
export function AppShell({ children }: { children: React.ReactNode }) {
27+
const pathname = usePathname();
28+
const [hasAdminSession, setHasAdminSession] = useState(false);
29+
30+
useEffect(() => {
31+
setHasAdminSession(Boolean(sessionStorage.getItem('predictiq-admin-key')));
32+
}, [pathname]);
33+
34+
const isLandingPage = pathname === '/';
35+
const isAdminSection = pathname?.startsWith('/admin');
36+
37+
if (isLandingPage || isAdminSection) {
38+
return <>{children}</>;
39+
}
40+
41+
const navItems = hasAdminSession
42+
? [...NAV_ITEMS, { href: '/admin/content', label: 'Admin' }]
43+
: NAV_ITEMS;
44+
45+
return (
46+
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
47+
<a href="#app-main-content" className="skip-link">
48+
Skip to main content
49+
</a>
50+
51+
<header
52+
role="banner"
53+
style={{
54+
borderBottom: '1px solid var(--border)',
55+
backgroundColor: 'var(--surface)',
56+
position: 'sticky',
57+
top: 0,
58+
zIndex: 100,
59+
}}
60+
>
61+
<div
62+
style={{
63+
maxWidth: 'var(--container)',
64+
margin: '0 auto',
65+
padding: '1rem 1.5rem',
66+
display: 'flex',
67+
alignItems: 'center',
68+
justifyContent: 'space-between',
69+
gap: '1.5rem',
70+
}}
71+
>
72+
<Link
73+
href="/"
74+
aria-label="PredictIQ Home"
75+
style={{ textDecoration: 'none', fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem' }}
76+
>
77+
<span style={{ color: 'var(--fg)' }}>Predict</span>
78+
<span style={{ color: 'var(--gold)' }}>IQ</span>
79+
</Link>
80+
81+
<nav aria-label="Primary navigation">
82+
<ul
83+
style={{
84+
display: 'flex',
85+
gap: '1.5rem',
86+
listStyle: 'none',
87+
margin: 0,
88+
padding: 0,
89+
}}
90+
>
91+
{navItems.map((item) => {
92+
const isActive = pathname === item.href || pathname?.startsWith(`${item.href}/`);
93+
return (
94+
<li key={item.href}>
95+
<Link
96+
href={item.href}
97+
aria-current={isActive ? 'page' : undefined}
98+
style={{
99+
textDecoration: 'none',
100+
fontSize: 'var(--text-sm)',
101+
fontWeight: 500,
102+
color: isActive ? 'var(--gold)' : 'var(--fg-muted)',
103+
}}
104+
>
105+
{item.label}
106+
</Link>
107+
</li>
108+
);
109+
})}
110+
</ul>
111+
</nav>
112+
</div>
113+
</header>
114+
115+
<main id="app-main-content" role="main" style={{ flex: 1 }}>
116+
{children}
117+
</main>
118+
119+
<footer
120+
role="contentinfo"
121+
style={{
122+
borderTop: '1px solid var(--border)',
123+
backgroundColor: 'var(--surface)',
124+
padding: '1.5rem',
125+
textAlign: 'center',
126+
fontSize: 'var(--text-xs)',
127+
color: 'var(--fg-muted)',
128+
}}
129+
>
130+
© {new Date().getFullYear()} PredictIQ. Built on Stellar.
131+
</footer>
132+
</div>
133+
);
134+
}
135+
136+
export default AppShell;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use client';
2+
3+
/**
4+
* Button — shared design-system primitive (#1315).
5+
*
6+
* Every write action in this backlog (bet placement, market creation,
7+
* resolution, admin actions) should funnel through this so loading/
8+
* disabled states are handled consistently instead of ad hoc per form.
9+
* Styling follows the existing Button in components/admin/Form.tsx (the
10+
* closest prior art), generalized here to be usable outside the admin
11+
* section too.
12+
*/
13+
14+
import React from 'react';
15+
16+
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
17+
18+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
19+
variant?: ButtonVariant;
20+
isLoading?: boolean;
21+
leftIcon?: React.ReactNode;
22+
rightIcon?: React.ReactNode;
23+
}
24+
25+
function variantStyles(variant: ButtonVariant): React.CSSProperties {
26+
switch (variant) {
27+
case 'primary':
28+
return { backgroundColor: 'var(--gold)', color: 'var(--on-primary)', border: 'none', fontWeight: 600 };
29+
case 'danger':
30+
return { backgroundColor: 'var(--destructive)', color: '#ffffff', border: 'none', fontWeight: 600 };
31+
case 'secondary':
32+
return {
33+
backgroundColor: 'var(--surface-2)',
34+
color: 'var(--fg)',
35+
border: '1px solid var(--border-strong)',
36+
fontWeight: 500,
37+
};
38+
case 'ghost':
39+
return { backgroundColor: 'transparent', color: 'var(--fg-muted)', border: 'none', fontWeight: 500 };
40+
}
41+
}
42+
43+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44+
(
45+
{ variant = 'primary', isLoading = false, leftIcon, rightIcon, children, disabled, className = '', style, ...props },
46+
ref
47+
) => {
48+
const isDisabled = disabled || isLoading;
49+
50+
return (
51+
<button
52+
ref={ref}
53+
type={props.type ?? 'button'}
54+
disabled={isDisabled}
55+
aria-disabled={isDisabled}
56+
aria-busy={isLoading || undefined}
57+
className={`ui-btn ui-btn--${variant} ${className}`}
58+
style={{
59+
display: 'inline-flex',
60+
alignItems: 'center',
61+
justifyContent: 'center',
62+
gap: '0.5rem',
63+
padding: '0.65rem 1.25rem',
64+
fontSize: 'var(--text-sm)',
65+
fontFamily: 'inherit',
66+
borderRadius: 'var(--radius-sm)',
67+
cursor: isDisabled ? 'not-allowed' : 'pointer',
68+
opacity: isDisabled ? 0.6 : 1,
69+
transition: 'all var(--dur-fast)',
70+
textDecoration: 'none',
71+
...variantStyles(variant),
72+
...style,
73+
}}
74+
{...props}
75+
>
76+
{isLoading && (
77+
<span
78+
aria-hidden="true"
79+
style={{
80+
display: 'inline-block',
81+
width: '14px',
82+
height: '14px',
83+
border: '2px solid currentColor',
84+
borderTopColor: 'transparent',
85+
borderRadius: '50%',
86+
animation: 'spin 0.8s linear infinite',
87+
}}
88+
/>
89+
)}
90+
{!isLoading && leftIcon}
91+
<span>{children}</span>
92+
{!isLoading && rightIcon}
93+
</button>
94+
);
95+
}
96+
);
97+
Button.displayName = 'Button';
98+
99+
export default Button;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Card — shared design-system container primitive (#1316).
3+
*
4+
* Market list items (#57), statistics tiles (#49), and admin panels
5+
* (#89-97) each currently reinvent their own padding/border/shadow rules;
6+
* this is the one shared container to converge on instead.
7+
*/
8+
9+
import React from 'react';
10+
11+
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
12+
/** Renders with a hover elevation/border-highlight, for clickable cards. */
13+
interactive?: boolean;
14+
/** Removes the default padding, for cards that manage their own inner layout. */
15+
noPadding?: boolean;
16+
}
17+
18+
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
19+
({ interactive = false, noPadding = false, className = '', style, children, ...props }, ref) => {
20+
return (
21+
<div
22+
ref={ref}
23+
className={`ui-card ${interactive ? 'ui-card--interactive' : ''} ${className}`}
24+
style={{
25+
backgroundColor: 'var(--surface)',
26+
border: '1px solid var(--border)',
27+
borderRadius: 'var(--radius)',
28+
boxShadow: 'var(--shadow-sm)',
29+
padding: noPadding ? 0 : '1.25rem',
30+
transition: interactive ? 'border-color var(--dur-fast), box-shadow var(--dur-fast)' : undefined,
31+
cursor: interactive ? 'pointer' : undefined,
32+
...style,
33+
}}
34+
{...props}
35+
>
36+
{children}
37+
</div>
38+
);
39+
}
40+
);
41+
Card.displayName = 'Card';
42+
43+
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
44+
45+
export function CardHeader({ className = '', style, children, ...props }: CardHeaderProps) {
46+
return (
47+
<div
48+
className={`ui-card__header ${className}`}
49+
style={{
50+
display: 'flex',
51+
alignItems: 'flex-start',
52+
justifyContent: 'space-between',
53+
gap: '1rem',
54+
marginBottom: '0.85rem',
55+
...style,
56+
}}
57+
{...props}
58+
>
59+
{children}
60+
</div>
61+
);
62+
}
63+
64+
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
65+
as?: 'h2' | 'h3' | 'h4';
66+
}
67+
68+
export function CardTitle({ as = 'h3', className = '', style, children, ...props }: CardTitleProps) {
69+
const Heading = as;
70+
return (
71+
<Heading
72+
className={`ui-card__title ${className}`}
73+
style={{
74+
margin: 0,
75+
fontSize: 'var(--text-lg)',
76+
fontFamily: 'var(--font-display)',
77+
fontWeight: 600,
78+
color: 'var(--fg)',
79+
...style,
80+
}}
81+
{...props}
82+
>
83+
{children}
84+
</Heading>
85+
);
86+
}
87+
88+
export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {}
89+
90+
export function CardBody({ className = '', style, children, ...props }: CardBodyProps) {
91+
return (
92+
<div
93+
className={`ui-card__body ${className}`}
94+
style={{ fontSize: 'var(--text-sm)', color: 'var(--fg-muted)', lineHeight: 1.5, ...style }}
95+
{...props}
96+
>
97+
{children}
98+
</div>
99+
);
100+
}
101+
102+
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
103+
104+
export function CardFooter({ className = '', style, children, ...props }: CardFooterProps) {
105+
return (
106+
<div
107+
className={`ui-card__footer ${className}`}
108+
style={{
109+
marginTop: '1rem',
110+
paddingTop: '0.85rem',
111+
borderTop: '1px solid var(--border)',
112+
display: 'flex',
113+
alignItems: 'center',
114+
gap: '0.75rem',
115+
...style,
116+
}}
117+
{...props}
118+
>
119+
{children}
120+
</div>
121+
);
122+
}
123+
124+
export default Card;

0 commit comments

Comments
 (0)