Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,46 @@ brand.*: social (mastodon, linkedin, facebook) — bg-brand-mastodon etc.
| Section BG white | `bg-white` (dark auto-handled) | `bg-white dark:bg-neutral-950` (unless deeper black needed) |
| Card hover | `hover:border-neutral-300` | `hover:shadow-xl` |

### Chrome & Navigation — one primitive per shape (SSOT)

"Chrome" = the repeated structural shells: headers, footers, nav bars, panels,
sidebars, bottom nav. Each SHAPE has exactly ONE primitive. Never hand-roll a
card shell (`bg-surface-base rounded-lg border p-…`) or an active-nav class
(`bg-action/10 … ring-action/20`, `text-action`, `border-action bg-action-muted`)
inline — those are defined once and consumed everywhere. Retheming chrome = edit
the primitive, not N components.

| Need | Use | File | Never hand-roll |
|---|---|---|---|
| Titled bordered surface (title + subtitle + right action + body) | `<Panel title subtitle icon action>` | `components/ui/Panel.tsx` | `<div className="bg-surface-base rounded-lg border p-5">` + own `<h2>` |
| Untitled surface | `<Card>` | `components/ui/card.tsx` | `<div className="card-shell p-6">` / `rounded-xl border bg-surface-base` |
| Header row *inside* a card | `<AdminSectionHeader>` | `components/admin/AdminSectionHeader.tsx` | bespoke `flex justify-between` + divider |
| Full-bleed page hero (centered) | `<PageHero theme title subtitle>` | `components/layout/PageHero.tsx` | a one-off `<section>` with a bespoke `<h1>` type string |
| Page-section vertical rhythm / tinted band | `<Section density tone>` | `components/layout/Section.tsx` | hardcoded `py-16 sm:py-20 …` |
| Mobile bottom tab bar (`<lg`) | `<BottomNav items ariaLabel more>` | `components/layout/BottomNav.tsx` | a `fixed bottom-0 … flex` `<nav>` + tab loop |
| Active/inactive nav item classes | `navLinkClass(shape, active)` / `NAV_STATE` | `lib/design/nav.ts` | inline `text-action` / `ring-action/20` ladders |

Rules:
- **Nav state is one definition.** `NAV_STATE` (`sidebar` / `bottomTab` / `pill`)
is the ONLY place "current page" is styled. `adminInteractive.navActive`
re-exports `NAV_STATE.sidebar.active` — the value lives in `nav.ts`, not admin.
Need a new nav shape? Add it to `NAV_STATE`, don't hand-roll a sixth encoding.
- **Neutral home for shared chrome.** Dashboard must not import from `app/admin/*`
(see SoC rule). `BottomNav` + `nav.ts` live in neutral `components/layout` /
`lib/design` so admin, dashboard, and public all consume them.
- **One primitive per shape.** If you find yourself building a second "hero" or a
second "bottom bar," extend the existing primitive with a prop — don't fork it.
(This is why `ResponsiveHero` was folded into `PageHero`.)
- **Card shell = `card-shell` only** (rounded-xl + border + surface, NO shadow).
`<Card>`/`<Panel>` both resolve to it. `rounded-lg`, missing border, or
`shadow-xs` on a static card is drift — fix it, don't copy it.

Deliberately NOT unified (yet): the right-edge **drawer/sheet** overlay
(MessageSidebar, MobileMenu, DashboardMobileNav sheet) — a shared `<Drawer>`
with focus-trap + scroll-lock is the next chrome primitive; until it lands,
match MobileMenu's implementation (scrim `bg-black/40 backdrop-blur-xs`, focus
trap via `useFocusTrap`, body scroll-lock) and never `bg-opacity-*`.

### Mobile-first = ACTION-first, not stats-first

On a phone, the user opened the page to DO the one thing the page is for —
Expand Down Expand Up @@ -207,5 +247,14 @@ grep -rn 'shadow-lg\|shadow-xl\|shadow-2xl' src/app/[locale]/ src/components/

# Gradient backgrounds that should be flat
grep -rn 'bg-gradient-to' src/app/[locale]/ src/components/ | grep -v 'from-black\|to-transparent'

# Hand-rolled card shells — should be <Card>/<Panel> (resolve to card-shell)
grep -rnE 'bg-surface-base rounded-(lg|xl) border' src/components src/app

# Nav-active drift — active state must come from NAV_STATE / navLinkClass
grep -rn 'ring-action/30' src/components src/app

# Deprecated opacity utility on scrims (use bg-black/NN)
grep -rn 'bg-opacity-' src/components src/app
```

25 changes: 5 additions & 20 deletions src/app/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useMemo } from 'react'
import { adminInteractive } from '@/lib/admin-ui'
import { navLinkClass } from '@/lib/design/nav'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Link as PublicLink } from '@/i18n/navigation'
Expand Down Expand Up @@ -212,11 +213,7 @@ export function AdminSidebar({
key={section.path}
href={section.path}
onClick={() => { setMobileMenuOpen(false); setFilterQuery('') }}
className={`flex items-center gap-2.5 rounded-lg px-2 py-3 lg:py-1.5 transition-colors ${
isActive(section.path)
? adminInteractive.navActive
: `text-text-tertiary ${adminInteractive.rowHoverSubtle} hover:text-text-primary`
}`}
className={navLinkClass('sidebar', isActive(section.path))}
>
<Icon className="h-4 w-4 shrink-0 text-text-muted dark:text-text-secondary" />
<span className="flex-1 text-sm font-medium">
Expand Down Expand Up @@ -250,13 +247,7 @@ export function AdminSidebar({
<Link
href={only.path}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center gap-2.5 rounded-lg px-2 py-3 lg:py-1.5 transition-colors ${
sidebarCollapsed ? 'justify-center' : ''
} ${
onlyActive
? adminInteractive.navActive
: `text-text-tertiary ${adminInteractive.rowHoverSubtle} hover:text-text-primary`
}`}
className={navLinkClass('sidebar', onlyActive, sidebarCollapsed ? 'justify-center' : '')}
title={sidebarCollapsed ? sectionLabel(tSections, only) : undefined}
>
<OnlyIcon className={`shrink-0 ${sidebarCollapsed ? 'h-5 w-5' : 'h-4 w-4'} ${onlyActive ? 'text-action' : 'text-text-muted dark:text-text-secondary'}`} />
Expand Down Expand Up @@ -299,13 +290,7 @@ export function AdminSidebar({
key={section.path}
href={section.path}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center gap-2.5 rounded-lg px-2 py-3 lg:py-1.5 transition-colors ${
sidebarCollapsed ? 'justify-center' : ''
} ${
active
? adminInteractive.navActive
: `text-text-tertiary ${adminInteractive.rowHoverSubtle} hover:text-text-primary`
}`}
className={navLinkClass('sidebar', active, sidebarCollapsed ? 'justify-center' : '')}
title={sidebarCollapsed ? `${sectionLabel(tSections, section)}${sensitive ? ` (${t('sensitiveLabel')})` : ''}` : sensitivityReason}
>
{/* Larger icon when collapsed so it's easier to tap and recognise at a glance */}
Expand Down Expand Up @@ -340,7 +325,7 @@ export function AdminSidebar({
sidebarCollapsed ? 'justify-center' : ''
} ${
isActive(hirnSection.path)
? 'bg-action/10 text-action ring-1 ring-action/30'
? adminInteractive.navActive
: 'text-text-secondary hover:bg-action/10'
}`}
title={sidebarCollapsed ? t('hirnTitle') : undefined}
Expand Down
7 changes: 3 additions & 4 deletions src/app/admin/decisions/[id]/ResultsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type ConsentResponse,
type SimpleMajorityResponse,
} from '@/config/decisions';
import Heading from '@/components/admin/AdminHeading';
import { Panel } from '@/components/ui/Panel';

interface RankedOption {
id: string;
Expand Down Expand Up @@ -103,8 +103,7 @@ export default function ResultsPanel({ outcome, outcomeSummary, votingMethod, ai
if (!data) return null;

return (
<div className="rounded-lg bg-surface-base p-6 shadow-xs">
<Heading level={2} className="mb-4 text-lg font-semibold text-text-primary">Ergebnis</Heading>
<Panel title="Ergebnis" className="p-6">

{/* AI Outcome Narrative — Beschluss hero */}
{aiOutcomeNarrative && (
Expand Down Expand Up @@ -284,6 +283,6 @@ export default function ResultsPanel({ outcome, outcomeSummary, votingMethod, ai
<p className="mt-1 text-sm text-action">{outcomeSummary}</p>
</div>
)}
</div>
</Panel>
);
}
8 changes: 4 additions & 4 deletions src/app/services/open-source-solutions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Metadata } from 'next'
import { ResponsiveHero } from '@/components/layout/ResponsiveHero'
import { PageHero } from '@/components/layout/PageHero'
import { BenefitsSection } from './components/BenefitsSection'
import { ServicesSection } from './components/ServicesSection'
import { ComparisonSection } from './components/ComparisonSection'
Expand All @@ -24,10 +24,10 @@ export default function OpenSourceSolutionsPage() {
return (
<main>
{/* Hero Section */}
<ResponsiveHero
<PageHero
theme="services"
title="Open-Source-Lösungen"
description="Professionelle Implementierung von Open-Source-Software, die proprietären Alternativen entspricht oder diese übertrifft."
backgroundColor="green"
subtitle="Professionelle Implementierung von Open-Source-Software, die proprietären Alternativen entspricht oder diese übertrifft."
/>

{/* Benefits Section */}
Expand Down
61 changes: 18 additions & 43 deletions src/components/admin/MobileBottomNav.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use client'

import { useTranslations } from 'next-intl'
import { Link, usePathname } from '@/i18n/navigation'
import { Menu } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { usePathname } from '@/i18n/navigation'
import { BottomNav } from '@/components/layout/BottomNav'
import { getMobileBottomNavSections } from '@/config/sections'
import { sectionShortLabel } from '@/lib/section-labels'

Expand All @@ -24,49 +23,25 @@ interface MobileBottomNavProps {
*/
export function MobileBottomNav({ accessibleSections, onMenuClick }: MobileBottomNavProps) {
const pathname = usePathname()
const items = getMobileBottomNavSections(accessibleSections)
const sections = getMobileBottomNavSections(accessibleSections)
const t = useTranslations('admin.sidebar')
const tSections = useTranslations('admin.sections')

return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border bg-surface-base safe-area-inset-bottom lg:hidden"
aria-label={t('navAria')}
>
{items.map(section => {
// Dashboard ("/admin") needs exact-match so it isn't lit for
// every /admin/* page; everything else uses prefix-match.
const exact = section.path === '/admin'
const active = exact ? pathname === section.path : pathname.startsWith(section.path)
const Icon = section.ui.icon
const label = sectionShortLabel(tSections, section)
return (
<Link
key={section.id}
href={section.path}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-xs transition-colors ${
active
? 'text-action'
: 'text-text-tertiary dark:text-text-tertiary hover:text-text-secondary'
}`}
aria-current={active ? 'page' : undefined}
>
<Icon className="w-5 h-5" aria-hidden="true" />
<span>{label}</span>
</Link>
)
})}
const items = sections.map(section => ({
key: section.id,
href: section.path,
label: sectionShortLabel(tSections, section),
icon: section.ui.icon,
// Dashboard ("/admin") needs exact-match so it isn't lit for every
// /admin/* page; everything else uses prefix-match.
active: section.path === '/admin' ? pathname === section.path : pathname.startsWith(section.path),
}))

{/* Mehr — opens sidebar */}
<Button
variant="ghost"
onClick={onMenuClick}
className="flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] h-auto text-xs text-text-tertiary hover:text-text-secondary rounded-none"
aria-label={t('openAria')}
>
<Menu className="w-5 h-5" aria-hidden="true" />
<span>{t('mobileMore')}</span>
</Button>
</nav>
return (
<BottomNav
items={items}
ariaLabel={t('navAria')}
more={{ label: t('mobileMore'), ariaLabel: t('openAria'), onClick: onMenuClick }}
/>
)
}
18 changes: 9 additions & 9 deletions src/components/admin/projects/ContributionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { toast } from 'sonner'
import { apiFetch } from '@/lib/api/client'
import { cn } from '@/lib/utils'
import { designPrimitive } from '@/lib/design-system'
import { Panel } from '@/components/ui/Panel'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
Expand Down Expand Up @@ -82,12 +83,10 @@ export function ContributionsPanel({ slug, initialContributions, needs }: Props)
}

return (
<section className={cn(designPrimitive.surface.card, 'p-4 sm:p-5')}>
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<div className="min-w-0">
<h2 className="text-base font-semibold text-text-primary">{t('contributions.title')}</h2>
<p className="text-xs text-text-tertiary mt-0.5">{t('contributions.subtitle')}</p>
</div>
<Panel
title={t('contributions.title')}
subtitle={t('contributions.subtitle')}
action={
<Select
value={filter}
onChange={e => setFilter(e.target.value as ContributionStatus | 'all')}
Expand All @@ -98,7 +97,8 @@ export function ContributionsPanel({ slug, initialContributions, needs }: Props)
<option key={s} value={s}>{t(`contributionStatusLabels.${s}`)}</option>
))}
</Select>
</div>
}
>

{filtered.length === 0 ? (
<p className="text-sm text-text-tertiary text-center py-8">
Expand All @@ -111,7 +111,7 @@ export function ContributionsPanel({ slug, initialContributions, needs }: Props)
{filtered.map(c => {
const need = c.needId ? needById[c.needId] : null
return (
<div key={c.id} className="rounded-lg border border p-3 sm:p-4">
<div key={c.id} className="rounded-lg border p-3 sm:p-4">
<div className="flex flex-wrap items-start justify-between gap-2 mb-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
Expand Down Expand Up @@ -187,6 +187,6 @@ export function ContributionsPanel({ slug, initialContributions, needs }: Props)
})}
</div>
)}
</section>
</Panel>
)
}
23 changes: 11 additions & 12 deletions src/components/admin/projects/NeedsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import { useState, useTransition, type FormEvent } from 'react'
import { useTranslations } from 'next-intl'
import { toast } from 'sonner'
import { apiFetch } from '@/lib/api/client'
import { cn } from '@/lib/utils'
import { designPrimitive } from '@/lib/design-system'
import { Panel } from '@/components/ui/Panel'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
Expand Down Expand Up @@ -108,12 +107,10 @@ export function NeedsPanel({ slug, initialNeeds }: Props) {
}

return (
<section className={cn(designPrimitive.surface.card, 'p-4 sm:p-5 mb-5')}>
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<div className="min-w-0">
<h2 className="text-base font-semibold text-text-primary">{t('needs.title')}</h2>
<p className="text-xs text-text-tertiary mt-0.5">{t('needs.subtitle')}</p>
</div>
<Panel
title={t('needs.title')}
subtitle={t('needs.subtitle')}
action={
<Button
type="button"
variant="primary"
Expand All @@ -124,12 +121,14 @@ export function NeedsPanel({ slug, initialNeeds }: Props) {
<Plus className="h-3.5 w-3.5" />
{t('needs.new')}
</Button>
</div>
}
className="mb-5"
>

{adding && (
<form
onSubmit={handleAdd}
className="grid gap-3 grid-cols-1 sm:grid-cols-2 mb-5 p-3 rounded-lg border border bg-surface-raised dark:bg-surface-base/3"
className="grid gap-3 grid-cols-1 sm:grid-cols-2 mb-5 p-3 rounded-lg border bg-surface-raised dark:bg-surface-base/3"
>
<Select name="type" variant="elevated">
{Object.values(NEED_TYPES).map(v => (
Expand Down Expand Up @@ -169,7 +168,7 @@ export function NeedsPanel({ slug, initialNeeds }: Props) {
{needs.map(need => (
<div
key={need.id}
className="rounded-lg border border p-3 space-y-2"
className="rounded-lg border p-3 space-y-2"
>
{/* Row 1 — main editable line. Stacks on mobile, lays out responsively on larger screens */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2">
Expand Down Expand Up @@ -252,6 +251,6 @@ export function NeedsPanel({ slug, initialNeeds }: Props) {
))}
</div>
)}
</section>
</Panel>
)
}
Loading
Loading