diff --git a/.husky/pre-commit b/.husky/pre-commit index 9e972a9..2ffefef 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -73,8 +73,9 @@ fi # 6. Web checks — only when web/ files are staged. if [ -n "$(staged_match '^web/')" ]; then - echo "→ web: prettier --check" - ( cd web && pnpm run format:check ) + echo "→ web: prettier --write" + ( cd web && pnpm run format ) + git add $(git diff --cached --name-only --diff-filter=ACMR | grep '^web/' | tr '\n' ' ') echo "→ web: eslint" ( cd web && pnpm run lint ) diff --git a/backend/internal/domain/market/handler.go b/backend/internal/domain/market/handler.go index f958be6..fa7a423 100644 --- a/backend/internal/domain/market/handler.go +++ b/backend/internal/domain/market/handler.go @@ -2,6 +2,7 @@ package market import ( "errors" + "log/slog" "net/http" "strconv" @@ -30,6 +31,7 @@ func (h *Handler) Search(c *gin.Context) { //nolint:dupl // similar structure to markets, total, err := h.service.Search(c.Request.Context(), params) if err != nil { + slog.ErrorContext(c.Request.Context(), "market search failed", "error", err) apiErr := apierror.Internal("failed to search markets") c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) return @@ -77,6 +79,7 @@ func (h *Handler) GetBySlug(c *gin.Context) { c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) return } + slog.ErrorContext(c.Request.Context(), "market get by slug failed", "slug", slug, "error", err) apiErr := apierror.Internal("failed to get market") c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) return diff --git a/web/docs/design-system.md b/web/docs/design-system.md new file mode 100644 index 0000000..8b52f57 --- /dev/null +++ b/web/docs/design-system.md @@ -0,0 +1,172 @@ +# Marktvogt Design System — Burgund + +Established May 2026 via Claude Design handoff (bundle: `marktvogt-de.tar.gz`). + +## Identity + +**Burgund** is the locked visual identity for Marktvogt. The name comes from the deep sealing-wax red at its core. + +Design principles chosen in the original session: + +- **Editorial-classical** — Cormorant Garamond display, EB Garamond body, editorial proportions +- **Mid-density** — enough whitespace to breathe, enough content to inform +- **Minimal decoration** — one ornament glyph (✦), drop-caps, double rules only. No rounded corners, no gradient badges, no color-coded semantic variants. +- **Body-lead hierarchy** — lead paragraph at 22px italic, then 16/15/14px body; display headlines at 76/56/44px + +## Tokens + +Source of truth: `web/src/app.css` (`@theme` block and `:root.dark` overrides). + +| Token | Light | Dark | +| --------------------- | -------------------------------- | ------------------------ | +| `--color-bg` | `#f5efe4` (warm parchment) | `#0f0c0a` (deep ink) | +| `--color-surface` | `#ffffff` | `#191411` | +| `--color-surface-alt` | `#ece4d4` | `#241c17` | +| `--color-ink` | `#181410` | `#f0e6d2` | +| `--color-ink-soft` | `#3a322a` | `#c0b094` | +| `--color-ink-muted` | `#6e6253` | `#74644f` | +| `--color-rule` | `#181410` | `#3a2e22` | +| `--color-rule-soft` | `#c9b58c` | `#2a221d` | +| `--color-accent` | `#9a1e2c` (sealing-wax burgundy) | `#d86268` (halbton rose) | +| `--color-accent-soft` | `#c84858` | `#8a2a32` | +| `--color-on-accent` | `#f5efe4` | `#0f0c0a` | + +The dark accent is the "Halbton" step chosen from the design session — midway between the original loud `#e84a5e` and the subdued `#c87a7a`. The dark Submit-CTA "Bordeau block" uses `surface-alt #241c17` with cream `ink` foreground. + +## Typography + +| Role | Font | Usage | +| -------------- | ------------------ | ------------------------------------------------------------------- | +| `font-display` | Cormorant Garamond | Headlines, section titles, drop-caps | +| `font-serif` | EB Garamond | Body text, nav links, buttons, table content | +| `font-sans` | Inter | Reserved; currently unused in UI — available for UI forms if needed | +| `font-mono` | JetBrains Mono | CAPS labels, tags, mono counters | + +All fonts self-hosted under `web/static/fonts/`. Variable fonts covering the declared weight ranges. + +### Type scale (derived from design files) + +| Use | Size | Weight | Font | +| --------------- | ----------- | ------- | --------------------------------------- | +| Display hero | 76–88px | 500 | display | +| Display large | 56px | 500 | display | +| Display medium | 44px | 500 | display | +| Display small | 24px | 400–500 | display | +| Lead / intro | 22px italic | 400 | serif | +| Body large | 19px | 400 | serif | +| Body | 16px | 400 | serif | +| Body small | 15px | 400 | serif | +| UI text | 14px | 400 | serif | +| Caption | 13px | 400 | serif | +| Mono caps large | 11px | 400 | mono + uppercase + tracking 0.18em | +| Mono caps | 10px | 400 | mono + uppercase + tracking 0.12–0.15em | +| Mono caps small | 9px | 400 | mono + uppercase + tracking 0.1em | + +## Atoms + +Source files: `web/src/lib/components/atoms/` + +### `MarktvogtMark` + +The shield-M logo. Props: `size` (default 32). Uses `currentColor` — inherits from parent element color. + +```svelte + +``` + +The SVG source lives at `web/src/lib/assets/marktvogt-logo.svg`. + +### `Caps` + +Mono uppercase label. Props: `size` (px, default 11), `color` (CSS value, defaults to `ink-muted`). + +```svelte +Hessen · Nr. 015 +``` + +### `Tag` + +Pill/badge. Props: `accent` (boolean, default false). Accent variant: bg + border in accent color, on-accent foreground. + +```svelte +Empfohlen +Burg +``` + +### `Rule` + +Section divider. Props: `kind`: `'thin'` | `'double'` | `'ornament'` (default `'thin'`). + +```svelte + + + + + + +``` + +### `Heraldry` + +Procedural heraldic SVG by string seed. Used as market card hero fallback when no real photo is available. Props: `seed` (string), `palette` (`{ a, b, bg, fg }`). + +```svelte + +``` + +8 deterministic variants: Stripes, Checky, Chevron, Banner, Tower, Cross, Saltire, Fleury. + +## Decoration vocabulary + +**Allowed:** + +- `✦` ornament glyph — ornament Rule, section transitions +- Drop-cap — first character of lead paragraph, 40–56px Cormorant Garamond, accent color +- Double rule — between major page sections +- Section headers: mono-caps label + thin rule below +- Breadcrumb line: mono-caps "Verzeichnis · Region · Nr. XXX" + +**Forbidden:** + +- Gold underline on headings (the old `h1::after` accent-400 stripe — removed) +- Color-coded semantic Alert variants (info/success/warning/error with distinct hues) — use `surface-alt + border-rule-soft` for neutral blocks, `border-accent` for errors only +- Rounded corners (`rounded-*`) on content elements — squares only; `rounded-sm` allowed on focus rings only +- Badge pills with backgrounds other than accent (the full surface-alt + rule-soft pair only) + +## Dark mode + +Dark mode applies via `.dark` class on `` (set by `lib/theme.ts`, user-controlled via ThemeToggle). Three states: light / dark / system. + +The dark "Bordeau block" pattern (used in Submit-CTA, strong CTAs): + +```css +background: var(--color-surface-alt); /* #241c17 */ +color: var(--color-ink); /* #f0e6d2 cream */ +accent: var(--color-accent); /* #d86268 */ +``` + +## Voice & tone + +Editorial-warm. "Hannes" is the editorial voice — a Kunstfigur (constructed character) representing the collective editorial team. The disclaimer appears under his signature: + +> ✦ — Hannes, der Marktvogt · Hessen · Met-Brauer · Lagergänger seit 2003 +> _eine Kunstfigur · die Redaktion arbeitet kollektiv_ + +This is intentional and not removable. See `chat1.md` lines 860–895 for the design decision. + +## Open items (Phase 1 does not address) + +- Photography pipeline — Lagerleben articles, camp profiles, real market photos +- OCR/AI program parsing — Submit-Flow optional upload step +- Real-time messaging — dashboard inboxes use REST for Phase 4b; WebSocket later +- Newsletter/Saisonbrief backend — Home form ships as non-functional in Phase 2 + +## Phase roadmap + +| Phase | Branch | Status | +| ---------------------------------------------------------------------------------- | ------------------------- | ----------- | +| 1 · Design system | `feat/burgund-redesign` | In progress | +| 2 · Public surfaces (Startseite, Verzeichnis, Detail, Kalender, Karte, Lagerleben) | `feat/burgund-public` | Planned | +| 3 · Flows (Auth, Submit-Flow) | `feat/burgund-flows` | Planned | +| 4a · Backend (roles, groups, applications, Lagerleben CMS) | `feat/burgund-backend` | Planned | +| 4b · Dashboards (Veranstalter, Admin, Händler, Lager) | `feat/burgund-dashboards` | Planned | diff --git a/web/src/app.css b/web/src/app.css index fa9170c..7c0bbfd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -2,154 +2,211 @@ /* ── Fonts ───────────────────────────────────────────────── */ +/* Cormorant Garamond — display */ @font-face { - font-family: 'MedievalSharp'; - src: url('/fonts/medievalsharp-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Crimson Pro'; - src: url('/fonts/crimsonpro-400.woff2') format('woff2'); + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400-ext.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; } - @font-face { - font-family: 'Crimson Pro'; - src: url('/fonts/crimsonpro-400i.woff2') format('woff2'); - font-weight: 400; + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400i-ext.woff2') format('woff2'); + font-weight: 400 700; font-style: italic; font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400i.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } -/* ── Dark mode variant: .dark class only (JS resolves system preference) ── */ +/* EB Garamond — body serif */ +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400-ext.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400i-ext.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400i.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* Inter — sans UI */ +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-400-ext.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-400.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* JetBrains Mono — mono caps labels */ +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono-400-ext.woff2') format('woff2'); + font-weight: 100 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono-400.woff2') format('woff2'); + font-weight: 100 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ── Dark mode: .dark class (JS resolves system preference) ── */ @custom-variant dark (&:is(.dark *)); -/* ── Theme tokens ────────────────────────────────────────── */ +/* ── Burgund design token system ─────────────────────────── */ @theme { - /* Forest green primary */ - --color-primary-50: oklch(0.97 0.02 150); - --color-primary-100: oklch(0.93 0.04 150); - --color-primary-200: oklch(0.86 0.08 150); - --color-primary-300: oklch(0.77 0.13 150); - --color-primary-400: oklch(0.67 0.16 150); - --color-primary-500: oklch(0.55 0.16 150); - --color-primary-600: oklch(0.47 0.14 150); - --color-primary-700: oklch(0.4 0.12 150); - --color-primary-800: oklch(0.33 0.09 150); - --color-primary-900: oklch(0.27 0.07 150); - --color-primary-950: oklch(0.18 0.05 150); - - /* Gold / amber accent */ - --color-accent-50: oklch(0.98 0.02 75); - --color-accent-100: oklch(0.94 0.06 70); - --color-accent-200: oklch(0.88 0.11 65); - --color-accent-300: oklch(0.82 0.15 60); - --color-accent-400: oklch(0.75 0.16 58); - --color-accent-500: oklch(0.68 0.16 55); - --color-accent-600: oklch(0.58 0.14 55); - --color-accent-700: oklch(0.48 0.11 55); - --color-accent-800: oklch(0.4 0.08 55); - --color-accent-900: oklch(0.32 0.06 55); - --color-accent-950: oklch(0.22 0.04 55); - - /* Warm stone neutrals (constant scale — dark mode uses dark: utilities) */ - --color-stone-50: oklch(0.98 0.005 70); - --color-stone-100: oklch(0.96 0.008 60); - --color-stone-200: oklch(0.92 0.01 55); - --color-stone-300: oklch(0.87 0.012 50); - --color-stone-400: oklch(0.71 0.013 55); - --color-stone-500: oklch(0.56 0.013 58); - --color-stone-600: oklch(0.45 0.012 58); - --color-stone-700: oklch(0.38 0.011 55); - --color-stone-800: oklch(0.32 0.01 50); - --color-stone-900: oklch(0.25 0.008 45); - --color-stone-950: oklch(0.17 0.006 40); - - /* Semantic surface colors (overridden in .dark via CSS vars) */ - --color-parchment: oklch(0.96 0.012 70); - --color-vellum: oklch(0.99 0.006 70); - - /* Danger / brick red */ - --color-danger-50: oklch(0.97 0.015 25); - --color-danger-100: oklch(0.93 0.04 25); - --color-danger-200: oklch(0.87 0.08 25); - --color-danger-300: oklch(0.78 0.12 25); - --color-danger-400: oklch(0.68 0.15 25); - --color-danger-500: oklch(0.58 0.16 25); - --color-danger-600: oklch(0.5 0.15 25); - --color-danger-700: oklch(0.42 0.12 25); - --color-danger-800: oklch(0.35 0.09 25); - --color-danger-900: oklch(0.28 0.06 25); - --color-danger-950: oklch(0.2 0.04 25); + /* Color tokens */ + --color-bg: #f5efe4; + --color-surface: #ffffff; + --color-surface-alt: #ece4d4; + --color-ink: #181410; + --color-ink-soft: #3a322a; + --color-ink-muted: #6e6253; + --color-rule: #181410; + --color-rule-soft: #c9b58c; + --color-accent: #9a1e2c; + --color-accent-soft: #c84858; + --color-on-accent: #f5efe4; /* Typography */ - --font-heading: 'MedievalSharp', cursive; - --font-sans: 'Crimson Pro', 'Georgia', serif; + --font-display: 'Cormorant Garamond', 'Garamond', serif; + --font-serif: 'EB Garamond', 'Garamond', serif; + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; } -/* ── Dark surface overrides (.dark class set by JS) ──────── */ +/* ── Dark mode overrides (.dark on ) ───────────────── */ :root.dark { color-scheme: dark; - --color-parchment: oklch(0.14 0.007 20); - --color-vellum: oklch(0.19 0.009 18); + --color-bg: #0f0c0a; + --color-surface: #191411; + --color-surface-alt: #241c17; + --color-ink: #f0e6d2; + --color-ink-soft: #c0b094; + --color-ink-muted: #74644f; + --color-rule: #3a2e22; + --color-rule-soft: #2a221d; + --color-accent: #d86268; + --color-accent-soft: #8a2a32; + --color-on-accent: #0f0c0a; } /* ── Base layer ──────────────────────────────────────────── */ @layer base { body { - @apply bg-parchment text-stone-800 antialiased; + @apply bg-bg text-ink font-serif antialiased; } - .dark body { - @apply text-stone-200; - } - - /* Skip-to-content link (visible on focus only) */ + /* Skip-to-content link */ .skip-link { - @apply bg-primary-700 absolute -top-full left-4 z-50 rounded-b-lg px-4 py-2 text-sm font-medium text-white; - @apply focus:ring-primary-400 focus:top-0 focus:ring-2 focus:outline-none; + @apply bg-accent text-on-accent absolute -top-full left-4 z-50 px-4 py-2 text-sm font-medium; + @apply focus:ring-accent focus:top-0 focus:ring-2 focus:outline-none; } h1, h2, h3, h4 { - font-family: var(--font-heading); + font-family: var(--font-display); } - h1 { - @apply relative pb-3; - } - - h1::after { - content: ''; - @apply bg-accent-400 absolute bottom-0 left-0 h-0.5 w-16; - } - - h1:is([class*='text-center'])::after { - @apply left-1/2 -translate-x-1/2; - } - - /* ── Shared form control styles ───────────────────────── */ + /* ── Form controls ───────────────────────────────────── */ input:where( :not([type='hidden']):not([type='checkbox']):not([type='radio']):not([type='submit']):not( [type='button'] ):not([type='reset']) ), - textarea { - @apply bg-vellum block w-full rounded-lg border border-stone-300 px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors; - @apply placeholder-stone-400; - @apply focus:border-primary-500 focus:ring-primary-500 focus:ring-2 focus:outline-none; + textarea, + select { + @apply border-rule-soft bg-surface text-ink block w-full border px-3 py-2 text-sm shadow-sm transition-colors; + @apply placeholder-ink-muted; + @apply focus:border-accent focus:ring-accent focus:ring-1 focus:outline-none; + border-radius: 0; } .dark @@ -158,23 +215,18 @@ [type='button'] ):not([type='reset']) ), - .dark textarea { - @apply border-stone-600 bg-stone-800 text-stone-100 placeholder-stone-500; + .dark textarea, + .dark select { + @apply border-rule-soft bg-surface-alt text-ink placeholder-ink-muted; } - /* Error state */ input[aria-invalid='true'], - textarea[aria-invalid='true'] { - @apply border-danger-400 text-danger-900 placeholder-danger-300; + textarea[aria-invalid='true'], + select[aria-invalid='true'] { + @apply border-accent-soft; } - .dark input[aria-invalid='true'], - .dark textarea[aria-invalid='true'] { - @apply border-danger-500 text-danger-200; - } - - /* Focus ring offset matches background */ *:focus-visible { - --tw-ring-offset-color: var(--color-parchment); + --tw-ring-offset-color: var(--color-bg); } } diff --git a/web/src/app.html b/web/src/app.html index 6bbb9c9..54097c3 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -7,18 +7,18 @@ - - + + svelte-logo + + + + + diff --git a/web/src/lib/assets/marktvogt-logo.svg b/web/src/lib/assets/marktvogt-logo.svg new file mode 100644 index 0000000..4f66a53 --- /dev/null +++ b/web/src/lib/assets/marktvogt-logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/src/lib/components/admin/MarketForm.svelte b/web/src/lib/components/admin/MarketForm.svelte index 6a9263c..7feb892 100644 --- a/web/src/lib/components/admin/MarketForm.svelte +++ b/web/src/lib/components/admin/MarketForm.svelte @@ -270,18 +270,14 @@ {#if error} -