diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..3052c83 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,51 @@ +# clip2md — Design System + +Register: **product**. Color strategy: **Restrained** (tinted neutrals + one accent ≤10%). +Theme: **dark**. Scene: Nutzer legt spätabends oder tagsüber am Monitor schnell einen langen +Thread ab; eine ruhige, dunkle, blendfreie Fläche hält den Fokus auf dem Text, nicht auf dem UI. + +## Color (OKLCH) + +Neutrals sind zur Marken-Hue (kühles Blau-Grau, ~265) getönt — nie reines `#000`/`#fff`. + +| Token | OKLCH | Rolle | +| --- | --- | --- | +| `--bg` | `0.17 0.014 265` | App-Hintergrund (tief) | +| `--surface` | `0.215 0.016 265` | Editor-Fläche | +| `--raised` | `0.255 0.018 265` | Bars, Buttons | +| `--line` | `0.32 0.02 265` | Ruhige Rahmen | +| `--line-strong`| `0.42 0.024 265` | Aktive Rahmen | +| `--text` | `0.95 0.006 265` | Primärtext | +| `--muted` | `0.68 0.012 265` | Sekundärtext, Labels | +| `--faint` | `0.5 0.012 265` | Tertiär, Legende | +| `--accent` | `0.74 0.15 152` | Grün — Primäraktion, Fokus, „konvertiert" | +| `--accent-ink` | `0.22 0.05 152` | Text auf Accent | +| `--danger` | `0.66 0.16 25` | Fehler-Toast, destruktiv | + +Accent trägt <10% der Fläche: Save-Button, Fokus-Ring, „konvertiert"-Badge. Sonst Neutrals. + +## Typography + +- **Sans (UI-Chrome):** Inter / system-ui. Labels, Buttons, Legende. +- **Mono (Daten):** ui-monospace / JetBrains Mono. Editor, Dateiname, Zähler — alles, was „Inhalt" ist. +- Scale mit ≥1.25-Kontrast: 11 (legende) · 12.5 (meta) · 14 (body/editor) · 15 (buttons) · 18 (wordmark). +- Editor-Zeilenhöhe 1.65, `tab-size: 2`, Body-Zeilen bleiben durch die Mono-Fläche natürlich lesbar. + +## Elevation & Layout + +- Drei Ebenen: `bg` (App) → `surface` (Editor, das Herzstück, bekommt den Raum) → `raised` (Bars/Buttons). +- Keine Cards, keine Schatten außer einem weichen Toast-Drop. Trennung über 1px-Linien und Ton, nicht Boxen. +- Spacing-Rhythmus variiert bewusst: großzügig um den Editor, kompakt in der Toolbar. +- Radius 10–12px, konsistent. + +## Motion + +- Ease-out-quint (`cubic-bezier(0.22, 1, 0.36, 1)`), 120–240ms. Kein Bounce. +- Nur Opacity/Transform animieren. Toast slidet ein, Button-Press senkt 1px, „konvertiert"-Badge pulst einmal kurz, auto-aktualisierter Dateiname flasht dezent. +- `prefers-reduced-motion`: alle nicht-essenziellen Transitions aus. + +## Accessibility + +- `:focus-visible` Ring in Accent, überall. Touch-Ziele ≥40px. +- Toasts über `aria-live="polite"`. Editor & Felder korrekt gelabelt. +- Kontrast: Text/`bg` und Accent-Ink/Accent über WCAG AA. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..107680e --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,35 @@ +# clip2md — Product Context + +**register: product** + +## Product Purpose + +Ein winziges, self-hosted Utility: langen oder formatierten Text (Reddit-Thread, Website, +Artikel) reinpasten und in unter zwei Sekunden als sauberes `.md` speichern — ohne Umwege, +ohne Login, ohne Server-Roundtrip. Ein einziger Screen, tastaturgetrieben. + +## Users + +Till (Heidrich Digital) und Team. Power-User, die schnell Web-Inhalte in ihr Markdown-Archiv +(Obsidian, Outline, Repos) überführen. Nutzung am Desktop, oft nebenbei zwischen Lesen und +Ablegen. Erwartung: reagiert sofort, kein Nachdenken, Keyboard-first (`⌘V`, `⌘S`). + +## Brand & Tone + +Heidrich Digital: ruhig, präzise, editorial-minimalistisch, anti-AI-slop. Deutsch, sachlich, +auf Augenhöhe. Kein Marketing-Geschrei, keine Emojis im UI. Das Tool soll sich wie ein +scharfes, wertiges Werkzeug anfühlen, nicht wie ein SaaS-Dashboard. + +## Strategic Principles + +- **Frictionless.** Der schnellste Weg von Zwischenablage zu `.md`. Jeder Klick, der wegfällt, ist ein Gewinn. +- **Clientseitig & privat.** Nichts verlässt den Browser. Kein CDN, keine Third-Party-Requests (DSGVO-sauber, offline-fähig). +- **Keyboard-first.** Maus optional. `⌘V` konvertiert, `⌘S` speichert. +- **Ehrliches Feedback.** Der Nutzer sieht, was passiert ist (konvertiert vs. roh), ohne dass es aufdringlich wird. + +## Anti-References + +- Generische SaaS-Cards mit Icon + Heading + Text. +- Hero-Metric-Kacheln, Gradient-Text, Glassmorphism. +- Überladene Toolbars. Dieses Tool hat genau die Knöpfe, die es braucht. +- Bunte „freundliche" Farbverläufe. Ruhe schlägt Effekt. diff --git a/index.html b/index.html index 0d2c2a1..6c41d7f 100644 --- a/index.html +++ b/index.html @@ -3,240 +3,419 @@ - + + + clip2md — Paste to Markdown
-

clip2md

- Reinpasten → ein Klick → als .md speichern. +
m↓
+

clip2md

+ reinpasten → .md - Speichern ⌘/Strg + S · Roh einfügen ⌘/Strg + + V +
-
-
- +
+
+ + +
-
+
@@ -259,17 +438,20 @@ Dann ⌘/Strg+S oder „Speichern“."> turndown.use(window.turndownPluginGfm.gfm); } } catch (e) { /* gfm optional */ } - // Drop noise that clutters pasted web pages. turndown.remove(["script", "style", "noscript", "iframe", "svg"]); var editor = document.getElementById("editor"); + var main = document.getElementById("main"); var filename = document.getElementById("filename"); + var fnameBox = document.getElementById("fnameBox"); + var fnameTag = document.getElementById("fnameTag"); var wordsEl = document.getElementById("words"); var charsEl = document.getElementById("chars"); - var srcInfo = document.getElementById("srcInfo"); + var srcBadge = document.getElementById("srcBadge"); + var srcText = document.getElementById("srcText"); var toastEl = document.getElementById("toast"); - var filenameEdited = false; // becomes true once the user types in the field + var filenameEdited = false; var toastTimer = null; // ---- Helpers -------------------------------------------------------- @@ -291,21 +473,17 @@ Dann ⌘/Strg+S oder „Speichern“."> function slugify(s) { if (!s) return ""; s = s.replace(/[äöüßÄÖÜ]/g, function (c) { return UMLAUT[c] || c; }); - s = s.normalize("NFKD").replace(/[̀-ͯ]/g, ""); // strip accents - s = s.toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); + s = s.normalize("NFKD").replace(/[̀-ͯ]/g, ""); + s = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return s.slice(0, 60).replace(/-+$/g, ""); } function deriveTitle(md) { var lines = md.split(/\r?\n/); - // Prefer the first ATX heading. for (var i = 0; i < lines.length; i++) { var m = lines[i].match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); if (m && m[1].trim()) return m[1].trim(); } - // Otherwise the first non-empty, non-decorative line. for (var j = 0; j < lines.length; j++) { var t = lines[j].trim(); if (!t) continue; @@ -320,9 +498,22 @@ Dann ⌘/Strg+S oder „Speichern“."> return today() + (slug ? "-" + slug : "-clip"); } - function refreshFilename() { + function refreshFilename(flash) { if (filenameEdited) return; - filename.value = computeFilename(editor.value); + var next = computeFilename(editor.value); + if (next !== filename.value) { + filename.value = next; + if (flash) { + fnameBox.classList.remove("flash"); + void fnameBox.offsetWidth; // reflow to restart animation + fnameBox.classList.add("flash"); + } + } + } + + function setFnameTag() { + if (filenameEdited) { fnameTag.textContent = "eigen"; fnameTag.classList.remove("auto"); } + else { fnameTag.textContent = "auto"; fnameTag.classList.add("auto"); } } function updateCounts() { @@ -332,63 +523,81 @@ Dann ⌘/Strg+S oder „Speichern“."> wordsEl.textContent = w.toLocaleString("de-DE"); } - function refreshAll() { updateCounts(); refreshFilename(); } + function updateEmpty() { + main.classList.toggle("has-content", editor.value.length > 0); + } + + function showSource(text, ok, pulse) { + srcText.textContent = text; + srcBadge.classList.toggle("ok", !!ok); + srcBadge.classList.add("show"); + if (pulse) { + srcBadge.classList.remove("pulse"); + void srcBadge.offsetWidth; + srcBadge.classList.add("pulse"); + } + } + function hideSource() { srcBadge.classList.remove("show", "ok", "pulse"); } + + function refreshAll(flash) { updateCounts(); refreshFilename(flash); updateEmpty(); } // ---- Smart paste ---------------------------------------------------- - // Cmd/Ctrl+Shift+V forces a raw (plain-text) paste. var forceRaw = false; editor.addEventListener("paste", function (e) { var cd = e.clipboardData || window.clipboardData; - if (!cd) return; // let the browser handle it + if (!cd) return; var html = cd.getData("text/html"); var text = cd.getData("text/plain"); - var out, source; + var out, badge, ok; if (!forceRaw && html && html.trim()) { try { out = turndown.turndown(html).replace(/\n{3,}/g, "\n\n").trim(); - source = "HTML → Markdown konvertiert"; + badge = "HTML → Markdown konvertiert"; ok = true; } catch (err) { - out = text; source = "Roh eingefügt (Konvertierung fehlgeschlagen)"; + out = text; badge = "Roh eingefügt (Konvertierung fehlgeschlagen)"; ok = false; } } else { out = text; - source = forceRaw ? "Roh eingefügt (erzwungen)" : "Roh eingefügt (kein HTML)"; + badge = forceRaw ? "Roh eingefügt (erzwungen)" : "Roh eingefügt"; + ok = false; } forceRaw = false; if (out == null) return; e.preventDefault(); insertAtCursor(out); - srcInfo.textContent = source; - refreshAll(); - toast(source); + showSource(badge, ok, true); + refreshAll(true); }); function insertAtCursor(str) { var start = editor.selectionStart, end = editor.selectionEnd; - var before = editor.value.slice(0, start); - var after = editor.value.slice(end); - editor.value = before + str + after; + editor.value = editor.value.slice(0, start) + str + editor.value.slice(end); var pos = start + str.length; editor.selectionStart = editor.selectionEnd = pos; } editor.addEventListener("input", function () { - srcInfo.textContent = ""; - refreshAll(); + hideSource(); + refreshAll(false); }); // ---- Filename field ------------------------------------------------- filename.addEventListener("input", function () { filenameEdited = filename.value.trim().length > 0; + setFnameTag(); + }); + // Empty field → hand control back to auto. + filename.addEventListener("blur", function () { + if (!filename.value.trim()) { filenameEdited = false; setFnameTag(); refreshFilename(false); } }); // ---- Save ----------------------------------------------------------- function save() { var content = editor.value; - if (!content.trim()) { toast("Nichts zu speichern.", true); return; } + if (!content.trim()) { toast("Nichts zu speichern.", true); flashEditor(); return; } var name = (filename.value.trim() || computeFilename(content)); name = name.replace(/[\/\\:*?"<>|]+/g, "-").replace(/\s+/g, "-"); if (!/\.md$/i.test(name)) name += ".md"; @@ -396,15 +605,15 @@ Dann ⌘/Strg+S oder „Speichern“."> var blob = new Blob([content], { type: "text/markdown;charset=utf-8" }); var url = URL.createObjectURL(blob); var a = document.createElement("a"); - a.href = url; - a.download = name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + a.href = url; a.download = name; + document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); - toast("Gespeichert: " + name); + toast("Gespeichert · " + name); + } + function flashEditor() { + editor.style.borderColor = "var(--danger)"; + setTimeout(function () { editor.style.borderColor = ""; }, 500); } - document.getElementById("save").addEventListener("click", save); // ---- Copy ----------------------------------------------------------- @@ -430,8 +639,7 @@ Dann ⌘/Strg+S oder „Speichern“."> if (editor.value && !confirm("Editor wirklich leeren?")) return; editor.value = ""; filenameEdited = false; - srcInfo.textContent = ""; - refreshAll(); + hideSource(); setFnameTag(); refreshAll(false); editor.focus(); }); @@ -441,14 +649,14 @@ Dann ⌘/Strg+S oder „Speichern“."> if (mod && !e.shiftKey && !e.altKey && (e.key === "s" || e.key === "S")) { e.preventDefault(); save(); return; } - // Arm a raw paste for the next paste event. if (mod && e.shiftKey && (e.key === "v" || e.key === "V")) { - forceRaw = true; // paste handler consumes and resets it + forceRaw = true; } }); // ---- Init ----------------------------------------------------------- - refreshAll(); + setFnameTag(); + refreshAll(false); editor.focus(); })();