Compare commits

...

2 Commits

Author SHA1 Message Date
Timo
a1b90eb245 Untrack .agents directory and add it to .gitignore
.agents/ contains local tooling/skill files that got committed by
mistake; .gitignore already had .agent (singular) but not the plural
folder actually in use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:34:59 +02:00
b6512a318d Bug fixes 2026-07-24 16:33:44 +02:00
15 changed files with 469 additions and 51 deletions

7
.gitignore vendored
View File

@@ -39,3 +39,10 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.impeccable
.codex
.claude
.agent
.agents
.claudeignore

209
DESIGN.md Normal file
View File

@@ -0,0 +1,209 @@
---
name: Entscheidomat
description: Deutschsprachige Zufalls-Tools mit warmem Porzellan-Papier-Design und Smaragd-Akzenten
colors:
paper: "#faf8f3"
surface: "#ffffff"
ink: "#111412"
ink-soft: "#45504a"
line: "#e0eae4"
emerald: "#0d7a57"
emerald-strong: "#095c41"
emerald-soft: "#e6f7f0"
emerald-line: "#b2e5d1"
clay: "#b4633f"
clay-soft: "#f6eae2"
typography:
display:
fontFamily: "Cormorant Garamond, Georgia, serif"
fontSize: "clamp(2.5rem, 6vw, 4.5rem)"
fontWeight: 600
lineHeight: "1.1"
letterSpacing: "-0.01em"
headline:
fontFamily: "Inter, system-ui, sans-serif"
fontSize: "1.875rem"
fontWeight: 700
lineHeight: "1.2"
letterSpacing: "-0.02em"
title:
fontFamily: "Inter, system-ui, sans-serif"
fontSize: "1.25rem"
fontWeight: 600
lineHeight: "1.3"
body:
fontFamily: "Inter, system-ui, sans-serif"
fontSize: "1rem"
fontWeight: 400
lineHeight: "1.6"
label:
fontFamily: "Inter, system-ui, sans-serif"
fontSize: "0.75rem"
fontWeight: 600
lineHeight: "1"
letterSpacing: "0.15em"
rounded:
sm: "8px"
md: "16px"
lg: "24px"
full: "9999px"
spacing:
xs: "4px"
sm: "8px"
md: "16px"
lg: "24px"
xl: "32px"
2xl: "48px"
3xl: "64px"
components:
button-primary:
backgroundColor: "{colors.emerald}"
textColor: "{colors.surface}"
rounded: "{rounded.full}"
padding: "12px 28px"
button-primary-hover:
backgroundColor: "{colors.emerald-strong}"
button-secondary:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.full}"
padding: "12px 24px"
card:
backgroundColor: "{colors.surface}"
rounded: "{rounded.lg}"
padding: "24px"
input:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.md}"
padding: "12px 16px"
---
# Design System: Entscheidomat
## Overview
**Creative North Star: "Das analoge Porzellan-Labor"**
Entscheidomat vereint die visuelle Wärme von tactilem Porzellan-Papier (`#faf8f3`) mit der Präzision eines modernen digitalen Werkzeugs. Statt lauter Kitsch-Visuals oder kalter SaaS-Gradients basiert das Design-System auf organischen, edlen Smaragd-Tönen (`#0d7a57`), feinen Begrenzungslinien (`#e0eae4`) und subtilen 3D-Zufallselementen (gebürstetes Messing für Münzen, mattes Anthrazit für Magic 8-Ball).
Das System legt höchsten Wert auf flüssiges visuelles Feedback, taktile Reaktion bei Klicks/Touches und eine ruhige, haptische Atmosphäre. Serifenstarke Akzente in *Cormorant Garamond* setzen spielerische Highlights im Markennamen und in Überschriften, während *Inter* für perfekte Lesbarkeit in Formularen, Tabellen und Interaktionsflächen sorgt.
**Key Characteristics:**
- **Tactile Paper Canvas:** Warmes Porzellan-Papier (`#faf8f3`) als ruhiger, angenehmer Hintergrund für alle Seiten.
- **Smaragd-Präzision:** Gezielter Einsatz von tiefem Smaragdgrün (`#0d7a57`) für primäre Aktionen, Erfolgszustände und Markenakzente.
- **Typografischer Kontrast:** Zusammenspiel aus sachlicher Sans-Serif (*Inter*) und eleganter Display-Serif (*Cormorant Garamond*).
- **Abgerundete Taktilität:** Vollabgerundete Pill-Buttons (`9999px`) für Aktionen und weiche Container-Ecken (`24px`).
- **Subtile Tiefe:** Sanfte mehrschichtige Schatten statt schwerer Schlagschatten; Interaktionen erzeugen sanftes Anheben (*Lift*).
## Colors
Die Farbpalette ist warm, natürlich und fokussiert. Ein einzelner Hauptakzent in Smaragdgrün leitet den Blick, ergänzt durch einen warmen Terracotta-Farbton für seltene Sekundärakzente.
### Primary
- **Porcelain Paper** (`#faf8f3`): Globaler Seitenhintergrund; schafft eine warme, papierartige Grundstimmung.
- **Deep Ink** (`#111412`): Primäre Textfarbe und dunkle Bedienelemente für maximalen Kontrast.
### Accent
- **Emerald Accent** (`#0d7a57`): Primärer Markenakzent; wird für CTA-Buttons, aktive Zustände und Markenhighlights verwendet.
- **Emerald Strong** (`#095c41`): Hover- und Active-Zustand für Smaragd-Elemente.
- **Emerald Soft Tint** (`#e6f7f0`): Sanfter Hintergrund-Tint für Tooltips, ausgewählte Elemente und Badges.
- **Emerald Line** (`#b2e5d1`): Zarter Rahmen für Smaragd-Badges und Hover-Zustände.
### Secondary
- **Terracotta Clay** (`#b4633f`): Warmer Sekundärakzent für ausgewählte Hervorhebungen und spielerische Akzente.
- **Terracotta Soft Tint** (`#f6eae2`): Tint-Hintergrund für sekundäre Badges.
### Neutral
- **Clean Surface** (`#ffffff`): Container-, Karten- und Modal-Hintergründe für klare Abhebung vom Paper-Background.
- **Soft Ink** (`#45504a`): Sekundäre Textfarbe für Erklärungen, Untertitel und Metadaten.
- **Warm Line** (`#e0eae4`): Subtile Trennlinien und Kartenränder.
### Named Rules
**The Emerald Sparsensity Rule.** Der Smaragd-Akzent darf auf keiner Seite mehr als 10% der visuellen Fläche einnehmen. Seine Seltenheit verleiht ihm Wertigkeit und Leuchtkraft.
**The No-Pure-Black Rule.** Reines Schwarz (`#000000`) ist für Text und Flächen verboten. Stattdessen wird stets das warme Deep Ink (`#111412`) verwendet.
## Typography
Die Typografie basiert auf dem Kontrast zwischen der funktionalen Sans-Serif *Inter* und der charakterstarken Serif *Cormorant Garamond*.
**Display Font:** Cormorant Garamond, Georgia, serif
**Body Font:** Inter, system-ui, -apple-system, sans-serif
### Hierarchy
- **Display** (Semibold 600 / Italic, `clamp(2.5rem, 6vw, 4.5rem)`, Line-Height `1.1`): Hero-Überschriften und charakteristische Markenwörter (z. B. *omat* in *Entscheidomat*).
- **Headline** (Bold 700, `1.875rem` / `30px`, Line-Height `1.2`, Letter-Spacing `-0.02em`): Sektionsüberschriften und Tool-Titel.
- **Title** (Semibold 600, `1.25rem` / `20px`, Line-Height `1.3`): Kartenüberschriften und Dialog-Titel.
- **Body** (Regular 400, `1rem` / `16px`, Line-Height `1.6`, max. Zeilenlänge 6575ch): Fließtext, Beschreibungen und Anleitungen.
- **Label** (Semibold 600, `0.75rem` / `12px`, Letter-Spacing `0.15em`, Uppercase): Eyebrows, Kategorie-Tags und Tabellenköpfe.
### Named Rules
**The Dual-Type Rule.** *Cormorant Garamond* wird ausschließlich für Display-Akzente und dekorative Wort-Highlights verwendet; Fließtext, Buttons, Inputs und Navigations-Links verbleiben stets in *Inter*.
## Layout
Das Layout folgt einem großzügigen, zentrierten Rastersystem mit klaren Abständen und responsiver Skalierung.
- **Container:** Maximale Breite von `1152px` (`max-w-6xl`) mit horizontalem Padding (`px-5` mobil, `px-8` ab Desktop).
- **Spatial Grid:** 8px-Rhythmus (`8px`, `16px`, `24px`, `32px`, `48px`, `64px`).
- **Responsive Layout:** 1-spaltig auf Mobilgeräten, 2- bis 3-spaltige Grid-Layouts ab `md` (`768px`) und `lg` (`1024px`).
- **Header:** Fixierte Navigation (`64px` Höhe) mit `backdrop-blur-xl` und fließendem Übergang beim Scrollen.
## Elevation & Depth
Entscheidomat verwendet tonal geschichtetes Paper-on-Surface Design anstelle von schweren Schlagschatten. Tiefe entsteht primär durch den Kontrast zwischen Porzellan-Hintergrund (`#faf8f3`) und weißen Oberflächen (`#ffffff`), ergänzt durch sanfte, diffuse Umgebungsschatten.
### Shadow Vocabulary
- **Soft Ambient** (`0 2px 4px rgb(17 20 18 / 0.03), 0 12px 36px rgb(17 20 18 / 0.06)`): Standard-Schatten für ruhende Karten und interaktive Container.
- **Lifted Elevation** (`0 4px 12px rgb(17 20 18 / 0.06), 0 24px 52px rgb(17 20 18 / 0.10)`): Hover-Zustand für Karten und aktive Tool-Elemente.
- **CTA Focus Glow** (`0 12px 28px rgb(17 20 18 / 0.22)`): Hervorhebung für primäre Aktionsbuttons.
### Named Rules
**The Flat-at-Rest Rule.** Elemente ruhen flach mit minimalem Umgebungsleuchten (`Soft Ambient`). Tiefe entsteht dynamisch erst bei Benutzer-Interaktion (Hover/Focus).
## Shapes
Das Formensystem kombiniert organisch gerundete Ecken mit präzisen Kanten.
- **Buttons & Pills:** Vollabgerundet (`9999px`) für haptisches, freundliches Aussehen.
- **Karten & Container:** `24px` Radius (`rounded-3xl` / `rounded-2xl`) für moderne Softness.
- **Inputs & Formulare:** `16px` Radius (`rounded-xl`) mit feiner `1px` Umrandung (`#e0eae4`).
- **Focus Rings:** `2px` durchgezogene Smaragd-/Ink-Linie mit `2px` Offset und `6px` Radius.
## Components
### Buttons
- **Shape:** Full Pill (`border-radius: 9999px`)
- **Primary:** Background `#0d7a57`, Text `#ffffff`, Padding `12px 28px`, Font Weight 600.
- **Primary Hover:** Background `#095c41`, Scale-up Transition `scale(1.02)`.
- **Secondary:** Background `#ffffff`, Text `#111412`, Border `1px solid #e0eae4`, Shadow `Soft Ambient`.
- **Sound/Icon Button:** Kreisförmig (`40px x 40px`), Background `#ffffff`, Border `#e0eae4`, Icon `#45504a`.
### Cards & Tool-Container
- **Corner Style:** `24px` (`rounded-3xl`)
- **Background:** `#ffffff` (`bg-surface`)
- **Border:** `1px solid #e0eae4`
- **Internal Padding:** `24px` bis `32px`
- **Shadow:** `Soft Ambient` in Ruhe, transformiert zu `Lifted Elevation` bei Hover.
### Inputs / Text Fields
- **Style:** Background `#ffffff`, Text `#111412`, Border `1px solid #e0eae4`, Radius `16px`.
- **Focus:** Border-Color `#0d7a57`, Focus-Ring `2px solid #0d7a57` mit `2px` Offset.
### Navigation
- **Style:** Fixierte Leiste (`h-16`), Transparent im Ruhezustand an der Spitze, wechselt beim Scrollen zu `bg-paper/95` mit `backdrop-blur-xl` und zartem unteren Rand (`#e0eae4`).
## Do's and Don'ts
### Do:
- **Do** immer das warme Porzellan-Papier (`#faf8f3`) als Seitenhintergrund verwenden.
- **Do** Smaragdgrün (`#0d7a57`) gezielt für die wichtigsten primären Interaktionen einsetzen.
- **Do** Buttons stets als Abgerundete Pills (`rounded-full`) gestalten.
- **Do** flüssige Framer-Motion / GSAP Übergänge bei Zufalls-Auslosungen anbieten.
### Don't:
- **Don't** kühle bläuliche Grautöne oder schrill-leuchtende Gradienten verwenden.
- **Don't** reines Schwarz (`#000000`) für Text oder Flächen nutzen.
- **Don't** Gedankenstriche oder Bindestrich-Separaten in Eyebrows oder Titeln verwenden.
- **Don't** harte Schlagschatten oder knallige Neonfarben einbauen.

46
PRODUCT.md Normal file
View File

@@ -0,0 +1,46 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Alltags- & Freizeitnutzer in spontanen Entscheidungssituationen (z. B. schnelle Ja/Nein-Entscheidungen, Spieleabende, Auswahl von Essen oder Freizeitaktivitäten) sowie kleinere Gruppen und Teams.
## Product Purpose
Entscheidomat bietet eine Sammlung deutschsprachiger, kostenloser und intuitiver Zufalls-Tools (Ja/Nein-Generator, Münzwurf, Glücksrad, Würfel, Zufallszahl-Generator, Magic 8-Ball, Namen-Auslosung). Das Produkt nimmt Nutzenden spielerisch den Entscheidungsdruck ab und bringt Leichtigkeit in spontane Alltagssituationen.
## Positioning
Maximale Flexibilität & Anpassbarkeit der einzelnen Zufalls-Tools kombiniert mit schnellen Ladezeiten, modernem visuellen Feedback und hoher Nutzerfreundlichkeit ohne Barrieren.
## Operating Context
Spontane Nutzung auf Smartphone, Tablet oder Desktop-Browser im Alltagsgeschehen, bei Treffen mit Freunden, in der Freizeit oder bei der Tagesplanung.
## Capabilities and Constraints
- **Funktionalität:** 7 Kern-Tools (Ja/Nein-Generator, Münze, Glücksrad, Würfel, Zufallszahl, Magic 8-Ball, Namen-Auslosung).
- **Technischer Stack:** Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS v4, Framer Motion & GSAP.
- **Datenhaltung:** Keine Server-Datenbank; Einstellungsdaten und Anpassungen werden lokal im Browser (LocalStorage) gespeichert.
- **Infrastructure:** Docker Multi-Stage Build, Caddy Reverse Proxy, Umami Cloud (EU-Region, anonyme Statistiken).
- **Sprache:** Deutschsprachig.
## Brand Commitments
- **Markenname:** Entscheidomat (`entscheidomat.com`)
- **Tonalität & Voice:** Locker, sympathisch, klar & intuitiv.
- **Qualitätsanspruch:** Kostenlos, schnell und barrierefrei nutzbar.
## Evidence on Hand
- Funktionsfähige Web-App mit 7 integrierten Tool-Komponenten unter `components/tools/`
- SEO-Vorbereitungen mit Open-Graph Previews, `sitemap.xml` und `robots.txt`
- Vorhandene Dokumentation und Deployment-Guides (`README.md`, `ZUKUNFTSPLAN.md`)
## Product Principles
1. **Sofortige Nutzbarkeit:** Keine Registrierung, keine Hürden das gewählte Tool steht sofort zur Interaktion bereit.
2. **Maximale Anpassbarkeit:** Hohe Flexibilität bei Eingaben, Namen, Listen und Einstellungen für unterschiedliche Einsatzzwecke.
3. **Visuelle Leichtigkeit & Feedback:** Ansprechende Animationen und deutliches haptisches/visuelles Feedback machen das Auslosen unterhaltsam.
4. **Datenschutz durch Design:** Keine Speicherung personenbezogener Daten; Einstellungen bleiben vollständig im Browser des Nutzers.
## Accessibility & Inclusion
- Vollständige Tastatur- und Screenreader-Bedienbarkeit aller Tools.
- WCAG AA Kontrastwerte und deutliche Fokus-Zustände.

View File

@@ -315,7 +315,7 @@ export default function Hero() {
aria-label="Zu den Tools scrollen"
className="absolute bottom-6 left-1/2 z-10 hidden -translate-x-1/2 flex-col items-center gap-1 text-ink-soft transition-colors hover:text-ink md:flex"
>
<span className="text-[11px] font-semibold uppercase tracking-[0.2em]">
<span className="text-xs font-semibold uppercase tracking-[0.15em]">
Scroll
</span>
<ArrowDown className="animate-bob h-4 w-4" />

View File

@@ -27,7 +27,7 @@ export default function LegalLayout({
ladungsfähige Geschäftsanschrift vollständig ergänzt werden.
</div>
<div className="mt-10 space-y-8 text-[15px] leading-relaxed text-ink-soft [&_h2]:font-display [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-ink [&_a]:text-[#0d7a57] [&_a]:underline [&_a]:underline-offset-2 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5">
<div className="mt-10 space-y-8 text-base leading-relaxed text-ink-soft [&_h2]:font-display [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-ink [&_a]:text-[#0d7a57] [&_a]:underline [&_a]:underline-offset-2 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5">
{children}
</div>
</div>

View File

@@ -96,7 +96,7 @@ export default function Nav() {
</button>
{toolsOpen && (
<div role="menu" className="absolute right-0 top-[calc(100%+0.7rem)] w-80 overflow-hidden rounded-3xl border border-line bg-surface p-2 shadow-[0_22px_55px_rgba(17,20,18,0.16)]">
<p className="px-3 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-soft">Alle Zufalls-Tools</p>
<p className="px-3 pb-2 pt-1 text-xs font-semibold uppercase tracking-[0.15em] text-ink-soft">Alle Zufalls-Tools</p>
{TOOLS.map((tool) => (
<Link key={tool.href} href={tool.href} role="menuitem" onClick={closeMenus} className="group flex items-center justify-between rounded-2xl px-3 py-2.5 transition-colors hover:bg-[#e6f7f0]">
<span><span className="block text-sm font-semibold text-ink group-hover:text-[#0d7a57]">{tool.label}</span><span className="block text-xs text-ink-soft">{tool.hint}</span></span>
@@ -121,7 +121,7 @@ export default function Nav() {
{mobileOpen && (
<div id="mobile-menu" className="border-t border-line/70 bg-paper px-5 pb-6 pt-4 shadow-xl sm:hidden">
<div className="mx-auto max-w-6xl">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-soft">Tools auswählen</p>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-ink-soft">Tools auswählen</p>
<div className="grid gap-1">
{TOOLS.map((tool) => <Link key={tool.href} href={tool.href} onClick={closeMenus} className="flex items-center justify-between rounded-2xl px-4 py-3 hover:bg-[#e6f7f0]"><span><span className="block font-semibold text-ink">{tool.label}</span><span className="text-xs text-ink-soft">{tool.hint}</span></span><span className="text-[#0d7a57]"></span></Link>)}
</div>

View File

@@ -124,7 +124,7 @@ export default function Playground() {
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[#0d7a57]/20 bg-[#e6f7f0] text-[#0d7a57] shadow-sm transition-all duration-300 group-hover:scale-110 group-hover:border-[#0d7a57] group-hover:bg-[#0d7a57] group-hover:text-white">
<Icon className="h-6 w-6" strokeWidth={2.1} />
</div>
<span className="inline-flex items-center rounded-full border border-line bg-paper px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-ink-soft">
<span className="inline-flex items-center rounded-full border border-line bg-paper px-3 py-1 text-xs font-semibold uppercase tracking-wider text-ink-soft">
{tool.badge}
</span>
</div>

View File

@@ -139,7 +139,7 @@ export default function Preloader() {
</svg>
<p
ref={wordRef}
className="mt-6 text-[11px] font-semibold uppercase tracking-[0.28em] text-ink-soft opacity-0"
className="mt-6 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft opacity-0"
>
Entscheidomat
</p>

View File

@@ -130,9 +130,10 @@ export default function CoinFlip() {
<input
value={frontLabel}
onChange={(e) => setFrontLabel(e.target.value)}
disabled={flipping}
maxLength={14}
placeholder="Kopf"
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<label className="block">
@@ -142,9 +143,10 @@ export default function CoinFlip() {
<input
value={backLabel}
onChange={(e) => setBackLabel(e.target.value)}
disabled={flipping}
maxLength={14}
placeholder="Zahl"
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
</div>

View File

@@ -208,7 +208,8 @@ export default function DiceRoller() {
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={`relative flex-1 cursor-pointer rounded-full px-3 py-2 text-sm font-semibold transition-colors duration-200 ${
disabled={rolling}
className={`relative flex-1 cursor-pointer rounded-full px-3 py-2 text-sm font-semibold transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-60 ${
mode === m.id ? "text-white" : "text-ink-soft hover:text-ink"
}`}
>
@@ -236,7 +237,7 @@ export default function DiceRoller() {
<div className="mt-4 grid grid-cols-2 gap-2">
{customFaces.map((face, i) => (
<label key={i} className="block">
<span className="mb-1 block text-[11px] font-medium text-ink-soft">
<span className="mb-1 block text-xs font-medium text-ink-soft">
Seite {i + 1}
</span>
<input
@@ -248,9 +249,10 @@ export default function DiceRoller() {
),
)
}
disabled={rolling}
maxLength={18}
placeholder={`Seite ${i + 1}`}
className="w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
className="w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
))}

View File

@@ -20,7 +20,6 @@ export default function ListPicker() {
const [display, setDisplay] = useState<string | null>(null);
const [drawing, setDrawing] = useState(false);
const [winner, setWinner] = useState<string | null>(null);
const [error, setError] = useState(false);
const lastPaintRef = useRef(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -32,10 +31,13 @@ export default function ListPicker() {
.map((s) => s.trim())
.filter(Boolean);
const insufficientItems = items.length < 2;
const draw = () => {
if (drawing) return;
if (items.length < 2) {
setError(true);
if (insufficientItems) {
setDisplay(null);
setWinner(null);
play("tick");
gsap.fromTo(
textareaRef.current,
@@ -44,7 +46,6 @@ export default function ListPicker() {
);
return;
}
setError(false);
const pick = items[Math.floor(Math.random() * items.length)];
setDrawing(true);
@@ -74,11 +75,16 @@ export default function ListPicker() {
play("win");
celebrateConfetti();
if (removeAfterDraw) {
setText(
items
.filter((item) => item !== pick)
.join("\n"),
);
setText((prevText) => {
const currentItems = prevText
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const idx = currentItems.indexOf(pick);
if (idx === -1) return prevText;
currentItems.splice(idx, 1);
return currentItems.join("\n");
});
}
},
});
@@ -95,11 +101,12 @@ export default function ListPicker() {
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
disabled={drawing}
rows={8}
placeholder={"Ein Eintrag pro Zeile, z. B.\nAnna\nBen\nClara"}
aria-label="Liste der Einträge, ein Eintrag pro Zeile"
className={`mt-4 w-full resize-none rounded-xl border bg-surface px-4 py-3 font-mono text-sm leading-relaxed outline-none transition focus:ring-2 ${
error
className={`mt-4 w-full resize-none rounded-xl border bg-surface px-4 py-3 font-mono text-sm leading-relaxed outline-none transition focus:ring-2 disabled:cursor-not-allowed disabled:opacity-60 ${
insufficientItems
? "border-clay/60 focus:border-clay/60 focus:ring-clay/20"
: "border-line focus:border-primary/60 focus:ring-primary/20"
}`}
@@ -109,12 +116,13 @@ export default function ListPicker() {
type="checkbox"
checked={removeAfterDraw}
onChange={(e) => setRemoveAfterDraw(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-primary"
disabled={drawing}
className="h-4 w-4 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
Gezogene Einträge entfernen (ohne Zurücklegen)
</label>
<p className={`mt-3 text-xs leading-relaxed ${error ? "font-semibold text-clay" : "text-ink-soft"}`}>
{error
<p className={`mt-3 text-xs leading-relaxed ${insufficientItems ? "font-semibold text-clay" : "text-ink-soft"}`}>
{insufficientItems
? "Bitte mindestens 2 Einträge eintragen."
: "Ideal für Teams, Gruppen-Reihenfolgen oder Gewinnspiele."}
</p>
@@ -133,7 +141,7 @@ export default function ListPicker() {
>
<ListChecks className="h-8 w-8 text-primary/50" />
<p className="font-display text-xl font-medium text-ink-soft">
{items.length >= 2
{!insufficientItems
? `${items.length} Einträge warten auf die Ziehung.`
: "Trage links mindestens 2 Einträge ein."}
</p>

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Pencil, Plus, Sparkles, X } from "lucide-react";
import { gsap } from "@/lib/gsap";
@@ -36,6 +36,8 @@ export default function Magic8Ball() {
const [editing, setEditing] = useState(false);
const ballRef = useRef<HTMLDivElement>(null);
const answerInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const justAddedRef = useRef(false);
const { play } = useSound();
const reduced = usePrefersReducedMotion();
@@ -90,8 +92,17 @@ export default function Magic8Ball() {
const addAnswer = () => {
if (answers.length >= MAX_ANSWERS) return;
setAnswers((list) => [...list, ""]);
justAddedRef.current = true;
};
useEffect(() => {
if (!justAddedRef.current) return;
justAddedRef.current = false;
const last = answerInputRefs.current[answers.length - 1];
last?.scrollIntoView({ block: "nearest", behavior: "smooth" });
last?.focus();
}, [answers.length]);
const removeAnswer = (i: number) => {
if (answers.length <= MIN_ANSWERS) return;
setAnswers((list) => list.filter((_, idx) => idx !== i));
@@ -138,7 +149,7 @@ export default function Magic8Ball() {
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(6px)" }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="text-center font-display text-[13px] font-semibold leading-snug text-[#dde5dd]"
className="text-center font-display text-sm font-semibold leading-snug text-[#dde5dd]"
>
{answer}
</motion.p>
@@ -210,6 +221,9 @@ export default function Magic8Ball() {
{answers.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<input
ref={(el) => {
answerInputRefs.current[i] = el;
}}
value={a}
onChange={(e) => updateAnswer(i, e.target.value)}
maxLength={60}

View File

@@ -12,6 +12,11 @@ import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
const randInt = (lo: number, hi: number) =>
lo + Math.floor(Math.random() * (hi - lo + 1));
const toInt = (raw: string) => {
const n = Math.round(Number(raw));
return Number.isFinite(n) ? n : 0;
};
export default function RandomNumber() {
const [min, setMin] = useLocalStorage("num-min", 1);
const [max, setMax] = useLocalStorage("num-max", 100);
@@ -120,9 +125,11 @@ export default function RandomNumber() {
</span>
<input
type="number"
step={1}
value={min}
onChange={(e) => setMin(Number(e.target.value))}
className="tabular w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
onChange={(e) => setMin(toInt(e.target.value))}
disabled={rolling}
className="tabular w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<label className="block">
@@ -131,9 +138,11 @@ export default function RandomNumber() {
</span>
<input
type="number"
step={1}
value={max}
onChange={(e) => setMax(Number(e.target.value))}
className="tabular w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
onChange={(e) => setMax(toInt(e.target.value))}
disabled={rolling}
className="tabular w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Plus, RotateCcw, Sparkles, X } from "lucide-react";
import { gsap } from "@/lib/gsap";
@@ -51,6 +51,8 @@ export default function WheelOfFortune() {
const pointerRef = useRef<HTMLDivElement>(null);
const rotationRef = useRef(0);
const lastTickRef = useRef(-1);
const optionInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const justAddedRef = useRef(false);
const { play } = useSound();
const reduced = usePrefersReducedMotion();
@@ -115,20 +117,49 @@ export default function WheelOfFortune() {
});
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName.toLowerCase();
if (activeTag === "input" || activeTag === "textarea") return;
if (e.code === "Space") {
e.preventDefault();
spin();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [spinning, n]);
const updateOption = (i: number, value: string) => {
if (spinning) return;
setOptions((opts) => opts.map((o, idx) => (idx === i ? value : o)));
setWinner(null);
};
const addOption = () => {
if (n >= MAX_OPTIONS) return;
if (spinning || n >= MAX_OPTIONS) return;
setOptions((opts) => [...opts, ""]);
setWinner(null);
justAddedRef.current = true;
};
useEffect(() => {
if (!justAddedRef.current) return;
justAddedRef.current = false;
const last = optionInputRefs.current[options.length - 1];
last?.scrollIntoView({ block: "nearest", behavior: "smooth" });
last?.focus();
}, [options.length]);
const removeOption = (i: number) => {
if (spinning || n <= MIN_OPTIONS) return;
setOptions((opts) => opts.filter((_, idx) => idx !== i));
setWinner(null);
};
const removeOption = (i: number) => {
if (n <= MIN_OPTIONS) return;
setOptions((opts) => opts.filter((_, idx) => idx !== i));
const resetOptions = () => {
if (spinning) return;
setOptions(DEFAULT_OPTIONS);
setWinner(null);
};
@@ -141,14 +172,19 @@ export default function WheelOfFortune() {
<div className="relative w-full max-w-[400px]">
{/* Zeiger */}
<div
ref={pointerRef}
ref={pointerRef}
aria-hidden
className="absolute -top-1.5 left-1/2 z-10 h-9 w-7 -translate-x-1/2 bg-primary shadow-[0_6px_16px_rgb(108_92_231/0.5)]"
className="absolute -top-1.5 left-1/2 z-10 h-9 w-7 -translate-x-1/2 bg-primary shadow-[0_6px_16px_rgb(13_122_87/0.35)]"
style={{
clipPath: "polygon(50% 100%, 0 0, 100% 0)",
}}
/>
<svg viewBox="0 0 420 420" className="w-full drop-shadow-[0_20px_36px_rgb(23_23_28/0.14)]">
<svg
viewBox="0 0 420 420"
role="img"
aria-label="Glücksrad mit Zufalls-Optionen"
className="w-full drop-shadow-[0_20px_36px_rgb(23_23_28/0.14)]"
>
{/* äußerer Ring */}
<circle cx={C} cy={C} r={206} fill="#ffffff" stroke="#e9e9e4" />
<g ref={wheelRef}>
@@ -199,7 +235,7 @@ export default function WheelOfFortune() {
</g>
{/* Nabe */}
<circle cx={C} cy={C} r={30} fill="#ffffff" stroke="#e9e9e4" strokeWidth={2} />
<circle cx={C} cy={C} r={9} fill="#6c5ce7" />
<circle cx={C} cy={C} r={9} fill="#0d7a57" />
</svg>
</div>
@@ -234,18 +270,16 @@ export default function WheelOfFortune() {
</div>
{/* Optionen-Editor */}
<div className="rounded-3xl border border-line bg-cream/70 p-5">
<div className="rounded-3xl border border-line bg-surface p-5 shadow-soft">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
Optionen ({n}/{MAX_OPTIONS})
</h3>
<button
type="button"
onClick={() => {
setOptions(DEFAULT_OPTIONS);
setWinner(null);
}}
className="cursor-pointer text-xs font-medium text-ink-soft underline-offset-2 transition-colors hover:text-primary hover:underline"
onClick={resetOptions}
disabled={spinning}
className="cursor-pointer text-xs font-medium text-ink-soft underline-offset-2 transition-colors hover:text-[#0d7a57] hover:underline disabled:cursor-not-allowed disabled:opacity-40"
>
Zurücksetzen
</button>
@@ -260,17 +294,21 @@ export default function WheelOfFortune() {
style={{ background: PALETTE[i % PALETTE.length] }}
/>
<input
ref={(el) => {
optionInputRefs.current[i] = el;
}}
value={option}
onChange={(e) => updateOption(i, e.target.value)}
disabled={spinning}
maxLength={24}
placeholder={`Option ${i + 1}`}
aria-label={`Option ${i + 1}`}
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
className="w-full rounded-xl border border-line bg-paper/50 px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-[#0d7a57] focus:bg-surface focus:ring-2 focus:ring-[#0d7a57]/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => removeOption(i)}
disabled={n <= MIN_OPTIONS}
disabled={spinning || n <= MIN_OPTIONS}
aria-label={`Option ${i + 1} entfernen`}
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-xl text-ink-soft transition-colors hover:bg-clay-soft hover:text-clay disabled:cursor-not-allowed disabled:opacity-30"
>
@@ -283,8 +321,8 @@ export default function WheelOfFortune() {
<button
type="button"
onClick={addOption}
disabled={n >= MAX_OPTIONS}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border-2 border-dashed border-line px-4 py-2.5 text-sm font-semibold text-ink-soft transition-colors hover:border-primary/50 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
disabled={spinning || n >= MAX_OPTIONS}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border-2 border-dashed border-line px-4 py-2.5 text-sm font-semibold text-ink-soft transition-colors hover:border-[#0d7a57] hover:bg-[#e6f7f0]/50 hover:text-[#0d7a57] disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus className="h-4 w-4" />
Option hinzufügen

83
skills-lock.json Normal file
View File

@@ -0,0 +1,83 @@
{
"version": 1,
"skills": {
"brandkit": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/brandkit/SKILL.md",
"computedHash": "735007879f5110aeae1e981a53fe74a12afde70347f9773dacd50e4697a091bf"
},
"design-taste-frontend": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/taste-skill/SKILL.md",
"computedHash": "899b84384f74f540ea5284d9b2e9234e050998b42eacc805410b518d4226c0b3"
},
"design-taste-frontend-v1": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/taste-skill-v1/SKILL.md",
"computedHash": "0a226c13d69639553cf8a7c40aa5527b026820f8ddaf7a6b263988e32ab44a27"
},
"full-output-enforcement": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/output-skill/SKILL.md",
"computedHash": "524f7c7950c3fde726101613f630140411c7d080e63be718687f4b38f919be81"
},
"gpt-taste": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/gpt-tasteskill/SKILL.md",
"computedHash": "7bd1bfdda0e51016ca222693401a65fc0dd9bf9487e0bd5b1740ba510f6f963a"
},
"high-end-visual-design": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/soft-skill/SKILL.md",
"computedHash": "f730e4132775f13eea19e3dc39afc6bb453cfc0498872b127ad8f0d47cfd802d"
},
"image-to-code": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/image-to-code-skill/SKILL.md",
"computedHash": "3d2ac14d956fdafc5500d2108b90895e134d847f9f199e7f1d18bf1f814cf7c8"
},
"imagegen-frontend-mobile": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/imagegen-frontend-mobile/SKILL.md",
"computedHash": "7c1d1dc59cd0df4063b8621af543bf5dea1d576b261507e17b0323c69680b048"
},
"imagegen-frontend-web": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/imagegen-frontend-web/SKILL.md",
"computedHash": "27255c7c50181e58add156b2aa2bd5325285b85f04027e4c8b73742d98d7be0a"
},
"industrial-brutalist-ui": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/brutalist-skill/SKILL.md",
"computedHash": "e66e5a2a8b73cef79ab6546459c665e8b983645689c5f3772f7eed8b9f1e8e0e"
},
"minimalist-ui": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/minimalist-skill/SKILL.md",
"computedHash": "7e88897e0371184e97a82b8d9ef080323c13f21eb067b58ef71eb05173a08ac2"
},
"redesign-existing-projects": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/redesign-skill/SKILL.md",
"computedHash": "47eced3e960a2c961d3cbee11fa1216264f589344b586a5cbd03967cd6b9c54b"
},
"stitch-design-taste": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/stitch-skill/SKILL.md",
"computedHash": "c96b580192b34167efa01f460bbc669ff4315cf97f3cf6ff85ded8029aa720b8"
}
}
}