mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Raise frontend contrast, unify the page frame, and add browser identity (#328)
This commit is contained in:
@@ -34,38 +34,38 @@ h6,
|
||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink */
|
||||
|
||||
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||
--color-primary: oklch(47.5% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white */
|
||||
|
||||
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white */
|
||||
|
||||
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown */
|
||||
|
||||
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment */
|
||||
|
||||
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
||||
--color-info-content: oklch(97% 0.008 145); /* near-white */
|
||||
|
||||
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||
--color-success: oklch(46.5% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white */
|
||||
|
||||
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||
--color-warning: oklch(47.5% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white */
|
||||
|
||||
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||
--color-error: oklch(48% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white */
|
||||
|
||||
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown */
|
||||
|
||||
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
||||
--color-muted: oklch(42% 0.055 62); /* amber-brown */
|
||||
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
@@ -91,38 +91,38 @@ h6,
|
||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white */
|
||||
|
||||
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
||||
--color-primary-content: oklch(30.5% 0.012 50); /* warm off-white */
|
||||
|
||||
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||
--color-secondary: oklch(62.5% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(23.5% 0.005 75); /* near-white */
|
||||
|
||||
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
||||
--color-accent-content: oklch(18% 0.012 55); /* near-black */
|
||||
|
||||
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white */
|
||||
|
||||
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||
--color-info: oklch(61.5% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(23% 0.006 145); /* pale green-white */
|
||||
|
||||
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
||||
--color-success-content: oklch(28.5% 0.012 120); /* pale barley-white */
|
||||
|
||||
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
||||
--color-warning-content: oklch(32.5% 0.012 55); /* warm near-white */
|
||||
|
||||
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||
--color-error: oklch(62.5% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(23% 0.004 15); /* near-white */
|
||||
|
||||
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white */
|
||||
|
||||
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
||||
--color-muted: oklch(78% 0.018 72); /* warm grey */
|
||||
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
@@ -148,40 +148,38 @@ h6,
|
||||
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
||||
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
|
||||
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
|
||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||
|
||||
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
|
||||
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
|
||||
--color-primary-content: oklch(34% 0.01 295); /* pale lavender */
|
||||
|
||||
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
|
||||
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
|
||||
--color-secondary-content: oklch(30% 0.006 10); /* warm near-white */
|
||||
|
||||
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
|
||||
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
|
||||
--color-accent-content: oklch(37.5% 0.01 130); /* near-white */
|
||||
|
||||
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
|
||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
|
||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender */
|
||||
|
||||
--color-info: oklch(62% 0.04 250); /* muted indigo */
|
||||
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
|
||||
--color-info-content: oklch(23% 0.008 250); /* pale indigo-white */
|
||||
|
||||
--color-success: oklch(65% 0.04 145); /* elderberry green */
|
||||
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
|
||||
--color-success-content: oklch(27.5% 0.008 145); /* pale green-white */
|
||||
|
||||
--color-warning: oklch(70% 0.05 65); /* sour apricot */
|
||||
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
|
||||
--color-warning-content: oklch(32.5% 0.03 65); /* near-white */
|
||||
|
||||
--color-error: oklch(50% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
|
||||
--color-error: oklch(62% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(22% 0.006 22); /* near-white */
|
||||
|
||||
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
|
||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
|
||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||
|
||||
--color-muted: oklch(
|
||||
77.6% 0.022 300
|
||||
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
|
||||
--color-muted: oklch(77.6% 0.022 300); /* desaturated lavender */
|
||||
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
|
||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
@@ -207,38 +205,38 @@ h6,
|
||||
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
||||
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
|
||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black */
|
||||
|
||||
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
|
||||
--color-primary: oklch(50% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white */
|
||||
|
||||
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
|
||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
|
||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white */
|
||||
|
||||
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
|
||||
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
|
||||
--color-accent-content: oklch(22% 0.022 148); /* deep green */
|
||||
|
||||
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
||||
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
|
||||
--color-neutral-content: oklch(38.4% 0.005 118); /* near-white */
|
||||
|
||||
--color-info: oklch(38% 0.065 232); /* clear summer sky */
|
||||
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
|
||||
--color-info-content: oklch(98% 0.005 232); /* near-white */
|
||||
|
||||
--color-success: oklch(38% 0.085 145); /* young shoot green */
|
||||
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
|
||||
--color-success-content: oklch(98% 0.005 145); /* near-white */
|
||||
|
||||
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
||||
--color-warning: oklch(51% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white */
|
||||
|
||||
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
|
||||
--color-error: oklch(51.5% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white */
|
||||
|
||||
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
|
||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
|
||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black */
|
||||
|
||||
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
|
||||
--color-muted: oklch(38% 0.055 120); /* sage green */
|
||||
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
|
||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black */
|
||||
|
||||
--radius-selector: 2rem;
|
||||
--radius-field: 2rem;
|
||||
@@ -249,3 +247,33 @@ h6,
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* WCAG 1.4.11: form-field borders at >= 3:1 against every base layer */
|
||||
:is(.input, .select, .textarea):not(
|
||||
.input-neutral,
|
||||
.input-primary,
|
||||
.input-secondary,
|
||||
.input-accent,
|
||||
.input-info,
|
||||
.input-success,
|
||||
.input-warning,
|
||||
.input-error,
|
||||
.select-neutral,
|
||||
.select-primary,
|
||||
.select-secondary,
|
||||
.select-accent,
|
||||
.select-info,
|
||||
.select-success,
|
||||
.select-warning,
|
||||
.select-error,
|
||||
.textarea-neutral,
|
||||
.textarea-primary,
|
||||
.textarea-secondary,
|
||||
.textarea-accent,
|
||||
.textarea-info,
|
||||
.textarea-success,
|
||||
.textarea-warning,
|
||||
.textarea-error
|
||||
) {
|
||||
--input-color: color-mix(in oklab, var(--color-base-content) 56%, #0000);
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ const Navbar = ({ auth }: NavbarProps) => (
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
<Link to="/" className="lg:text-xl text-lg font-bold">
|
||||
The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
@@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
||||
user details
|
||||
</p>
|
||||
@@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
@@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||
header
|
||||
@@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Account Management</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
||||
<code className="kbd kbd-sm">/email</code>,{' '}
|
||||
<code className="kbd kbd-sm">/password</code>,{' '}
|
||||
|
||||
@@ -14,7 +14,7 @@ const SectionCard = ({ icon, title, description, open, onToggle, children }: Sec
|
||||
<div className="card-body">
|
||||
<div className="flex w-full items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-base-content/60 mt-1">{icon}</span>
|
||||
<span className="text-base-content/70 mt-1">{icon}</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">{title}</h2>
|
||||
<p className="text-sm text-base-content/70">{description}</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/70 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
|
||||
@@ -208,11 +208,13 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="size-7" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Account Settings</h1>
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight">
|
||||
Account Settings
|
||||
</h1>
|
||||
<p className="text-base-content/70">
|
||||
Manage the account details for{' '}
|
||||
<span className="font-mono">{username}</span>
|
||||
@@ -257,7 +259,7 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="link link-hover text-sm text-base-content/60 inline-block"
|
||||
className="link link-hover text-sm text-base-content/70 inline-block"
|
||||
>
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
|
||||
@@ -20,7 +20,7 @@ const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
||||
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
||||
<AuthFlowDemoCard />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||
<span className="text-base-content/70 text-xs uppercase tracking-widest font-semibold">
|
||||
Confirmed at
|
||||
</span>
|
||||
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
||||
|
||||
@@ -8,7 +8,7 @@ const LoginCallout = () => (
|
||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/70">
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const RegisterCallout = () => (
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||
className="link link-hover text-sm text-base-content/70 inline-flex items-center gap-1"
|
||||
>
|
||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||
Back to home
|
||||
|
||||
@@ -23,12 +23,12 @@ const BeerListCard = ({ beers }: BeerListCardProps) => (
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="font-bold text-sm">{beer.name}</span>
|
||||
<span className="text-xs text-base-content/60">{beer.description}</span>
|
||||
<span className="text-xs text-base-content/70">{beer.description}</span>
|
||||
</div>
|
||||
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
|
||||
{beer.style}
|
||||
</span>
|
||||
<span className="text-xs text-base-content/60 tabular-nums whitespace-nowrap">
|
||||
<span className="text-xs text-base-content/70 tabular-nums whitespace-nowrap">
|
||||
{beer.abv.toFixed(1)}% ABV
|
||||
</span>
|
||||
<StarRating value={beer.rating} size="xs" />
|
||||
|
||||
@@ -17,7 +17,7 @@ const BeerStyleBreakdownCard = ({ beers }: BeerStyleBreakdownCardProps) => {
|
||||
<div key={style} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-semibold">{style}</span>
|
||||
<span className="text-xs text-base-content/60 tabular-nums">{count}</span>
|
||||
<span className="text-xs text-base-content/70 tabular-nums">{count}</span>
|
||||
</div>
|
||||
<progress
|
||||
className="progress progress-primary h-1.5"
|
||||
|
||||
@@ -8,7 +8,7 @@ interface BreweryDetailsCardProps {
|
||||
|
||||
const DetailRow = ({ label, value }: { label: string; value: string | number }) => (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-base-content/60">{label}</span>
|
||||
<span className="text-base-content/70">{label}</span>
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,16 +26,18 @@ const BreweryHeaderCard = ({
|
||||
<div className="card-body gap-4 p-7">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-base-content/50">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-base-content/70">
|
||||
Brewery
|
||||
</span>
|
||||
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
||||
Est. {foundedYear}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-4xl leading-tight m-0">{brewery.breweryName}</h1>
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight m-0">
|
||||
{brewery.breweryName}
|
||||
</h1>
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/60 flex items-center gap-1 m-0">
|
||||
<p className="text-sm text-base-content/70 flex items-center gap-1 m-0">
|
||||
<MapPin className="size-4" aria-hidden="true" />
|
||||
{formatBreweryAddress(brewery.location)}
|
||||
</p>
|
||||
@@ -57,7 +59,7 @@ const BreweryHeaderCard = ({
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<StarRating value={avgRating} size="sm" />
|
||||
<span className="font-bold">{avgRating.toFixed(1)}</span>
|
||||
<span className="text-sm text-base-content/60">({ratingsCount} ratings)</span>
|
||||
<span className="text-sm text-base-content/70">({ratingsCount} ratings)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base-content/70 leading-relaxed text-pretty m-0">
|
||||
|
||||
@@ -8,7 +8,7 @@ interface CommunityStatsCardProps {
|
||||
|
||||
const StatRow = ({ label, value }: { label: string; value: number }) => (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-base-content/60">{label}</span>
|
||||
<span className="text-base-content/70">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ const YourRatingCard = ({ value, onChange }: YourRatingCardProps) => (
|
||||
<div className="card-body flex-row flex-wrap items-center justify-between gap-4 p-6">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="text-xl m-0">Your rating</h3>
|
||||
<p className="text-sm text-base-content/60 m-0">
|
||||
<p className="text-sm text-base-content/70 m-0">
|
||||
{value
|
||||
? `You rated this ${value} of 5. Tap the same star to clear.`
|
||||
: 'Tap a star to rate this brewery.'}
|
||||
|
||||
@@ -31,14 +31,14 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
|
||||
<span className="font-bold text-sm">{comment.user}</span>
|
||||
)}
|
||||
<StarRating value={comment.rating} size="xs" />
|
||||
<span className="text-xs text-base-content/60">{comment.time}</span>
|
||||
<span className="text-xs text-base-content/70">{comment.time}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleLike(comment.id)}
|
||||
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
|
||||
comment.liked ? 'text-primary' : 'text-base-content/60'
|
||||
comment.liked ? 'text-primary' : 'text-base-content/70'
|
||||
}`}
|
||||
>
|
||||
{comment.liked ? (
|
||||
|
||||
@@ -24,7 +24,7 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
||||
</p>
|
||||
)}
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/50 mt-2">
|
||||
<p className="text-sm text-base-content/70 mt-2">
|
||||
{formatBreweryAddress(brewery.location)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -19,14 +19,14 @@ const FeaturedBreweryCard = ({ brewery, onShowOnMap }: FeaturedBreweryCardProps)
|
||||
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{brewery.location && (
|
||||
<div className="text-sm font-semibold text-base-content/60">
|
||||
<div className="text-sm font-semibold text-base-content/70">
|
||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
|
||||
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/60 m-0">
|
||||
<p className="text-sm text-base-content/70 m-0">
|
||||
{formatBreweryAddress(brewery.location)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
|
||||
<div className="card-body gap-1.5">
|
||||
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
|
||||
{brewery.location && (
|
||||
<div className="text-sm text-base-content/60">
|
||||
<div className="text-sm text-base-content/70">
|
||||
{brewery.location.cityName},{' '}
|
||||
{brewery.location.stateProvinceCode}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const NearbyBreweriesHeader = ({
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
|
||||
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
||||
<span className="text-sm text-base-content/60">
|
||||
<span className="text-sm text-base-content/70">
|
||||
{center
|
||||
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
||||
: 'Finding breweries near you…'}
|
||||
|
||||
@@ -23,7 +23,7 @@ const NearbyBreweryList = ({
|
||||
return (
|
||||
<>
|
||||
{withDistance.length === 0 && center && (
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ const NearbyBreweryListItem = ({
|
||||
</span>
|
||||
</div>
|
||||
{brewery.location && (
|
||||
<div className="text-sm text-base-content/60">
|
||||
<div className="text-sm text-base-content/70">
|
||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SidebarCardProps {
|
||||
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body gap-3 p-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-base-content/50 m-0">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-base-content/70 m-0">
|
||||
{title}
|
||||
</p>
|
||||
{children}
|
||||
|
||||
@@ -44,8 +44,8 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
||||
<div className="max-w-7xl mx-auto px-5 pt-10">
|
||||
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
|
||||
<p className="text-lg text-base-content/60 max-w-xl m-0">
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-2">Breweries</h1>
|
||||
<p className="text-lg text-base-content/70 max-w-xl m-0">
|
||||
Discover our partner breweries — start with this week's feature, then see
|
||||
what's pouring near you.
|
||||
</p>
|
||||
|
||||
@@ -80,15 +80,15 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="max-w-4xl mx-auto px-6 pt-8 pb-16">
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-16">
|
||||
<Link
|
||||
to="/breweries"
|
||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
|
||||
>
|
||||
← Back to breweries
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_19rem] gap-5 items-start">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_23rem] gap-5 items-start">
|
||||
<div className="flex flex-col gap-5">
|
||||
<BreweryHeaderCard
|
||||
brewery={brewery}
|
||||
|
||||
@@ -72,14 +72,16 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-14">
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<Link
|
||||
to="/breweries"
|
||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
|
||||
>
|
||||
← Back to Breweries
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold mb-4">Full brewery directory</h1>
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">
|
||||
Full brewery directory
|
||||
</h1>
|
||||
<p className="text-base-content/70 mb-6">
|
||||
All of our partner breweries, newest first.
|
||||
</p>
|
||||
@@ -93,7 +95,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
</ClientOnly>
|
||||
|
||||
{allBreweries.length === 0 ? (
|
||||
<p className="text-center text-base-content/60 mt-12">
|
||||
<p className="text-center text-base-content/70 mt-12">
|
||||
No breweries found yet.
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Clock, Lock, Tools } from 'iconoir-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
type Reason = 'soon' | 'maintenance' | 'auth';
|
||||
|
||||
interface Action {
|
||||
to: string;
|
||||
label: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface Content {
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
actions: Action[];
|
||||
}
|
||||
|
||||
const CONTENT: Record<Reason, Content> = {
|
||||
soon: {
|
||||
Icon: Clock,
|
||||
title: 'This feature is not available yet',
|
||||
description: 'This feature is still in development.',
|
||||
actions: [
|
||||
{ to: '/breweries', label: 'Browse breweries', className: 'btn-primary' },
|
||||
{ to: '/', label: 'Back to home', className: 'btn-ghost' },
|
||||
],
|
||||
},
|
||||
maintenance: {
|
||||
Icon: Tools,
|
||||
title: 'This feature is temporarily unavailable',
|
||||
description: 'This feature is offline for maintenance. Try again in a few minutes.',
|
||||
actions: [],
|
||||
},
|
||||
auth: {
|
||||
Icon: Lock,
|
||||
title: 'Sign in to use this feature',
|
||||
description:
|
||||
'This feature requires a valid session. Log in or create an account to continue.',
|
||||
actions: [
|
||||
{ to: '/login', label: 'Login', className: 'btn-primary' },
|
||||
{ to: '/register', label: 'Register', className: 'btn-outline' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface FeatureNotAvailableProps {
|
||||
reason?: Reason;
|
||||
}
|
||||
|
||||
const FeatureNotAvailable = ({ reason = 'soon' }: FeatureNotAvailableProps) => {
|
||||
const { Icon, title, description, actions } = CONTENT[reason];
|
||||
|
||||
return (
|
||||
<section className="flex flex-col items-center gap-3 rounded-box border border-base-300 bg-base-100 px-6 py-12 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-base-200 text-base-content/70">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<div className="flex max-w-md flex-col gap-2">
|
||||
<h2 className="font-serif text-2xl font-bold leading-tight">{title}</h2>
|
||||
<p className="text-base-content/70 text-pretty">{description}</p>
|
||||
</div>
|
||||
{actions.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-3 pt-2">
|
||||
{actions.map(({ to, label, className }) => (
|
||||
<Link key={to} to={to} className={`btn btn-sm ${className}`}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureNotAvailable;
|
||||
@@ -1,3 +1,4 @@
|
||||
import FeatureNotAvailable from '../components/FeatureNotAvailable';
|
||||
import type { Route } from './+types/beer-styles';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
|
||||
const BeerStyles = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Beer Styles</h1>
|
||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||
<div className="mt-8">
|
||||
<FeatureNotAvailable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import FeatureNotAvailable from '../components/FeatureNotAvailable';
|
||||
import type { Route } from './+types/beers';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
|
||||
const Beers = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Beers</h1>
|
||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||
<div className="mt-8">
|
||||
<FeatureNotAvailable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
||||
const ClosingCta = () => (
|
||||
<section className="mx-auto flex max-w-3xl flex-col items-center gap-5 px-5 py-20 text-center">
|
||||
<h2 className="m-0 font-serif text-[clamp(2rem,4vw,2.75rem)] leading-tight text-balance">
|
||||
🍺 Your next favourite brewery is on the list.
|
||||
Your next favourite brewery is on the list.
|
||||
</h2>
|
||||
|
||||
<p className="m-0 text-lg leading-[1.55] text-[var(--color-muted)]">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expect, within } from 'storybook/test';
|
||||
import { biergartenThemes, defaultThemeName } from '../../theme/themes';
|
||||
import ThemeTryOut from './ThemeTryOut';
|
||||
|
||||
const themeTryOutDescription = `Landing page theme band. Hosts the shared \`ThemeSegmentedControl\` with short labels, so a visitor can switch the whole document theme from the marketing page. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme.`;
|
||||
const themeTryOutDescription = `Landing page theme band. Frames the shared \`ThemeSegmentedControl\` in an elevated card with a "Set the mood" eyebrow and a pour count, and renders it with short labels so the four options fit the marketing page's narrower column. Each option carries its \`shortVibe\` mood line, and picking one switches the whole document theme. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme. \`Themes/ThemeSegmentedControl\` covers the control itself.`;
|
||||
|
||||
const ThemeTryOutHarness = () => {
|
||||
const [router] = useState(() =>
|
||||
@@ -48,9 +48,15 @@ export const Default: Story = {
|
||||
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await expect(canvas.getByText('Set the mood')).toBeVisible();
|
||||
await expect(canvas.getByText(`${biergartenThemes.length} pours`)).toBeVisible();
|
||||
|
||||
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
|
||||
await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length);
|
||||
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(within(group).getByText(theme.shortLabel)).toBeInTheDocument();
|
||||
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
|
||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,11 +13,17 @@ const ThemeTryOut = () => (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-box bg-base-100 p-3 text-base-content">
|
||||
<ThemeSegmentedControl
|
||||
labels="short"
|
||||
className="join join-vertical w-full sm:join-horizontal"
|
||||
/>
|
||||
<div className="rounded-box border border-neutral-content/15 bg-base-100 p-4 text-base-content shadow-xl sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="m-0 text-xs font-bold uppercase tracking-[0.18em] opacity-60">
|
||||
Set the mood
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-sm opacity-75">Your theme follows your pour.</p>
|
||||
</div>
|
||||
<span className="badge badge-outline shrink-0">4 pours</span>
|
||||
</div>
|
||||
<ThemeSegmentedControl labels="short" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -31,7 +31,7 @@ const TextTokenSample = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
|
||||
@@ -54,7 +54,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||
Core
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
@@ -69,7 +69,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||
Status
|
||||
</p>
|
||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
||||
@@ -81,7 +81,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||
Content
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useMemo } from 'react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
||||
import ThemeSegmentedControl from './ThemeSegmentedControl';
|
||||
|
||||
const themeSegmentedControlDescription = `The shared theme picker, used by the theme guide and by the landing page's try-out band. Each option pairs a gradient swatch of the theme's palette with its name and \`shortVibe\` mood line, and selection is carried by both color and elevation. Picking an option sets \`data-theme\` on \`<html>\` and writes the \`${themeCookieName}\` cookie, so the choice survives the next request and the server renders the same theme. The initial selection comes from the root route's loader, so these stories run behind a memory data router whose \`root\` route supplies the theme the cookie would have provided.`;
|
||||
|
||||
interface HarnessProps {
|
||||
labels?: 'full' | 'short';
|
||||
className?: string;
|
||||
onChange?: (theme: ThemeName) => void;
|
||||
/** Theme the root loader hands the control, standing in for the theme cookie. */
|
||||
seedTheme?: ThemeName;
|
||||
}
|
||||
|
||||
const ThemeSegmentedControlHarness = ({
|
||||
labels,
|
||||
className,
|
||||
onChange,
|
||||
seedTheme = defaultThemeName,
|
||||
}: HarnessProps) => {
|
||||
// Rebuilt per arg change so the docs controls remount the control rather
|
||||
// than leaving it seeded from the previous render.
|
||||
const router = useMemo(
|
||||
() =>
|
||||
createMemoryRouter([
|
||||
{
|
||||
id: 'root',
|
||||
path: '/',
|
||||
loader: () => ({ theme: seedTheme }),
|
||||
element: (
|
||||
<div className="max-w-md rounded-box bg-base-100 p-4 text-base-content">
|
||||
<ThemeSegmentedControl
|
||||
labels={labels}
|
||||
className={className}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]),
|
||||
[labels, className, onChange, seedTheme],
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Themes/ThemeSegmentedControl',
|
||||
component: ThemeSegmentedControlHarness,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
labels: 'full',
|
||||
seedTheme: defaultThemeName,
|
||||
onChange: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
labels: { control: 'inline-radio', options: ['full', 'short'] },
|
||||
seedTheme: {
|
||||
control: 'select',
|
||||
options: biergartenThemes.map((theme) => theme.value),
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
usesDataRouter: true,
|
||||
docs: {
|
||||
description: {
|
||||
component: themeSegmentedControlDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ThemeSegmentedControlHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const getOptions = (canvasElement: HTMLElement) => {
|
||||
const group = within(canvasElement).getByRole('radiogroup', { name: 'Theme selector' });
|
||||
return { group, radios: within(group).getAllByRole('radio') as HTMLInputElement[] };
|
||||
};
|
||||
|
||||
/** Full labels, as the theme guide renders it. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const { group, radios } = getOptions(canvasElement);
|
||||
|
||||
await expect(radios).toHaveLength(biergartenThemes.length);
|
||||
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(within(group).getByText(theme.label)).toBeVisible();
|
||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
||||
}
|
||||
|
||||
const [lager] = radios;
|
||||
await expect(lager.value).toBe(defaultThemeName);
|
||||
await expect(lager.checked).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
/** Short labels, as the landing page renders it in the narrower try-out card. */
|
||||
export const ShortLabels: Story = {
|
||||
args: {
|
||||
labels: 'short',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const { group } = getOptions(canvasElement);
|
||||
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
|
||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(within(group).queryByText(biergartenThemes[0].label)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** A returning visitor whose cookie already names a theme other than the default. */
|
||||
export const SeededFromCookie: Story = {
|
||||
args: {
|
||||
seedTheme: 'biergarten-cassis',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const { radios } = getOptions(canvasElement);
|
||||
|
||||
for (const radio of radios) {
|
||||
await expect(radio.checked).toBe(radio.value === 'biergarten-cassis');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectingATheme: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const previousTheme = document.documentElement.getAttribute('data-theme');
|
||||
const { group, radios } = getOptions(canvasElement);
|
||||
|
||||
await userEvent.click(within(group).getByText('Biergarten Stout'));
|
||||
|
||||
for (const radio of radios) {
|
||||
await expect(radio.checked).toBe(radio.value === 'biergarten-stout');
|
||||
}
|
||||
|
||||
await expect(args.onChange).toHaveBeenCalledWith('biergarten-stout');
|
||||
await expect(document.documentElement.getAttribute('data-theme')).toBe('biergarten-stout');
|
||||
await expect(document.cookie).toContain(`${themeCookieName}=biergarten-stout`);
|
||||
|
||||
// The control themes the whole document, so hand <html> back to the
|
||||
// toolbar's theme before the next story mounts.
|
||||
if (previousTheme === null) {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', previousTheme);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Single column, for a sidebar or any container too narrow for the paired grid. */
|
||||
export const SingleColumn: Story = {
|
||||
args: {
|
||||
className: 'grid grid-cols-1 gap-2',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const { radios } = getOptions(canvasElement);
|
||||
await expect(radios).toHaveLength(biergartenThemes.length);
|
||||
},
|
||||
};
|
||||
@@ -19,7 +19,7 @@ interface ThemeSegmentedControlProps {
|
||||
|
||||
const ThemeSegmentedControl = ({
|
||||
labels = 'full',
|
||||
className = 'join join-vertical sm:join-horizontal',
|
||||
className = 'grid grid-cols-2 gap-2',
|
||||
onChange,
|
||||
}: ThemeSegmentedControlProps) => {
|
||||
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
||||
@@ -33,7 +33,11 @@ const ThemeSegmentedControl = ({
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
className={`group flex min-h-20 cursor-pointer items-center gap-3 rounded-box border p-3 text-left transition-all duration-200 ${
|
||||
checked
|
||||
? 'border-primary bg-primary text-primary-content shadow-md'
|
||||
: 'border-base-content/15 bg-base-200/45 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-base-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -47,7 +51,34 @@ const ThemeSegmentedControl = ({
|
||||
onChange?.(theme.value);
|
||||
}}
|
||||
/>
|
||||
{labels === 'short' ? theme.shortLabel : theme.label}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`size-9 shrink-0 rounded-full border-4 shadow-inner transition-transform duration-200 group-hover:scale-105 ${
|
||||
checked ? 'border-primary-content/70' : 'border-base-100'
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
theme.value === 'biergarten-lager'
|
||||
? 'linear-gradient(135deg, #f5c451 0%, #a96728 100%)'
|
||||
: theme.value === 'biergarten-stout'
|
||||
? 'linear-gradient(135deg, #d69b45 0%, #382116 100%)'
|
||||
: theme.value === 'biergarten-cassis'
|
||||
? 'linear-gradient(135deg, #c777b9 0%, #4c214e 100%)'
|
||||
: 'linear-gradient(135deg, #f7e6a5 0%, #82a85d 100%)',
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold">
|
||||
{labels === 'short' ? theme.shortLabel : theme.label}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-0.5 block truncate text-xs ${
|
||||
checked ? 'text-primary-content/75' : 'text-base-content/60'
|
||||
}`}
|
||||
>
|
||||
{theme.shortVibe}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface ThemeOption {
|
||||
label: string;
|
||||
/** Used where the segmented control is narrow, such as the landing page. */
|
||||
shortLabel: string;
|
||||
/** A compact mood descriptor for theme pickers. */
|
||||
shortVibe: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
@@ -17,24 +19,28 @@ export const biergartenThemes: ThemeOption[] = [
|
||||
value: 'biergarten-lager',
|
||||
label: 'Biergarten Lager',
|
||||
shortLabel: 'Lager',
|
||||
shortVibe: 'Muted parchment',
|
||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-stout',
|
||||
label: 'Biergarten Stout',
|
||||
shortLabel: 'Stout',
|
||||
shortVibe: 'Charred barrel',
|
||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-cassis',
|
||||
label: 'Biergarten Cassis',
|
||||
shortLabel: 'Cassis',
|
||||
shortVibe: 'Blackberry barrel',
|
||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-weizen',
|
||||
label: 'Biergarten Weizen',
|
||||
shortLabel: 'Weizen',
|
||||
shortVibe: 'Bright spring',
|
||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body items-center py-12">
|
||||
<p className="text-sm text-base-content/60 text-center m-0">{message}</p>
|
||||
<p className="text-sm text-base-content/70 text-center m-0">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ const ProfileSidebarCard = ({
|
||||
<div className="card-body gap-4 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
|
||||
<p className="text-sm text-base-content/60 m-0 mt-0.5">@{username}</p>
|
||||
<p className="text-sm text-base-content/70 m-0 mt-0.5">@{username}</p>
|
||||
</div>
|
||||
|
||||
{isOwnProfile ? (
|
||||
@@ -52,7 +52,7 @@ const ProfileSidebarCard = ({
|
||||
|
||||
<p className="text-sm leading-normal m-0">{bio}</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-sm text-base-content/60">
|
||||
<div className="flex flex-col gap-1.5 text-sm text-base-content/70">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||||
{location}
|
||||
@@ -68,7 +68,7 @@ const ProfileSidebarCard = ({
|
||||
<div className="flex flex-col gap-2">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-base-content/60">{stat.label}</span>
|
||||
<span className="text-sm text-base-content/70">{stat.label}</span>
|
||||
<span className="font-serif text-lg font-bold tabular-nums">
|
||||
{stat.value}
|
||||
</span>
|
||||
|
||||
@@ -24,7 +24,7 @@ const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
|
||||
{TABS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
||||
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/70 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
|
||||
@@ -36,11 +36,11 @@ const ActivityTab = ({ activity }: ActivityTabProps) => {
|
||||
</div>
|
||||
)}
|
||||
{item.comment && (
|
||||
<p className="text-sm text-base-content/60 italic mt-1.5 mb-0">
|
||||
<p className="text-sm text-base-content/70 italic mt-1.5 mb-0">
|
||||
“{item.comment}”
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
|
||||
<p className="text-xs text-base-content/70 mt-1.5 mb-0">
|
||||
{item.time}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
|
||||
) : (
|
||||
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
|
||||
)}
|
||||
<p className="text-xs text-base-content/60 m-0 truncate">{f.meta}</p>
|
||||
<p className="text-xs text-base-content/70 m-0 truncate">{f.meta}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,7 @@ const LikedTab = ({ liked }: LikedTabProps) => {
|
||||
<div className="h-[120px] bg-base-300" />
|
||||
<div className="card-body p-4 gap-1">
|
||||
<p className="font-serif font-bold m-0">{item.name}</p>
|
||||
<p className="text-sm text-base-content/60 m-0">{item.meta}</p>
|
||||
<p className="text-sm text-base-content/70 m-0">{item.meta}</p>
|
||||
<div className="mt-1">
|
||||
<StarRating value={item.rating} size="xs" />
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 pb-16">
|
||||
<div className="max-w-[1080px] mx-auto px-6 pt-8">
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<div className="rounded-box h-[190px] bg-base-300" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
||||
|
||||
@@ -17,6 +17,11 @@ import AiDisclosureDialog from './features/home/components/AiDisclosureDialog';
|
||||
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'icon', href: '/favicon.ico', sizes: 'any' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
|
||||
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
|
||||
{ rel: 'manifest', href: '/site.webmanifest' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
@@ -86,7 +91,7 @@ export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<main className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
|
||||
30
web/frontend/package-lock.json
generated
30
web/frontend/package-lock.json
generated
@@ -3948,9 +3948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5103,9 +5103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6775,9 +6775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7655,9 +7655,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8972,9 +8972,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
|
||||
Reference in New Issue
Block a user