Files
Pigmento/docs/superpowers/plans/2026-04-01-farbhelfer.md
2026-04-01 16:07:03 +02:00

33 KiB

Farbhelfer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a browser-based color tool that extracts hex codes from images, converts between color formats, shows color harmonies, and manages a personal color collection.

Architecture: Single index.html with tab-based navigation, three focused JS modules (converter.js, picker.js, collection.js), and one style.css. A global state object holds the active color and is passed between modules. No build step — open index.html directly in the browser.

Tech Stack: Vanilla HTML5, CSS3, JavaScript (ES6 modules), Canvas API, localStorage, no external dependencies.


File Map

File Verantwortung
index.html Shell, Tab-Navigation, alle Tab-Inhalte als Sections
style.css Layout, Tab-UI, Farbfelder, Buttons, responsive Basis
js/converter.js Hex-RGB-HSL Umrechnung, Harmonieberechnungen
js/picker.js Bild laden (Upload/Paste/Drop), Canvas, Pixelauswahl
js/collection.js localStorage lesen/schreiben, Favoriten, Historie, Schemata, Export/Import
js/eingabe.js Eingabe-Tab Logik, bidirektionale Umrechnung
js/harmonien.js Harmonien-Tab Logik, Rendering
js/app.js Globaler State, Tab-Wechsel, Modul-Koordination

Task 1: Projekt-Grundstruktur

Files:

  • Create: index.html

  • Create: style.css

  • Create: js/app.js

  • Schritt 1: index.html erstellen

<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Farbhelfer</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header>
    <h1>Farbhelfer</h1>
    <nav>
      <button class="tab-btn active" data-tab="picker">Picker</button>
      <button class="tab-btn" data-tab="eingabe">Eingabe</button>
      <button class="tab-btn" data-tab="harmonien">Harmonien</button>
      <button class="tab-btn" data-tab="sammlung">Sammlung</button>
    </nav>
  </header>

  <main>
    <section id="tab-picker" class="tab-content active">
      <p>Picker kommt hier</p>
    </section>
    <section id="tab-eingabe" class="tab-content">
      <p>Eingabe kommt hier</p>
    </section>
    <section id="tab-harmonien" class="tab-content">
      <p>Harmonien kommen hier</p>
    </section>
    <section id="tab-sammlung" class="tab-content">
      <p>Sammlung kommt hier</p>
    </section>
  </main>

  <script type="module" src="js/app.js"></script>
</body>
</html>
  • Schritt 2: style.css Basis erstellen
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: system-ui, sans-serif;
  background: #f5f5f5;
  color: #222;
  min-height: 100vh;
}

header {
  background: #fff;
  border-bottom: 1px solid #ddd;
  padding: 1rem 1.5rem;
  position: sticky;
  top: 0;
  z-index: 10;
}

header h1 { font-size: 1.2rem; margin-bottom: 0.75rem; }

nav { display: flex; gap: 0.5rem; }

.tab-btn {
  padding: 0.4rem 1rem;
  border: 1px solid #ccc;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
  font-size: 0.9rem;
}

.tab-btn.active {
  background: #222;
  color: #fff;
  border-color: #222;
}

main { padding: 1.5rem; max-width: 900px; margin: 0 auto; }

.tab-content { display: none; }
.tab-content.active { display: block; }

.color-preview {
  width: 100%;
  height: 80px;
  border-radius: 8px;
  border: 1px solid #ddd;
  margin-bottom: 0.75rem;
}

.color-codes {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  margin-bottom: 1rem;
}

.color-code-group { display: flex; flex-direction: column; gap: 0.25rem; }
.color-code-group label { font-size: 0.75rem; color: #666; text-transform: uppercase; }
.color-code-group input {
  padding: 0.4rem 0.6rem;
  border: 1px solid #ccc;
  border-radius: 6px;
  font-family: monospace;
  font-size: 0.9rem;
  width: 140px;
}

button.action-btn {
  padding: 0.4rem 0.9rem;
  border: 1px solid #ccc;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
  font-size: 0.85rem;
}

button.action-btn:hover { background: #f0f0f0; }

.swatch {
  display: inline-block;
  width: 40px;
  height: 40px;
  border-radius: 6px;
  border: 1px solid #ddd;
  cursor: pointer;
  vertical-align: middle;
}
  • Schritt 3: js/app.js mit Tab-Logik und globalem State erstellen
// Globaler State — aktive Farbe als { h, s, l } (HSL, 0-360, 0-100, 0-100)
export const state = {
  color: { h: 200, s: 60, l: 50 },
  setColor(hsl) {
    this.color = hsl;
    document.dispatchEvent(new CustomEvent('colorChanged', { detail: hsl }));
  }
};

// Tab-Navigation
document.querySelectorAll('.tab-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    const tab = btn.dataset.tab;
    document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
    document.querySelectorAll('.tab-content').forEach(s => s.classList.remove('active'));
    btn.classList.add('active');
    document.getElementById('tab-' + tab).classList.add('active');
  });
});
  • Schritt 4: Im Browser öffnen — Tab-Wechsel muss funktionieren

index.html direkt im Browser öffnen (Doppelklick oder open index.html). Alle 4 Tabs müssen klickbar sein und den Inhalt wechseln.

  • Schritt 5: Committen
git add index.html style.css js/app.js
git commit -m "feat: Grundstruktur mit Tab-Navigation"

Task 2: converter.js — Farbumrechnung und Harmonien

Files:

  • Create: js/converter.js

  • Schritt 1: js/converter.js erstellen

// Hex → RGB
export function hexToRgb(hex) {
  const h = hex.replace('#', '');
  const n = parseInt(h.length === 3
    ? h.split('').map(c => c + c).join('')
    : h, 16);
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}

// RGB → Hex
export function rgbToHex({ r, g, b }) {
  return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}

// RGB → HSL (h: 0-360, s: 0-100, l: 0-100)
export function rgbToHsl({ r, g, b }) {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h, s;
  const l = (max + min) / 2;
  if (max === min) {
    h = s = 0;
  } else {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
      case g: h = ((b - r) / d + 2) / 6; break;
      case b: h = ((r - g) / d + 4) / 6; break;
    }
  }
  return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}

// HSL → RGB
export function hslToRgb({ h, s, l }) {
  s /= 100; l /= 100;
  const k = n => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255) };
}

// HSL → Hex
export function hslToHex(hsl) {
  return rgbToHex(hslToRgb(hsl));
}

// Hex → HSL
export function hexToHsl(hex) {
  return rgbToHsl(hexToRgb(hex));
}

// Harmonien — Input: HSL, Output: Objekt mit Arrays von HSL-Objekten
export function getHarmonies(hsl) {
  const rotate = deg => ({ ...hsl, h: (hsl.h + deg + 360) % 360 });
  return {
    komplementaer:      [rotate(180)],
    analog:             [rotate(-30), rotate(30)],
    triade:             [rotate(120), rotate(240)],
    splitKomplementaer: [rotate(150), rotate(210)],
  };
}
  • Schritt 2: Schnelltest in der Browser-Konsole

index.html öffnen, in der Browserkonsole eingeben:

import('./js/converter.js').then(m => {
  console.log(m.hexToRgb('#3a8fc1'));           // { r: 58, g: 143, b: 193 }
  console.log(m.rgbToHex({r:58,g:143,b:193})); // #3a8fc1
  console.log(m.rgbToHsl({r:58,g:143,b:193})); // { h: 204, s: 54, l: 49 }
  console.log(m.hslToHex({h:204,s:54,l:49}));  // nahe #3a8fc1
});

Alle Werte müssen stimmen.

  • Schritt 3: Committen
git add js/converter.js
git commit -m "feat: Farbumrechnung Hex/RGB/HSL und Harmonieberechnungen"

Task 3: Eingabe-Tab

Files:

  • Modify: index.html — Tab-Inhalt für Eingabe befüllen

  • Create: js/eingabe.js

  • Modify: js/app.js — Eingabe-Modul initialisieren

  • Schritt 1: HTML für Eingabe-Tab in index.html ersetzen

Den Platzhalter <section id="tab-eingabe"> ersetzen mit:

<section id="tab-eingabe" class="tab-content">
  <h2>Farbe eingeben</h2>
  <div id="eingabe-preview" class="color-preview" style="background:#3a8fc1"></div>
  <div class="color-codes">
    <div class="color-code-group">
      <label>Hex</label>
      <input id="eingabe-hex" type="text" value="#3a8fc1" maxlength="7">
    </div>
    <div class="color-code-group">
      <label>RGB</label>
      <input id="eingabe-rgb" type="text" value="58, 143, 193">
    </div>
    <div class="color-code-group">
      <label>HSL</label>
      <input id="eingabe-hsl" type="text" value="204, 54%, 49%">
    </div>
  </div>
  <div style="display:flex;gap:0.5rem">
    <button class="action-btn" id="eingabe-fav-btn">Zu Favoriten</button>
    <button class="action-btn" id="eingabe-schema-btn">Zu Schema hinzufügen</button>
  </div>
</section>
  • Schritt 2: js/eingabe.js erstellen
import { hexToRgb, rgbToHex, hexToHsl, hslToHex, hslToRgb, rgbToHsl } from './converter.js';
import { state } from './app.js';

function hslToDisplay({ h, s, l }) { return h + ', ' + s + '%, ' + l + '%'; }
function rgbToDisplay({ r, g, b }) { return r + ', ' + g + ', ' + b; }

function parseRgb(str) {
  const parts = str.replace(/[^\d,]/g, '').split(',').map(Number);
  if (parts.length !== 3 || parts.some(isNaN)) return null;
  const [r, g, b] = parts;
  if ([r, g, b].some(v => v < 0 || v > 255)) return null;
  return { r, g, b };
}

function parseHsl(str) {
  const parts = str.replace(/[^\d,]/g, '').split(',').map(Number);
  if (parts.length !== 3 || parts.some(isNaN)) return null;
  const [h, s, l] = parts;
  if (h < 0 || h > 360 || s < 0 || s > 100 || l < 0 || l > 100) return null;
  return { h, s, l };
}

function updateUI(hsl) {
  const hex = hslToHex(hsl);
  const rgb = hslToRgb(hsl);
  document.getElementById('eingabe-preview').style.background = hex;
  document.getElementById('eingabe-hex').value = hex;
  document.getElementById('eingabe-rgb').value = rgbToDisplay(rgb);
  document.getElementById('eingabe-hsl').value = hslToDisplay(hsl);
}

export function initEingabe(onSaveFavorit, onSaveSchema) {
  const hexInput = document.getElementById('eingabe-hex');
  const rgbInput = document.getElementById('eingabe-rgb');
  const hslInput = document.getElementById('eingabe-hsl');

  hexInput.addEventListener('input', () => {
    const val = hexInput.value.trim();
    if (/^#[0-9a-fA-F]{6}$/.test(val)) {
      const hsl = hexToHsl(val);
      state.setColor(hsl);
      updateUI(hsl);
    }
  });

  rgbInput.addEventListener('input', () => {
    const rgb = parseRgb(rgbInput.value);
    if (rgb) {
      const hsl = rgbToHsl(rgb);
      state.setColor(hsl);
      updateUI(hsl);
    }
  });

  hslInput.addEventListener('input', () => {
    const hsl = parseHsl(hslInput.value);
    if (hsl) {
      state.setColor(hsl);
      updateUI(hsl);
    }
  });

  document.addEventListener('colorChanged', () => updateUI(state.color));

  document.getElementById('eingabe-fav-btn').addEventListener('click', () => {
    onSaveFavorit(state.color);
  });

  document.getElementById('eingabe-schema-btn').addEventListener('click', () => {
    onSaveSchema(state.color);
  });

  updateUI(state.color);
}
  • Schritt 3: initEingabe in app.js einbinden

Am Ende von js/app.js ergänzen (Platzhalter-Funktionen, bis collection.js in Task 6 fertig ist):

import { initEingabe } from './eingabe.js';

function addFavorit(hsl) { console.log('addFavorit', hsl); }
function addColorToSchema(hsl) { console.log('addColorToSchema', hsl); }

initEingabe(addFavorit, addColorToSchema);
  • Schritt 4: Im Browser testen

Eingabe-Tab öffnen:

  • Hex #ff6600 eingeben → RGB 255, 102, 0 und HSL 24, 100%, 50% erscheinen, Preview wird orange

  • RGB 0, 128, 0 eingeben → Hex #008000, HSL 120, 100%, 25%

  • HSL 300, 100%, 50% eingeben → Hex #ff00ff, RGB 255, 0, 255

  • Schritt 5: Committen

git add index.html js/eingabe.js js/app.js
git commit -m "feat: Eingabe-Tab mit bidirektionaler Farbumrechnung"

Task 4: Picker-Tab

Files:

  • Modify: index.html — Picker-Tab-Inhalt

  • Create: js/picker.js

  • Modify: js/app.js — Picker initialisieren

  • Schritt 1: HTML für Picker-Tab in index.html ersetzen

Den Platzhalter <section id="tab-picker"> ersetzen mit:

<section id="tab-picker" class="tab-content active">
  <h2>Farbe aus Bild</h2>
  <div id="picker-dropzone">
    <p>Bild hierher ziehen, einfügen (Strg+V) oder</p>
    <button class="action-btn" id="picker-file-trigger">Datei wählen</button>
    <input type="file" id="picker-file-input" accept="image/*" style="display:none">
  </div>
  <canvas id="picker-canvas" style="display:none;max-width:100%;cursor:crosshair;border-radius:8px;border:1px solid #ddd"></canvas>
  <div id="picker-result" style="display:none;margin-top:1rem">
    <div id="picker-preview" class="color-preview"></div>
    <div class="color-codes">
      <div class="color-code-group">
        <label>Hex</label>
        <input id="picker-hex" type="text" readonly>
      </div>
      <div class="color-code-group">
        <label>RGB</label>
        <input id="picker-rgb" type="text" readonly>
      </div>
      <div class="color-code-group">
        <label>HSL</label>
        <input id="picker-hsl" type="text" readonly>
      </div>
    </div>
    <div style="display:flex;gap:0.5rem">
      <button class="action-btn" id="picker-fav-btn">Zu Favoriten</button>
      <button class="action-btn" id="picker-schema-btn">Zu Schema hinzufügen</button>
    </div>
  </div>
</section>

CSS für Dropzone in style.css ergänzen:

#picker-dropzone {
  border: 2px dashed #ccc;
  border-radius: 8px;
  padding: 2rem;
  text-align: center;
  margin-bottom: 1rem;
  background: #fafafa;
}

#picker-dropzone.drag-over {
  border-color: #222;
  background: #f0f0f0;
}

#picker-dropzone p { margin-bottom: 0.75rem; color: #666; font-size: 0.9rem; }
  • Schritt 2: js/picker.js erstellen
import { rgbToHex, rgbToHsl } from './converter.js';
import { state } from './app.js';

function rgbToDisplay({ r, g, b }) { return r + ', ' + g + ', ' + b; }
function hslToDisplay({ h, s, l }) { return h + ', ' + s + '%, ' + l + '%'; }

function showColor(r, g, b) {
  const hex = rgbToHex({ r, g, b });
  const hsl = rgbToHsl({ r, g, b });
  state.setColor(hsl);
  document.getElementById('picker-preview').style.background = hex;
  document.getElementById('picker-hex').value = hex;
  document.getElementById('picker-rgb').value = rgbToDisplay({ r, g, b });
  document.getElementById('picker-hsl').value = hslToDisplay(hsl);
  document.getElementById('picker-result').style.display = 'block';
}

function loadImage(file) {
  const reader = new FileReader();
  reader.onload = (e) => {
    const img = new Image();
    img.onload = () => {
      const canvas = document.getElementById('picker-canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      const ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0);
      canvas.style.display = 'block';

      // Mittlerer Pixel als Vorschlag
      const mx = Math.floor(img.width / 2);
      const my = Math.floor(img.height / 2);
      const px = ctx.getImageData(mx, my, 1, 1).data;
      showColor(px[0], px[1], px[2]);

      // Eyedropper: Klick wählt Pixel frei
      canvas.onclick = (ev) => {
        const rect = canvas.getBoundingClientRect();
        const scaleX = canvas.width / rect.width;
        const scaleY = canvas.height / rect.height;
        const x = Math.floor((ev.clientX - rect.left) * scaleX);
        const y = Math.floor((ev.clientY - rect.top) * scaleY);
        const d = ctx.getImageData(x, y, 1, 1).data;
        showColor(d[0], d[1], d[2]);
      };
    };
    img.src = e.target.result;
  };
  reader.readAsDataURL(file);
}

export function initPicker(onSaveFavorit, onSaveSchema) {
  const dropzone = document.getElementById('picker-dropzone');

  document.getElementById('picker-file-trigger').addEventListener('click', () => {
    document.getElementById('picker-file-input').click();
  });

  document.getElementById('picker-file-input').addEventListener('change', (e) => {
    if (e.target.files[0]) loadImage(e.target.files[0]);
  });

  dropzone.addEventListener('dragover', (e) => {
    e.preventDefault();
    dropzone.classList.add('drag-over');
  });
  dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
  dropzone.addEventListener('drop', (e) => {
    e.preventDefault();
    dropzone.classList.remove('drag-over');
    const file = e.dataTransfer.files[0];
    if (file && file.type.startsWith('image/')) loadImage(file);
  });

  // Paste (Strg+V / Cmd+V)
  document.addEventListener('paste', (e) => {
    const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'));
    if (item) loadImage(item.getAsFile());
  });

  document.getElementById('picker-fav-btn').addEventListener('click', () => onSaveFavorit(state.color));
  document.getElementById('picker-schema-btn').addEventListener('click', () => onSaveSchema(state.color));
}
  • Schritt 3: initPicker in app.js einbinden

Ergänzen (nach den bestehenden Imports):

import { initPicker } from './picker.js';

initPicker(addFavorit, addColorToSchema);
  • Schritt 4: Im Browser testen

  • Screenshot mit Cmd+Shift+4 (Mac) erstellen → mit Cmd+V einfügen → Bild erscheint, mittlerer Pixel wird vorgeschlagen

  • Auf beliebigen Punkt im Bild klicken → Farbe wechselt

  • Datei hochladen über "Datei wählen" → funktioniert gleich

  • Bild per Drag & Drop ins Dropzone-Feld ziehen → Bild lädt

  • Schritt 5: Committen

git add index.html style.css js/picker.js js/app.js
git commit -m "feat: Picker-Tab mit Upload, Paste und Eyedropper"

Task 5: Harmonien-Tab

Files:

  • Modify: index.html — Harmonien-Tab-Inhalt

  • Create: js/harmonien.js

  • Modify: js/app.js — Harmonien initialisieren

  • Schritt 1: HTML für Harmonien-Tab in index.html ersetzen

<section id="tab-harmonien" class="tab-content">
  <h2>Farbharmonien</h2>
  <p class="subtitle">Ausgangsfarbe: <span id="harmonien-base-hex">#3a8fc1</span></p>
  <div id="harmonien-base-preview" class="color-preview" style="height:50px"></div>
  <div id="harmonien-grid" style="display:flex;flex-direction:column;gap:1.5rem;margin-top:1rem"></div>
</section>

CSS in style.css ergänzen:

.subtitle { font-size: 0.85rem; color: #666; margin-bottom: 0.5rem; }

.harmony-row h3 { font-size: 0.9rem; margin-bottom: 0.5rem; }
.harmony-swatches { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: flex-start; }
.harmony-swatch {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.25rem;
}
.harmony-swatch .swatch { width: 56px; height: 56px; }
.harmony-swatch span { font-size: 0.75rem; font-family: monospace; }
  • Schritt 2: js/harmonien.js erstellen
import { getHarmonies, hslToHex } from './converter.js';
import { state } from './app.js';

function renderHarmony(label, colors, onSaveFavorit, onSaveSchema) {
  const row = document.createElement('div');
  row.className = 'harmony-row';

  const heading = document.createElement('h3');
  heading.textContent = label;
  row.appendChild(heading);

  const swatchesDiv = document.createElement('div');
  swatchesDiv.className = 'harmony-swatches';

  colors.forEach(hsl => {
    const hex = hslToHex(hsl);

    const div = document.createElement('div');
    div.className = 'harmony-swatch';

    const swatch = document.createElement('div');
    swatch.className = 'swatch';
    swatch.style.background = hex;
    swatch.title = hex;

    const label = document.createElement('span');
    label.textContent = hex;

    const btnRow = document.createElement('div');
    btnRow.style.cssText = 'display:flex;gap:0.25rem';

    const favBtn = document.createElement('button');
    favBtn.className = 'action-btn';
    favBtn.textContent = 'Fav';
    favBtn.style.fontSize = '0.7rem';
    favBtn.addEventListener('click', () => onSaveFavorit(hsl));

    const schemaBtn = document.createElement('button');
    schemaBtn.className = 'action-btn';
    schemaBtn.textContent = '+Schema';
    schemaBtn.style.fontSize = '0.7rem';
    schemaBtn.addEventListener('click', () => onSaveSchema(hsl));

    btnRow.appendChild(favBtn);
    btnRow.appendChild(schemaBtn);

    div.appendChild(swatch);
    div.appendChild(label);
    div.appendChild(btnRow);
    swatchesDiv.appendChild(div);
  });

  row.appendChild(swatchesDiv);
  return row;
}

function render(onSaveFavorit, onSaveSchema) {
  const hsl = state.color;
  const hex = hslToHex(hsl);

  document.getElementById('harmonien-base-hex').textContent = hex;
  document.getElementById('harmonien-base-preview').style.background = hex;

  const grid = document.getElementById('harmonien-grid');
  grid.textContent = '';

  const h = getHarmonies(hsl);
  grid.appendChild(renderHarmony('Komplementar', h.komplementaer, onSaveFavorit, onSaveSchema));
  grid.appendChild(renderHarmony('Analog', h.analog, onSaveFavorit, onSaveSchema));
  grid.appendChild(renderHarmony('Triade', h.triade, onSaveFavorit, onSaveSchema));
  grid.appendChild(renderHarmony('Split-Komplementar', h.splitKomplementaer, onSaveFavorit, onSaveSchema));
}

export function initHarmonien(onSaveFavorit, onSaveSchema) {
  document.addEventListener('colorChanged', () => render(onSaveFavorit, onSaveSchema));
  render(onSaveFavorit, onSaveSchema);
}
  • Schritt 3: initHarmonien in app.js einbinden
import { initHarmonien } from './harmonien.js';

initHarmonien(addFavorit, addColorToSchema);
  • Schritt 4: Im Browser testen

Harmonien-Tab öffnen → 4 Sektionen (Komplementar, Analog, Triade, Split-Komplementar) mit Farbfeldern und Hex-Codes. Im Eingabe-Tab Farbe ändern → zurück zu Harmonien → Farben haben sich aktualisiert.

  • Schritt 5: Committen
git add index.html style.css js/harmonien.js js/app.js
git commit -m "feat: Harmonien-Tab mit Komplementar, Analog, Triade, Split-Komplementar"

Task 6: collection.js — Sammlung, localStorage, Export/Import

Files:

  • Create: js/collection.js

  • Modify: index.html — Sammlung-Tab-Inhalt

  • Modify: js/app.js — Platzhalter-Funktionen durch echte Imports ersetzen

  • Schritt 1: js/collection.js erstellen

import { hslToHex } from './converter.js';

const STORAGE_KEY = 'farbhelfer';
const HISTORY_MAX = 20;

function load() {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { favoriten: [], history: [], schemata: [] };
  } catch {
    return { favoriten: [], history: [], schemata: [] };
  }
}

function save(data) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}

export function addFavorit(hsl) {
  const data = load();
  const hex = hslToHex(hsl);
  if (!data.favoriten.some(f => hslToHex(f) === hex)) {
    data.favoriten.push(hsl);
    save(data);
    renderSammlung();
  }
}

export function removeFavorit(hex) {
  const data = load();
  data.favoriten = data.favoriten.filter(f => hslToHex(f) !== hex);
  save(data);
  renderSammlung();
}

export function addToHistory(hsl) {
  const data = load();
  const hex = hslToHex(hsl);
  data.history = data.history.filter(h => hslToHex(h) !== hex);
  data.history.unshift(hsl);
  if (data.history.length > HISTORY_MAX) data.history = data.history.slice(0, HISTORY_MAX);
  save(data);
}

export function addColorToSchema(hsl) {
  const name = prompt('Schema-Name (leer = letztes Schema):');
  const data = load();
  if (name) {
    const existing = data.schemata.find(s => s.name === name);
    if (existing) {
      if (existing.farben.length < 4) {
        existing.farben.push(hsl);
        save(data);
        renderSammlung();
      } else {
        alert('Schema hat bereits 4 Farben.');
      }
    } else {
      data.schemata.push({ name, farben: [hsl] });
      save(data);
      renderSammlung();
    }
  } else if (data.schemata.length > 0) {
    const last = data.schemata[data.schemata.length - 1];
    if (last.farben.length < 4) {
      last.farben.push(hsl);
      save(data);
      renderSammlung();
    } else {
      alert('Letztes Schema hat bereits 4 Farben.');
    }
  } else {
    alert('Kein Schema vorhanden. Bitte Name eingeben.');
  }
}

export function deleteSchema(name) {
  const data = load();
  data.schemata = data.schemata.filter(s => s.name !== name);
  save(data);
  renderSammlung();
}

export function exportCollection() {
  const data = load();
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'farbhelfer-sammlung.json';
  a.click();
}

export function importCollection() {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = '.json';
  input.onchange = (e) => {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      try {
        const imported = JSON.parse(ev.target.result);
        const data = load();
        const existingFavHexes = new Set(data.favoriten.map(hslToHex));
        (imported.favoriten || []).forEach(hsl => {
          if (!existingFavHexes.has(hslToHex(hsl))) data.favoriten.push(hsl);
        });
        const existingSchemaNames = new Set(data.schemata.map(s => s.name));
        (imported.schemata || []).forEach(s => {
          if (!existingSchemaNames.has(s.name)) data.schemata.push(s);
        });
        save(data);
        renderSammlung();
        alert('Import erfolgreich.');
      } catch {
        alert('Ungultige Datei.');
      }
    };
    reader.readAsText(file);
  };
  input.click();
}

function makeSwatch(hsl) {
  const hex = hslToHex(hsl);

  const wrapper = document.createElement('div');
  wrapper.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:0.25rem';

  const swatch = document.createElement('div');
  swatch.className = 'swatch';
  swatch.style.background = hex;
  swatch.title = hex;

  const label = document.createElement('span');
  label.style.cssText = 'font-size:0.75rem;font-family:monospace';
  label.textContent = hex;

  wrapper.appendChild(swatch);
  wrapper.appendChild(label);
  return wrapper;
}

export function renderSammlung() {
  const data = load();

  // Favoriten
  const favContainer = document.getElementById('sammlung-favoriten');
  if (favContainer) {
    favContainer.textContent = '';
    if (data.favoriten.length === 0) {
      const msg = document.createElement('p');
      msg.style.cssText = 'color:#999;font-size:0.85rem';
      msg.textContent = 'Noch keine Favoriten.';
      favContainer.appendChild(msg);
    } else {
      data.favoriten.forEach(hsl => {
        const hex = hslToHex(hsl);
        const cell = document.createElement('div');
        cell.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:0.25rem;margin:0.25rem';
        cell.appendChild(makeSwatch(hsl));

        const del = document.createElement('button');
        del.className = 'action-btn';
        del.textContent = 'Loschen';
        del.style.fontSize = '0.7rem';
        del.addEventListener('click', () => removeFavorit(hex));
        cell.appendChild(del);
        favContainer.appendChild(cell);
      });
    }
  }

  // Historie
  const histContainer = document.getElementById('sammlung-history');
  if (histContainer) {
    histContainer.textContent = '';
    if (data.history.length === 0) {
      const msg = document.createElement('p');
      msg.style.cssText = 'color:#999;font-size:0.85rem';
      msg.textContent = 'Noch kein Verlauf.';
      histContainer.appendChild(msg);
    } else {
      data.history.forEach(hsl => {
        const cell = document.createElement('div');
        cell.style.cssText = 'display:inline-block;margin:0.25rem';
        cell.appendChild(makeSwatch(hsl));
        histContainer.appendChild(cell);
      });
    }
  }

  // Schemata
  const schemataContainer = document.getElementById('sammlung-schemata');
  if (schemataContainer) {
    schemataContainer.textContent = '';
    if (data.schemata.length === 0) {
      const msg = document.createElement('p');
      msg.style.cssText = 'color:#999;font-size:0.85rem';
      msg.textContent = 'Noch keine Schemata.';
      schemataContainer.appendChild(msg);
    } else {
      data.schemata.forEach(schema => {
        const card = document.createElement('div');
        card.style.cssText = 'border:1px solid #ddd;border-radius:8px;padding:1rem;margin-bottom:0.75rem;background:#fff';

        const header = document.createElement('div');
        header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem';

        const nameEl = document.createElement('strong');
        nameEl.textContent = schema.name;

        const delBtn = document.createElement('button');
        delBtn.className = 'action-btn';
        delBtn.textContent = 'Schema loschen';
        delBtn.style.fontSize = '0.75rem';
        delBtn.addEventListener('click', () => deleteSchema(schema.name));

        header.appendChild(nameEl);
        header.appendChild(delBtn);

        const swatchesDiv = document.createElement('div');
        swatchesDiv.style.cssText = 'display:flex;gap:0.5rem;flex-wrap:wrap';
        schema.farben.forEach(hsl => swatchesDiv.appendChild(makeSwatch(hsl)));

        card.appendChild(header);
        card.appendChild(swatchesDiv);
        schemataContainer.appendChild(card);
      });
    }
  }
}
  • Schritt 2: HTML für Sammlung-Tab in index.html ersetzen
<section id="tab-sammlung" class="tab-content">
  <h2>Sammlung</h2>

  <div style="display:flex;gap:0.5rem;margin-bottom:1.5rem">
    <button class="action-btn" id="sammlung-export-btn">Exportieren</button>
    <button class="action-btn" id="sammlung-import-btn">Importieren</button>
  </div>

  <h3 style="margin-bottom:0.5rem">Favoriten</h3>
  <div id="sammlung-favoriten" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1.5rem"></div>

  <h3 style="margin-bottom:0.5rem">Verlauf (letzte 20)</h3>
  <div id="sammlung-history" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1.5rem"></div>

  <h3 style="margin-bottom:0.5rem">Farbschemata</h3>
  <div id="sammlung-schemata"></div>
</section>
  • Schritt 3: app.js vollständig aktualisieren

js/app.js komplett ersetzen mit der finalen Version (Platzhalter weg, alle echten Imports):

import { addFavorit, addColorToSchema, addToHistory, renderSammlung, exportCollection, importCollection } from './collection.js';
import { initEingabe } from './eingabe.js';
import { initPicker } from './picker.js';
import { initHarmonien } from './harmonien.js';

export const state = {
  color: { h: 200, s: 60, l: 50 },
  setColor(hsl) {
    this.color = hsl;
    document.dispatchEvent(new CustomEvent('colorChanged', { detail: hsl }));
  }
};

// Tab-Navigation
document.querySelectorAll('.tab-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    const tab = btn.dataset.tab;
    document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
    document.querySelectorAll('.tab-content').forEach(s => s.classList.remove('active'));
    btn.classList.add('active');
    document.getElementById('tab-' + tab).classList.add('active');
  });
});

// Jede Farbänderung in die Historie
document.addEventListener('colorChanged', (e) => addToHistory(e.detail));

initEingabe(addFavorit, addColorToSchema);
initPicker(addFavorit, addColorToSchema);
initHarmonien(addFavorit, addColorToSchema);

document.getElementById('sammlung-export-btn').addEventListener('click', exportCollection);
document.getElementById('sammlung-import-btn').addEventListener('click', importCollection);

renderSammlung();
  • Schritt 4: Im Browser testen

  • Im Eingabe-Tab Farbe eingeben → "Zu Favoriten" → Sammlung-Tab zeigt sie

  • Im Picker Farbe wählen → Verlauf in Sammlung aktualisiert sich

  • "Zu Schema hinzufügen" → Prompt erscheint → Schema-Name eingeben → Sammlung zeigt Schema

  • Exportieren → JSON-Download startet

  • Importierte JSON wieder importieren → keine Duplikate

  • Schritt 5: Committen

git add js/collection.js index.html js/app.js
git commit -m "feat: Sammlung mit Favoriten, Verlauf, Schemata, Export und Import"

Task 7: Finales CSS-Finishing

Files:

  • Modify: style.css

  • Schritt 1: Finale Styles an style.css anhängen

h2 { font-size: 1.1rem; margin-bottom: 1rem; }
h3 { font-size: 0.95rem; }

.tab-content { padding-top: 0.5rem; }

.color-preview { transition: background 0.15s; }

.action-btn:active { transform: scale(0.97); }

@media (max-width: 600px) {
  nav { flex-wrap: wrap; }
  .color-codes { flex-direction: column; }
  .color-code-group input { width: 100%; }
}
  • Schritt 2: Visuell prüfen

Alle 4 Tabs durchklicken — aufgeräumtes Layout? DevTools → Toggle device toolbar → unter 600px: Navigation und Inputs stapeln sich vertikal.

  • Schritt 3: Committen
git add style.css
git commit -m "style: finales CSS-Finishing und mobile Anpassungen"