Files
Hellion-NewTab/docs/superpowers/plans/2026-04-16-calculator-upgrade.md

94 KiB
Raw Permalink Blame History

Calculator Upgrade v2.1.0 — 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: Extend the Calculator widget with 5 new modes — Scientific, Unit Converter, Satisfactory, Factorio, Stationeers — using a tab-based architecture with mode registration pattern.

Architecture: Each mode lives in its own IIFE file that calls Calculator.registerMode(). The core calculator.js gains a _modes Map, tab-bar rendering, and switchMode() logic. The Shunting-Yard parser is extended with ^ (power) and sqrt() (function). All new files load between calculator.js and timer.js in newtab.html.

Tech Stack: Vanilla JS ES2020, CSS Custom Properties, chrome.storage.local via Store, i18n via t() helper

Spec: docs/superpowers/specs/2026-04-16-calculator-upgrade-design.md


Task 1: Tab-System im Calculator Core

Files:

  • Modify: src/js/calculator.js (全 Datei — Tab-System, registerMode, switchMode, Standard-Mode-Extraktion)
  • Modify: src/css/main.css:1284 (neue CSS-Klassen nach bestehenden Calculator-Styles)

Kontext: Aktuell rendert Calculator.renderBody() direkt das Standard-UI (Display + Buttons + History). Wir refactoren das zu einem Tab-System: renderBody() baut Tab-Bar + Mode-Container, dann delegiert an den aktiven Modus. Der Standard-Modus wird als interner Modus registriert. Externe Modi registrieren sich per Calculator.registerMode().

  • Step 1: Neue Properties und registerMode() hinzufügen

In calculator.js, nach _keydownHandler: null, (Zeile 19) folgende Properties ergänzen:

_modes: new Map(),
_activeMode: 'standard',
_tabBarEl: null,

Neue Methode nach _keydownHandler Block:

/**
 * Modus registrieren (wird von externen Mode-Dateien aufgerufen)
 * @param {string} name - Eindeutiger Modus-Name
 * @param {Object} config - { label, shortName, titleKey, render(bodyEl), destroy() }
 */
registerMode(name, config) {
  this._modes.set(name, config);
  // Tab-Bar aktualisieren falls Widget bereits offen
  if (this._tabBarEl) this._renderTabBar();
},
  • Step 2: Standard-Modus als internen Modus registrieren

Am Ende der init() Methode (vor dem Schließen des Objekts), nach den Event-Listener-Registrierungen, den Standard-Modus registrieren:

// Standard-Modus intern registrieren
this._modes.set('standard', {
  label: '🔢',
  shortName: 'Std',
  titleKey: 'calculator.tab.standard',
  render: (bodyEl) => this._renderStandardMode(bodyEl),
  destroy: () => {
    // Standard-Modus hat keinen speziellen Cleanup
    this._displayExprEl = null;
    this._displayResultEl = null;
  }
});
  • Step 3: renderBody() refactoren — Tab-Bar + Mode-Container

Die bestehende renderBody() Methode komplett ersetzen:

/**
 * Calculator-Body rendern: Tab-Bar + aktiver Modus
 * @param {HTMLElement} bodyEl
 */
renderBody(bodyEl) {
  bodyEl.textContent = '';
  bodyEl.style.padding = '0';
  bodyEl.style.display = 'flex';
  bodyEl.style.flexDirection = 'column';
  bodyEl.style.height = '100%';

  // Tab-Bar
  const tabBar = document.createElement('div');
  tabBar.className = 'calc-tab-bar';
  this._tabBarEl = tabBar;
  this._renderTabBar();

  // Mode-Body Container
  const modeBody = document.createElement('div');
  modeBody.className = 'calc-mode-body';

  bodyEl.append(tabBar, modeBody);

  // Aktiven Modus rendern
  const mode = this._modes.get(this._activeMode);
  if (mode) {
    mode.render(modeBody);
  }
},
  • Step 4: _renderTabBar() und _updateTabBar() erstellen
/**
 * Tab-Bar mit Buttons aus _modes Map befüllen
 */
_renderTabBar() {
  if (!this._tabBarEl) return;
  // Alle bisherigen Tabs entfernen
  while (this._tabBarEl.firstChild) {
    this._tabBarEl.removeChild(this._tabBarEl.firstChild);
  }

  this._modes.forEach((config, name) => {
    const tab = document.createElement('button');
    tab.type = 'button';
    tab.className = 'calc-tab' + (name === this._activeMode ? ' active' : '');
    tab.dataset.mode = name;

    const icon = document.createElement('span');
    icon.className = 'calc-tab-icon';
    icon.textContent = config.label;

    const label = document.createElement('span');
    label.className = 'calc-tab-label';
    label.textContent = config.shortName;

    tab.append(icon, label);
    tab.addEventListener('click', () => this.switchMode(name));
    this._tabBarEl.appendChild(tab);
  });
},

/**
 * Aktiven Tab visuell markieren (ohne Neuaufbau)
 */
_updateTabBar() {
  if (!this._tabBarEl) return;
  const tabs = this._tabBarEl.querySelectorAll('.calc-tab');
  tabs.forEach(tab => {
    tab.classList.toggle('active', tab.dataset.mode === this._activeMode);
  });
},
  • Step 5: switchMode() erstellen
/**
 * Modus wechseln
 * @param {string} name - Ziel-Modus
 */
async switchMode(name) {
  if (name === this._activeMode) return;
  const mode = this._modes.get(name);
  if (!mode) return;

  // Alten Modus aufräumen
  const oldMode = this._modes.get(this._activeMode);
  if (oldMode && oldMode.destroy) oldMode.destroy();

  this._activeMode = name;

  // Mode-Body leeren und neu rendern
  const entry = WidgetManager._widgets.get(this.WIDGET_ID);
  if (!entry) return;
  const modeBody = entry.el.querySelector('.calc-mode-body');
  if (!modeBody) return;
  modeBody.textContent = '';
  mode.render(modeBody);

  // Tab-UI aktualisieren
  this._updateTabBar();

  // Auto-Resize für komplexe Modi
  const isComplex = name !== 'standard';
  if (isComplex) {
    const state = WidgetManager.getState(this.WIDGET_ID);
    if (state) {
      const newW = Math.max(state.width, 320);
      const newH = Math.max(state.height, 480);
      if (newW !== state.width || newH !== state.height) {
        WidgetManager.resize(this.WIDGET_ID, newW, newH);
      }
    }
  }

  // Keyboard neu binden
  this._unbindKeyboard();
  if (name === 'standard' || name === 'scientific') {
    if (entry) this._bindKeyboard(entry.el);
  }

  await this.save();
},
  • Step 6: _renderStandardMode() extrahieren

Neue Methode, die den bisherigen renderBody() Inhalt enthält (Display + Buttons + History):

/**
 * Standard-Modus UI rendern
 * @param {HTMLElement} bodyEl
 */
_renderStandardMode(bodyEl) {
  bodyEl.style.padding = '8px';
  bodyEl.style.display = 'flex';
  bodyEl.style.flexDirection = 'column';
  bodyEl.style.flex = '1';
  bodyEl.style.overflow = 'hidden';

  // Display
  const display = document.createElement('div');
  display.className = 'calc-display';

  const exprEl = document.createElement('div');
  exprEl.className = 'calc-expression';
  this._displayExprEl = exprEl;

  const resultEl = document.createElement('div');
  resultEl.className = 'calc-result';
  resultEl.textContent = '0';
  this._displayResultEl = resultEl;

  display.append(exprEl, resultEl);

  // Buttons
  const buttonsEl = this._createButtons();

  // History
  const historyEl = this._createHistoryPanel();

  bodyEl.append(display, buttonsEl, historyEl);

  // Aktuellen State anzeigen
  this._updateDisplay();
},
  • Step 7: save() und load() um activeMode erweitern

In save(), calcData um activeMode erweitern:

const calcData = {
  x: widgetState ? widgetState.x : 400,
  y: widgetState ? widgetState.y : 120,
  width: widgetState ? widgetState.width : 280,
  height: widgetState ? widgetState.height : 400,
  open: this._isOpen,
  activeMode: this._activeMode,
  history: this._history.slice(0, this.MAX_HISTORY)
};

In load(), activeMode wiederherstellen:

async load() {
  const data = await Store.get(this.STORAGE_KEY);
  if (data && data.calculator) {
    this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
    if (data.calculator.activeMode) {
      this._activeMode = data.calculator.activeMode;
    }
  }
},

In onClose(), alten Modus aufräumen:

async onClose() {
  // Aktiven Modus aufräumen
  const mode = this._modes.get(this._activeMode);
  if (mode && mode.destroy) mode.destroy();

  this._isOpen = false;
  this._unbindKeyboard();
  this._tabBarEl = null;
  this._displayExprEl = null;
  this._displayResultEl = null;
  await this.save();
},
  • Step 8: CSS für Tab-Bar und Mode-Body

In src/css/main.css, nach dem .calc-history-item .calc-h-result Block (Zeile 1283), vor den Timer-Styles einfügen:

/* Calculator Tab System */
.calc-tab-bar {
  display: flex;
  background: rgba(0,0,0,0.2);
  border-bottom: 1px solid var(--border);
  overflow-x: auto;
  scrollbar-width: none;
  flex-shrink: 0;
}
.calc-tab-bar::-webkit-scrollbar {
  display: none;
}
.calc-tab {
  display: flex;
  align-items: center;
  gap: 3px;
  padding: 6px 8px;
  border: none;
  border-bottom: 2px solid transparent;
  background: none;
  color: var(--text-muted);
  font-size: 11px;
  font-family: 'Rajdhani', sans-serif;
  cursor: pointer;
  white-space: nowrap;
  transition: color 0.15s, border-color 0.15s;
  flex-shrink: 0;
}
.calc-tab:hover {
  color: var(--text-secondary);
}
.calc-tab.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
  font-weight: 600;
}
.calc-tab-icon {
  font-size: 12px;
}
.calc-tab-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}
.calc-mode-body {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
}
  • Step 9: i18n-Key für Standard-Tab

In src/js/i18n.js, nach 'calculator.error' in beiden Sprachen:

DE:

'calculator.tab.standard':   'Standard',

EN:

'calculator.tab.standard':   'Standard',
  • Step 10: Testen
  1. Extension in Chrome laden
  2. Calculator öffnen → Tab-Bar mit einem Tab "🔢 Std" sichtbar
  3. Calculator schließen und wieder öffnen → State bleibt erhalten
  4. Standard-Rechner funktioniert wie vorher (Buttons, History, Keyboard)
  • Step 11: Commit
git add src/js/calculator.js src/css/main.css src/js/i18n.js
git commit -m "feat(calculator): Tab-System mit registerMode() und switchMode()"

Task 2: Parser-Erweiterung — ^ und sqrt

Files:

  • Modify: src/js/calculator.js_evaluate(), _tokenize(), _parseExpression() innere Funktionen

Kontext: Der Shunting-Yard-Parser kennt aktuell nur +, -, *, /, %. Wir fügen ^ (Potenz, rechts-assoziativ) und sqrt (unäre Funktion) hinzu. Die neue Hierarchie: parseExpr(+,-)parseTerm(*,/,%)parsePower(^)parseFactor(number | parens | func).

  • Step 1: Sanitizer erweitern

In _evaluate(), die Sanitizer-Regex erweitern um ^ und Buchstaben (für sqrt):

_evaluate(expr) {
  try {
    // Nur erlaubte Zeichen (inkl. ^ und sqrt-Buchstaben)
    const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, '');
    if (!sanitized) return null;

    const tokens = this._tokenize(sanitized);
    if (!tokens) return null;

    return this._parseExpression(tokens);
  } catch {
    return null;
  }
},
  • Step 2: Tokenizer um ^ und sqrt erweitern

In _tokenize(), nach dem Operator-Block (if (/[+\-*/%]/.test(ch))) und vor dem Klammern-Block, ^ als Operator erkennen. Vor dem Zahlen-Block sqrt als Funktion erkennen:

_tokenize(expr) {
  const tokens = [];
  let i = 0;

  while (i < expr.length) {
    const ch = expr[i];

    // Funktion: sqrt
    if (expr.substring(i, i + 4) === 'sqrt') {
      tokens.push({ type: 'func', value: 'sqrt' });
      i += 4;
      continue;
    }

    // Zahl (inkl. Dezimal)
    if (/[0-9.]/.test(ch)) {
      let num = '';
      while (i < expr.length && /[0-9.]/.test(expr[i])) {
        num += expr[i];
        i++;
      }
      const parsed = parseFloat(num);
      if (isNaN(parsed)) return null;
      tokens.push({ type: 'number', value: parsed });
      continue;
    }

    // Potenz-Operator
    if (ch === '^') {
      tokens.push({ type: 'op', value: '^' });
      i++;
      continue;
    }

    // Operator
    if (/[+\-*/%]/.test(ch)) {
      // Negativer Vorzeichen-Check: am Anfang oder nach Operator/oeffnender Klammer
      if (ch === '-') {
        const prev = tokens[tokens.length - 1];
        if (!prev || prev.type === 'op' || (prev.type === 'paren' && prev.value === '(')) {
          let num = '-';
          i++;
          while (i < expr.length && /[0-9.]/.test(expr[i])) {
            num += expr[i];
            i++;
          }
          if (num === '-') return null;
          const parsed = parseFloat(num);
          if (isNaN(parsed)) return null;
          tokens.push({ type: 'number', value: parsed });
          continue;
        }
      }
      tokens.push({ type: 'op', value: ch });
      i++;
      continue;
    }

    // Klammern
    if (ch === '(' || ch === ')') {
      tokens.push({ type: 'paren', value: ch });
      i++;
      continue;
    }

    // Unbekannte Buchstaben überspringen (können Reste von Funktionsnamen sein)
    if (/[a-z]/.test(ch)) {
      return null;
    }

    // Unbekanntes Zeichen
    return null;
  }

  return tokens;
},
  • Step 3: Parser um parsePower() und Funktions-Support erweitern

Die _parseExpression() Methode komplett ersetzen. parseTerm() ruft jetzt parsePower() statt parseFactor() auf. parsePower() ist rechts-assoziativ. parseFactor() prüft auf Funktionen:

_parseExpression(tokens) {
  let pos = 0;

  function peek() { return tokens[pos]; }
  function consume() { return tokens[pos++]; }

  // Expression: Term (('+' | '-') Term)*
  function parseExpr() {
    let left = parseTerm();
    if (left === null) return null;

    while (pos < tokens.length) {
      const tk = peek();
      if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break;
      consume();
      const right = parseTerm();
      if (right === null) return null;
      left = tk.value === '+' ? left + right : left - right;
    }
    return left;
  }

  // Term: Power (('*' | '/' | '%') Power)*
  function parseTerm() {
    let left = parsePower();
    if (left === null) return null;

    while (pos < tokens.length) {
      const tk = peek();
      if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break;
      consume();
      const right = parsePower();
      if (right === null) return null;
      if (tk.value === '*') {
        left = left * right;
      } else if (tk.value === '/') {
        if (right === 0) return null;
        left = left / right;
      } else {
        left = left % right;
      }
    }
    return left;
  }

  // Power: Factor ('^' Power)?  — rechts-assoziativ durch Rekursion
  function parsePower() {
    let base = parseFactor();
    if (base === null) return null;

    const tk = peek();
    if (tk && tk.type === 'op' && tk.value === '^') {
      consume();
      const exp = parsePower(); // Rechts-assoziativ!
      if (exp === null) return null;
      return Math.pow(base, exp);
    }
    return base;
  }

  // Factor: func '(' Expression ')' | Number | '(' Expression ')'
  function parseFactor() {
    const tk = peek();
    if (!tk) return null;

    // Funktion: sqrt(...)
    if (tk.type === 'func') {
      const funcName = tk.value;
      consume();
      // Erwarte öffnende Klammer
      const open = peek();
      if (!open || open.type !== 'paren' || open.value !== '(') return null;
      consume();
      const val = parseExpr();
      if (val === null) return null;
      const close = peek();
      if (close && close.type === 'paren' && close.value === ')') {
        consume();
      }
      if (funcName === 'sqrt') {
        if (val < 0) return null; // Keine negativen Wurzeln
        return Math.sqrt(val);
      }
      return null;
    }

    if (tk.type === 'number') {
      consume();
      return tk.value;
    }

    if (tk.type === 'paren' && tk.value === '(') {
      consume();
      const val = parseExpr();
      if (val === null) return null;
      const closing = peek();
      if (closing && closing.type === 'paren' && closing.value === ')') {
        consume();
      }
      return val;
    }

    return null;
  }

  const result = parseExpr();

  // Alle Tokens muessen verbraucht sein
  if (pos < tokens.length) return null;

  if (result === null || !isFinite(result)) return null;
  return result;
},
  • Step 4: _handleKey() um ^ erweitern

Im default-case der _handleKey() Methode, nach dem Ziffern-Check, ^ als Operator behandeln. In den Operator-Case (die bestehende case '%': case '+': ... Kette), '^' hinzufügen:

case '%':
case '+':
case '-':
case '*':
case '/':
case '^': {
  // Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
  if (this._lastResult && this._currentExpr === '') {
    this._currentExpr = this._lastResult;
    this._lastResult = '';
  }
  // Doppelte Operatoren verhindern (letzten ersetzen)
  const last = this._currentExpr.slice(-1);
  if (/[+\-*/%^]/.test(last)) {
    this._currentExpr = this._currentExpr.slice(0, -1) + key;
  } else {
    this._currentExpr += key;
  }
  break;
}
  • Step 5: _formatExpression() um ^ erweitern
_formatExpression(expr) {
  return expr
    .replace(/\*/g, '\u00D7')
    .replace(/\//g, '\u00F7')
    .replace(/sqrt\(/g, '√(');
},
  • Step 6: Testen
  1. Calculator öffnen
  2. 2^10 eingeben → Ergebnis: 1024
  3. 2^3^2 eingeben → Ergebnis: 512 (rechts-assoziativ)
  4. In Browser-Console: Calculator._evaluate('sqrt(144)') → 12
  5. 3+sqrt(16) → 7
  6. sqrt(-4) → Fehler
  • Step 7: Commit
git add src/js/calculator.js
git commit -m "feat(calculator): Parser um ^ (Potenz) und sqrt() erweitern"

Task 3: Scientific-Modus

Files:

  • Create: src/js/calc-scientific.js
  • Modify: newtab.html:509 (Script-Tag nach calculator.js einfügen)
  • Modify: src/js/i18n.js (neue Keys)
  • Modify: src/css/main.css (Scientific-spezifische Styles)

Kontext: Der Scientific-Modus erweitert den Standard-Rechner um 6 wissenschaftliche Buttons (√, x², xⁿ, π, e, ±) und einen Formel-Helfer mit 6 vorgefertigten Formeln. Er nutzt den gleichen _handleKey()/_calculate() Flow des Cores.

  • Step 1: calc-scientific.js erstellen
/* =============================================
   HELLION NEWTAB — calc-scientific.js
   Scientific-Modus für Calculator Widget
   ============================================= */

(function() {
  'use strict';

  /** Formel-Definitionen */
  const FORMULAS = [
    {
      key: 'circle_area',
      fields: [{ key: 'radius', default: '' }],
      calc: (vals) => Math.PI * vals.radius * vals.radius
    },
    {
      key: 'circle_circumference',
      fields: [{ key: 'radius', default: '' }],
      calc: (vals) => 2 * Math.PI * vals.radius
    },
    {
      key: 'celsius_to_fahrenheit',
      fields: [{ key: 'temp', default: '' }],
      calc: (vals) => (vals.temp * 9 / 5) + 32
    },
    {
      key: 'fahrenheit_to_celsius',
      fields: [{ key: 'temp', default: '' }],
      calc: (vals) => (vals.temp - 32) * 5 / 9
    },
    {
      key: 'pythagoras',
      fields: [{ key: 'a', default: '' }, { key: 'b', default: '' }],
      calc: (vals) => Math.sqrt(vals.a * vals.a + vals.b * vals.b)
    },
    {
      key: 'percentage',
      fields: [{ key: 'value', default: '' }, { key: 'percent', default: '' }],
      calc: (vals) => vals.value * vals.percent / 100
    }
  ];

  /** Keyboard-Handler Referenz für Cleanup */
  let _keyboardExtHandler = null;

  /**
   * Scientific-Buttons rendern
   * @param {HTMLElement} container
   */
  function renderSciButtons(container) {
    const grid = document.createElement('div');
    grid.className = 'calc-buttons calc-sci-buttons';

    const buttons = [
      ['√',  'sqrt',   'operator'],
      ['x²', 'square', 'operator'],
      ['xⁿ', 'power',  'operator'],
      ['π',  'pi',     'operator'],
      ['e',  'euler',  'operator'],
      ['±',  'negate', 'operator']
    ];

    buttons.forEach(([label, value, cls]) => {
      const btn = document.createElement('button');
      btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
      btn.textContent = label;
      btn.type = 'button';
      btn.addEventListener('click', () => handleSciKey(value));
      grid.appendChild(btn);
    });

    container.appendChild(grid);
  }

  /**
   * Scientific-Taste verarbeiten
   * @param {string} key
   */
  function handleSciKey(key) {
    switch (key) {
      case 'sqrt':
        Calculator._currentExpr += 'sqrt(';
        Calculator._updateDisplay();
        break;
      case 'square':
        Calculator._currentExpr += '^2';
        Calculator._updateDisplay();
        break;
      case 'power':
        Calculator._handleKey('^');
        break;
      case 'pi':
        Calculator._currentExpr += '3.14159265359';
        Calculator._updateDisplay();
        break;
      case 'euler':
        Calculator._currentExpr += '2.71828182846';
        Calculator._updateDisplay();
        break;
      case 'negate':
        handleNegate();
        break;
    }
  }

  /**
   * Vorzeichen-Wechsel: letzten numerischen Wert negieren
   */
  function handleNegate() {
    const expr = Calculator._currentExpr;
    if (!expr && Calculator._lastResult) {
      // Ergebnis negieren
      const num = parseFloat(Calculator._lastResult);
      if (!isNaN(num)) {
        Calculator._currentExpr = String(-num);
        Calculator._lastResult = '';
        Calculator._updateDisplay();
      }
      return;
    }
    // Letzte Zahl in der Expression finden und negieren
    const match = expr.match(/(-?\d+\.?\d*)$/);
    if (match) {
      const num = parseFloat(match[1]);
      const negated = String(-num);
      Calculator._currentExpr = expr.slice(0, expr.length - match[1].length) + negated;
      Calculator._updateDisplay();
    }
  }

  /**
   * Formel-Helfer UI rendern
   * @param {HTMLElement} container
   */
  function renderFormulaHelper(container) {
    const wrapper = document.createElement('div');
    wrapper.className = 'calc-formula-helper';

    const label = document.createElement('div');
    label.className = 'calc-formula-label';
    label.textContent = t('calculator.sci.formulas');

    const select = document.createElement('select');
    select.className = 'calc-formula-select';

    // Leer-Option
    const emptyOpt = document.createElement('option');
    emptyOpt.value = '';
    emptyOpt.textContent = t('calculator.sci.select_formula');
    select.appendChild(emptyOpt);

    FORMULAS.forEach((f, i) => {
      const opt = document.createElement('option');
      opt.value = String(i);
      opt.textContent = t('calculator.sci.formula.' + f.key);
      select.appendChild(opt);
    });

    const inputsContainer = document.createElement('div');
    inputsContainer.className = 'calc-formula-inputs';

    const resultContainer = document.createElement('div');
    resultContainer.className = 'calc-formula-result';

    select.addEventListener('change', () => {
      // Inputs leeren
      while (inputsContainer.firstChild) {
        inputsContainer.removeChild(inputsContainer.firstChild);
      }
      resultContainer.textContent = '';

      const idx = parseInt(select.value, 10);
      if (isNaN(idx)) return;

      const formula = FORMULAS[idx];
      renderFormulaInputs(formula, inputsContainer, resultContainer);
    });

    wrapper.append(label, select, inputsContainer, resultContainer);
    container.appendChild(wrapper);
  }

  /**
   * Formel-Eingabefelder und Live-Ergebnis rendern
   * @param {Object} formula
   * @param {HTMLElement} inputsEl
   * @param {HTMLElement} resultEl
   */
  function renderFormulaInputs(formula, inputsEl, resultEl) {
    const inputs = {};

    formula.fields.forEach(field => {
      const row = document.createElement('div');
      row.className = 'calc-formula-row';

      const lbl = document.createElement('label');
      lbl.textContent = t('calculator.sci.field.' + field.key);

      const inp = document.createElement('input');
      inp.type = 'number';
      inp.className = 'calc-formula-input';
      inp.placeholder = '0';
      inp.step = 'any';
      inputs[field.key] = inp;

      inp.addEventListener('input', () => {
        recalcFormula(formula, inputs, resultEl);
      });

      row.append(lbl, inp);
      inputsEl.appendChild(row);
    });
  }

  /**
   * Formel-Ergebnis live berechnen
   * @param {Object} formula
   * @param {Object} inputs - { key: HTMLInputElement }
   * @param {HTMLElement} resultEl
   */
  function recalcFormula(formula, inputs, resultEl) {
    const vals = {};
    let allValid = true;

    for (const field of formula.fields) {
      const v = parseFloat(inputs[field.key].value);
      if (isNaN(v)) { allValid = false; break; }
      vals[field.key] = v;
    }

    if (!allValid) {
      resultEl.textContent = '';
      return;
    }

    const result = formula.calc(vals);
    if (result === null || !isFinite(result)) {
      resultEl.textContent = t('calculator.error');
      return;
    }

    resultEl.textContent = '= ' + Calculator._formatResult(result);
  }

  /**
   * Keyboard-Erweiterung für Scientific-Modus
   * @param {HTMLElement} widgetEl
   */
  function bindSciKeyboard(widgetEl) {
    _keyboardExtHandler = (e) => {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
      if (e.target.contentEditable === 'true') return;

      if (e.key === 'p') {
        handleSciKey('pi');
        e.preventDefault();
        e.stopPropagation();
      } else if (e.key === '^') {
        handleSciKey('power');
        e.preventDefault();
        e.stopPropagation();
      }
    };
    widgetEl.addEventListener('keydown', _keyboardExtHandler);
  }

  // Modus registrieren
  Calculator.registerMode('scientific', {
    label: '📐',
    shortName: 'Sci',
    titleKey: 'calculator.tab.scientific',

    render(bodyEl) {
      bodyEl.style.padding = '8px';
      bodyEl.style.display = 'flex';
      bodyEl.style.flexDirection = 'column';
      bodyEl.style.flex = '1';
      bodyEl.style.overflow = 'hidden';

      // Display (gemeinsam mit Standard)
      const display = document.createElement('div');
      display.className = 'calc-display';

      const exprEl = document.createElement('div');
      exprEl.className = 'calc-expression';
      Calculator._displayExprEl = exprEl;

      const resultEl = document.createElement('div');
      resultEl.className = 'calc-result';
      resultEl.textContent = Calculator._lastResult || '0';
      Calculator._displayResultEl = resultEl;

      display.append(exprEl, resultEl);

      // Scientific-Buttons (2x3 Grid)
      const sciSection = document.createElement('div');
      renderSciButtons(sciSection);

      // Standard-Buttons (4x5 Grid)
      const stdButtons = Calculator._createButtons();

      // History
      const historyEl = Calculator._createHistoryPanel();

      // Formel-Helfer
      const formulaSection = document.createElement('div');
      renderFormulaHelper(formulaSection);

      bodyEl.append(display, sciSection, stdButtons, historyEl, formulaSection);
      Calculator._updateDisplay();

      // Keyboard erweitern
      const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
      if (entry) bindSciKeyboard(entry.el);
    },

    destroy() {
      // Keyboard-Extension entfernen
      if (_keyboardExtHandler) {
        const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
        if (entry) {
          entry.el.removeEventListener('keydown', _keyboardExtHandler);
        }
        _keyboardExtHandler = null;
      }
      Calculator._displayExprEl = null;
      Calculator._displayResultEl = null;
    }
  });
})();
  • Step 2: Script-Tag in newtab.html einfügen

Nach Zeile 509 (<script src="src/js/calculator.js"></script>):

  <script src="src/js/calc-scientific.js"></script>
  • Step 3: CSS für Scientific-Modus

In src/css/main.css, nach den Tab-System Styles:

/* Calculator Scientific Mode */
.calc-sci-buttons {
  grid-template-columns: repeat(3, 1fr);
  margin-bottom: 4px;
}
.calc-formula-helper {
  border-top: 1px solid var(--border);
  padding-top: 8px;
  margin-top: 4px;
}
.calc-formula-label {
  font-size: 9px;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 1px;
  margin-bottom: 4px;
}
.calc-formula-select {
  width: 100%;
  padding: 4px 6px;
  background: rgba(0,0,0,0.3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 12px;
  font-family: 'Rajdhani', sans-serif;
  margin-bottom: 6px;
}
.calc-formula-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
}
.calc-formula-row label {
  font-size: 11px;
  color: var(--text-secondary);
  min-width: 50px;
}
.calc-formula-input {
  flex: 1;
  padding: 4px 6px;
  background: rgba(0,0,0,0.3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 12px;
  font-family: 'Rajdhani', sans-serif;
}
.calc-formula-result {
  font-size: 14px;
  color: var(--accent);
  font-weight: 600;
  font-family: 'Rajdhani', monospace;
  text-align: right;
  min-height: 20px;
  padding: 2px 0;
}
  • Step 4: i18n-Keys für Scientific

In src/js/i18n.js, DE-Block nach 'calculator.tab.standard':

'calculator.tab.scientific':         'Wissenschaftlich',
'calculator.sci.formulas':           'Formel-Helfer',
'calculator.sci.select_formula':     'Formel wählen…',
'calculator.sci.formula.circle_area':         'Kreisfläche (π×r²)',
'calculator.sci.formula.circle_circumference':'Kreisumfang (2πr)',
'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F',
'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C',
'calculator.sci.formula.pythagoras':          'Pythagoras (√(a²+b²))',
'calculator.sci.formula.percentage':          'Prozentwert',
'calculator.sci.field.radius':       'Radius',
'calculator.sci.field.temp':         'Temperatur',
'calculator.sci.field.a':            'Seite a',
'calculator.sci.field.b':            'Seite b',
'calculator.sci.field.value':        'Wert',
'calculator.sci.field.percent':      'Prozent',

EN-Block nach 'calculator.tab.standard':

'calculator.tab.scientific':         'Scientific',
'calculator.sci.formulas':           'Formula Helper',
'calculator.sci.select_formula':     'Choose formula…',
'calculator.sci.formula.circle_area':         'Circle Area (π×r²)',
'calculator.sci.formula.circle_circumference':'Circle Circumference (2πr)',
'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F',
'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C',
'calculator.sci.formula.pythagoras':          'Pythagoras (√(a²+b²))',
'calculator.sci.formula.percentage':          'Percentage',
'calculator.sci.field.radius':       'Radius',
'calculator.sci.field.temp':         'Temperature',
'calculator.sci.field.a':            'Side a',
'calculator.sci.field.b':            'Side b',
'calculator.sci.field.value':        'Value',
'calculator.sci.field.percent':      'Percent',
  • Step 5: Testen
  1. Calculator öffnen → 2 Tabs sichtbar (Std, Sci)
  2. Sci-Tab klicken → 6 Scientific-Buttons + Standard-Grid + Formel-Helfer
  3. √ klicken → sqrt( im Display, 9 eingeben, ) eingeben, = → 3
  4. π klicken → 3.14159265359 im Display
  5. Formel-Dropdown "Kreisfläche" wählen → Radius-Feld, r=5 → = 78.5398...
  6. p-Taste → Pi einfügen
  7. ^-Taste → Potenz-Operator
  • Step 6: Commit
git add src/js/calc-scientific.js newtab.html src/css/main.css src/js/i18n.js
git commit -m "feat(calculator): Scientific-Modus mit Formel-Helfer"

Task 4: Unit-Converter

Files:

  • Create: src/js/calc-converter.js
  • Modify: newtab.html (Script-Tag nach calc-scientific.js)
  • Modify: src/js/i18n.js (neue Keys)
  • Modify: src/css/main.css (Converter-spezifische Styles)

Kontext: 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit toBase/fromBase Pattern. Temperatur als Spezialfall. Live-Update bei Eingabe, Swap-Button, Schnellreferenz.

  • Step 1: calc-converter.js erstellen
/* =============================================
   HELLION NEWTAB — calc-converter.js
   Unit-Converter Modus für Calculator Widget
   ============================================= */

(function() {
  'use strict';

  // ---- UNIT DEFINITIONS ----

  const CATEGORIES = {
    length: {
      titleKey: 'calculator.conv.cat.length',
      baseUnit: 'm',
      units: {
        mm:  { toBase: v => v / 1000,       fromBase: v => v * 1000 },
        cm:  { toBase: v => v / 100,        fromBase: v => v * 100 },
        m:   { toBase: v => v,              fromBase: v => v },
        km:  { toBase: v => v * 1000,       fromBase: v => v / 1000 },
        in:  { toBase: v => v * 0.0254,     fromBase: v => v / 0.0254 },
        ft:  { toBase: v => v * 0.3048,     fromBase: v => v / 0.3048 },
        yd:  { toBase: v => v * 0.9144,     fromBase: v => v / 0.9144 },
        mi:  { toBase: v => v * 1609.344,   fromBase: v => v / 1609.344 }
      }
    },
    weight: {
      titleKey: 'calculator.conv.cat.weight',
      baseUnit: 'g',
      units: {
        mg:  { toBase: v => v / 1000,       fromBase: v => v * 1000 },
        g:   { toBase: v => v,              fromBase: v => v },
        kg:  { toBase: v => v * 1000,       fromBase: v => v / 1000 },
        t:   { toBase: v => v * 1000000,    fromBase: v => v / 1000000 },
        oz:  { toBase: v => v * 28.3495,    fromBase: v => v / 28.3495 },
        lb:  { toBase: v => v * 453.592,    fromBase: v => v / 453.592 }
      }
    },
    temperature: {
      titleKey: 'calculator.conv.cat.temperature',
      baseUnit: null, // Spezialfall
      units: { '\u00B0C': null, '\u00B0F': null, 'K': null },
      convert(value, from, to) {
        if (from === to) return value;
        const key = from + '_' + to;
        const conversions = {
          '\u00B0C_\u00B0F': v => (v * 9 / 5) + 32,
          '\u00B0C_K':  v => v + 273.15,
          '\u00B0F_\u00B0C': v => (v - 32) * 5 / 9,
          '\u00B0F_K':  v => (v - 32) * 5 / 9 + 273.15,
          'K_\u00B0C':  v => v - 273.15,
          'K_\u00B0F':  v => (v - 273.15) * 9 / 5 + 32
        };
        const fn = conversions[key];
        return fn ? fn(value) : null;
      }
    },
    volume: {
      titleKey: 'calculator.conv.cat.volume',
      baseUnit: 'ml',
      units: {
        ml:       { toBase: v => v,               fromBase: v => v },
        L:        { toBase: v => v * 1000,         fromBase: v => v / 1000 },
        'm\u00B3':{ toBase: v => v * 1000000,      fromBase: v => v / 1000000 },
        'gal(US)':{ toBase: v => v * 3785.41,      fromBase: v => v / 3785.41 },
        'gal(UK)':{ toBase: v => v * 4546.09,      fromBase: v => v / 4546.09 },
        'ft\u00B3':{ toBase: v => v * 28316.8,     fromBase: v => v / 28316.8 }
      }
    },
    speed: {
      titleKey: 'calculator.conv.cat.speed',
      baseUnit: 'm/s',
      units: {
        'm/s':  { toBase: v => v,             fromBase: v => v },
        'km/h': { toBase: v => v / 3.6,      fromBase: v => v * 3.6 },
        'mph':  { toBase: v => v * 0.44704,   fromBase: v => v / 0.44704 },
        'kn':   { toBase: v => v * 0.514444,  fromBase: v => v / 0.514444 }
      }
    },
    area: {
      titleKey: 'calculator.conv.cat.area',
      baseUnit: 'm\u00B2',
      units: {
        'mm\u00B2': { toBase: v => v / 1000000,     fromBase: v => v * 1000000 },
        'cm\u00B2': { toBase: v => v / 10000,        fromBase: v => v * 10000 },
        'm\u00B2':  { toBase: v => v,                fromBase: v => v },
        'km\u00B2': { toBase: v => v * 1000000,      fromBase: v => v / 1000000 },
        'ha':       { toBase: v => v * 10000,         fromBase: v => v / 10000 },
        'acre':     { toBase: v => v * 4046.86,       fromBase: v => v / 4046.86 },
        'ft\u00B2': { toBase: v => v * 0.092903,     fromBase: v => v / 0.092903 },
        'in\u00B2': { toBase: v => v * 0.00064516,   fromBase: v => v / 0.00064516 }
      }
    }
  };

  const CATEGORY_ORDER = ['length', 'weight', 'temperature', 'volume', 'speed', 'area'];

  // ---- STATE ----

  let _currentCategory = 'length';
  let _fromUnit = 'cm';
  let _toUnit = 'in';
  let _fromInput = null;
  let _toInput = null;
  let _refEl = null;

  // ---- CONVERSION ----

  /**
   * Wert konvertieren
   * @param {number} value
   * @param {string} from - Quelleinheit
   * @param {string} to - Zieleinheit
   * @returns {number|null}
   */
  function convert(value, from, to) {
    const cat = CATEGORIES[_currentCategory];
    if (!cat) return null;

    if (cat.convert) {
      return cat.convert(value, from, to);
    }

    const fromDef = cat.units[from];
    const toDef = cat.units[to];
    if (!fromDef || !toDef) return null;

    const base = fromDef.toBase(value);
    return toDef.fromBase(base);
  }

  /**
   * Live-Berechnung auslösen
   */
  function recalc() {
    if (!_fromInput || !_toInput) return;
    const val = parseFloat(_fromInput.value);
    if (isNaN(val)) {
      _toInput.value = '';
      updateReference();
      return;
    }
    const result = convert(val, _fromUnit, _toUnit);
    if (result === null) {
      _toInput.value = '';
    } else {
      _toInput.value = Calculator._formatResult(result);
    }
    updateReference();
  }

  /**
   * Schnellreferenz aktualisieren
   */
  function updateReference() {
    if (!_refEl) return;
    _refEl.textContent = '';

    const r1 = convert(1, _fromUnit, _toUnit);
    const r2 = convert(1, _toUnit, _fromUnit);

    if (r1 !== null) {
      const line1 = document.createElement('div');
      line1.textContent = '1 ' + _fromUnit + ' = ' + Calculator._formatResult(r1) + ' ' + _toUnit;
      _refEl.appendChild(line1);
    }
    if (r2 !== null) {
      const line2 = document.createElement('div');
      line2.textContent = '1 ' + _toUnit + ' = ' + Calculator._formatResult(r2) + ' ' + _fromUnit;
      _refEl.appendChild(line2);
    }
  }

  /**
   * Unit-Selects für aktuelle Kategorie befüllen
   * @param {HTMLSelectElement} selectEl
   * @param {string} selectedUnit
   */
  function populateUnitSelect(selectEl, selectedUnit) {
    while (selectEl.firstChild) {
      selectEl.removeChild(selectEl.firstChild);
    }
    const cat = CATEGORIES[_currentCategory];
    if (!cat) return;

    const units = Object.keys(cat.units);
    units.forEach(unit => {
      const opt = document.createElement('option');
      opt.value = unit;
      opt.textContent = unit;
      if (unit === selectedUnit) opt.selected = true;
      selectEl.appendChild(opt);
    });
  }

  /**
   * Kategorie-spezifische Defaults für Units
   * @param {string} catKey
   * @returns {{ from: string, to: string }}
   */
  function getDefaultUnits(catKey) {
    const defaults = {
      length: { from: 'cm', to: 'in' },
      weight: { from: 'kg', to: 'lb' },
      temperature: { from: '\u00B0C', to: '\u00B0F' },
      volume: { from: 'L', to: 'gal(US)' },
      speed: { from: 'km/h', to: 'mph' },
      area: { from: 'm\u00B2', to: 'ft\u00B2' }
    };
    return defaults[catKey] || { from: Object.keys(CATEGORIES[catKey].units)[0], to: Object.keys(CATEGORIES[catKey].units)[1] };
  }

  /**
   * State aus Storage laden
   */
  async function loadState() {
    const data = await Store.get(Calculator.STORAGE_KEY);
    if (data && data.calculator && data.calculator.converter) {
      const s = data.calculator.converter;
      if (s.lastCategory && CATEGORIES[s.lastCategory]) _currentCategory = s.lastCategory;
      if (s.fromUnit) _fromUnit = s.fromUnit;
      if (s.toUnit) _toUnit = s.toUnit;
    }
  }

  /**
   * State in Storage speichern
   */
  async function saveState() {
    const data = await Store.get(Calculator.STORAGE_KEY) || {};
    if (!data.calculator) data.calculator = {};
    data.calculator.converter = {
      lastCategory: _currentCategory,
      fromUnit: _fromUnit,
      toUnit: _toUnit
    };
    await Store.set(Calculator.STORAGE_KEY, data);
  }

  // ---- REGISTRATION ----

  Calculator.registerMode('converter', {
    label: '⚖️',
    shortName: 'Unit',
    titleKey: 'calculator.tab.converter',

    render(bodyEl) {
      bodyEl.style.padding = '8px';
      bodyEl.style.display = 'flex';
      bodyEl.style.flexDirection = 'column';
      bodyEl.style.gap = '8px';

      loadState().then(() => {
        buildUI(bodyEl);
      });
    },

    destroy() {
      _fromInput = null;
      _toInput = null;
      _refEl = null;
      saveState();
    }
  });

  /**
   * Converter-UI aufbauen
   * @param {HTMLElement} bodyEl
   */
  function buildUI(bodyEl) {
    // Kategorie-Dropdown
    const catSelect = document.createElement('select');
    catSelect.className = 'calc-conv-select';

    CATEGORY_ORDER.forEach(catKey => {
      const opt = document.createElement('option');
      opt.value = catKey;
      opt.textContent = t(CATEGORIES[catKey].titleKey);
      if (catKey === _currentCategory) opt.selected = true;
      catSelect.appendChild(opt);
    });

    // From-Zeile
    const fromRow = document.createElement('div');
    fromRow.className = 'calc-conv-row';

    _fromInput = document.createElement('input');
    _fromInput.type = 'number';
    _fromInput.className = 'calc-conv-input';
    _fromInput.placeholder = '0';
    _fromInput.step = 'any';

    const fromSelect = document.createElement('select');
    fromSelect.className = 'calc-conv-unit';
    populateUnitSelect(fromSelect, _fromUnit);

    fromRow.append(_fromInput, fromSelect);

    // Swap-Button
    const swapBtn = document.createElement('button');
    swapBtn.type = 'button';
    swapBtn.className = 'calc-conv-swap';
    swapBtn.textContent = '\u21C5';
    swapBtn.title = t('calculator.conv.swap');

    // To-Zeile
    const toRow = document.createElement('div');
    toRow.className = 'calc-conv-row';

    _toInput = document.createElement('input');
    _toInput.type = 'text';
    _toInput.className = 'calc-conv-input';
    _toInput.readOnly = true;
    _toInput.placeholder = '0';

    const toSelect = document.createElement('select');
    toSelect.className = 'calc-conv-unit';
    populateUnitSelect(toSelect, _toUnit);

    toRow.append(_toInput, toSelect);

    // Schnellreferenz
    _refEl = document.createElement('div');
    _refEl.className = 'calc-conv-ref';

    // Event Listeners
    _fromInput.addEventListener('input', () => recalc());

    fromSelect.addEventListener('change', () => {
      _fromUnit = fromSelect.value;
      recalc();
      saveState();
    });

    toSelect.addEventListener('change', () => {
      _toUnit = toSelect.value;
      recalc();
      saveState();
    });

    swapBtn.addEventListener('click', () => {
      const tmpUnit = _fromUnit;
      _fromUnit = _toUnit;
      _toUnit = tmpUnit;
      populateUnitSelect(fromSelect, _fromUnit);
      populateUnitSelect(toSelect, _toUnit);
      // Swap-Wert übernehmen
      const currentVal = _toInput.value;
      if (currentVal) {
        _fromInput.value = currentVal;
      }
      recalc();
      saveState();
    });

    catSelect.addEventListener('change', () => {
      _currentCategory = catSelect.value;
      const defaults = getDefaultUnits(_currentCategory);
      _fromUnit = defaults.from;
      _toUnit = defaults.to;
      populateUnitSelect(fromSelect, _fromUnit);
      populateUnitSelect(toSelect, _toUnit);
      _fromInput.value = '';
      _toInput.value = '';
      updateReference();
      saveState();
    });

    bodyEl.append(catSelect, fromRow, swapBtn, toRow, _refEl);

    // Initiale Referenz anzeigen
    updateReference();
  }
})();
  • Step 2: Script-Tag in newtab.html einfügen

Nach dem calc-scientific.js Script-Tag:

  <script src="src/js/calc-converter.js"></script>
  • Step 3: CSS für Converter

In src/css/main.css, nach den Scientific-Styles:

/* Calculator Converter Mode */
.calc-conv-select {
  width: 100%;
  padding: 6px 8px;
  background: rgba(0,0,0,0.3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 13px;
  font-family: 'Rajdhani', sans-serif;
}
.calc-conv-row {
  display: flex;
  gap: 6px;
}
.calc-conv-input {
  flex: 1;
  padding: 8px;
  background: rgba(0,0,0,0.3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 16px;
  font-family: 'Rajdhani', monospace;
}
.calc-conv-input:read-only {
  color: var(--accent);
  font-weight: 600;
}
.calc-conv-unit {
  width: 80px;
  padding: 4px 6px;
  background: rgba(0,0,0,0.3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 12px;
  font-family: 'Rajdhani', sans-serif;
}
.calc-conv-swap {
  align-self: center;
  width: 36px;
  height: 28px;
  background: rgba(255,255,255,0.04);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--accent);
  font-size: 16px;
  cursor: pointer;
  transition: all 0.15s;
}
.calc-conv-swap:hover {
  background: var(--accent-dim);
}
.calc-conv-ref {
  font-size: 11px;
  color: var(--text-muted);
  padding: 4px 0;
  border-top: 1px solid var(--border);
}
  • Step 4: i18n-Keys für Converter

DE-Block:

'calculator.tab.converter':          'Umrechner',
'calculator.conv.swap':              'Einheiten tauschen',
'calculator.conv.cat.length':        'Länge',
'calculator.conv.cat.weight':        'Gewicht',
'calculator.conv.cat.temperature':   'Temperatur',
'calculator.conv.cat.volume':        'Volumen',
'calculator.conv.cat.speed':         'Geschwindigkeit',
'calculator.conv.cat.area':          'Fläche',

EN-Block:

'calculator.tab.converter':          'Converter',
'calculator.conv.swap':              'Swap units',
'calculator.conv.cat.length':        'Length',
'calculator.conv.cat.weight':        'Weight',
'calculator.conv.cat.temperature':   'Temperature',
'calculator.conv.cat.volume':        'Volume',
'calculator.conv.cat.speed':         'Speed',
'calculator.conv.cat.area':          'Area',
  • Step 5: Testen
  1. Calculator öffnen → 3 Tabs sichtbar (Std, Sci, Unit)
  2. Unit-Tab klicken → Kategorie-Dropdown, Eingabe-Feld, Ergebnis
  3. 100 cm eingeben → 39.3701 in
  4. Swap-Button → cm und in tauschen, Wert übernehmen
  5. Kategorie zu Temperatur wechseln → °C und °F als Units
  6. 100 °C → 212 °F
  7. 0 °F → -17.7778 °C
  • Step 6: Commit
git add src/js/calc-converter.js newtab.html src/css/main.css src/js/i18n.js
git commit -m "feat(calculator): Unit-Converter mit 6 Kategorien"

Task 5: Satisfactory Calculator

Files:

  • Create: src/js/calc-satisfactory.js
  • Modify: newtab.html (Script-Tag nach calc-converter.js)
  • Modify: src/js/i18n.js (neue Keys)
  • Modify: src/css/main.css (Game-Calculator shared Styles)

Kontext: 3 Sub-Modi: Items/Min, Overclock Power, Machines. Power-Exponent: 1.321928 (Update 8 Wert). Shared CSS-Klassen .calc-game-* werden hier definiert und von Factorio/Stationeers wiederverwendet.

  • Step 1: calc-satisfactory.js erstellen
/* =============================================
   HELLION NEWTAB — calc-satisfactory.js
   Satisfactory Calculator Modus
   ============================================= */

(function() {
  'use strict';

  const POWER_EXPONENT = 1.321928;

  const SUB_MODES = ['itemsPerMin', 'power', 'machines'];
  let _activeSubMode = 'itemsPerMin';

  // ---- SHARED HELPERS ----

  /**
   * Sub-Tab-Leiste erstellen
   * @param {HTMLElement} container
   * @param {Array<string>} modes
   * @param {string} activeMode
   * @param {string} i18nPrefix
   * @param {Function} onSwitch
   */
  function createSubTabs(container, modes, activeMode, i18nPrefix, onSwitch) {
    const bar = document.createElement('div');
    bar.className = 'calc-game-subtabs';

    modes.forEach(mode => {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'calc-game-subtab' + (mode === activeMode ? ' active' : '');
      btn.textContent = t(i18nPrefix + mode);
      btn.dataset.mode = mode;
      btn.addEventListener('click', () => {
        bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        onSwitch(mode);
      });
      bar.appendChild(btn);
    });

    container.appendChild(bar);
  }

  /**
   * Eingabefeld mit Label erstellen
   * @param {string} labelKey - i18n-Key
   * @param {number} defaultVal
   * @param {Object} opts - { step, min, max }
   * @returns {{ row: HTMLElement, input: HTMLInputElement }}
   */
  function createField(labelKey, defaultVal, opts) {
    opts = opts || {};
    const row = document.createElement('div');
    row.className = 'calc-game-field';

    const label = document.createElement('label');
    label.textContent = t(labelKey);

    const input = document.createElement('input');
    input.type = 'number';
    input.className = 'calc-game-input';
    input.value = defaultVal;
    if (opts.step) input.step = opts.step;
    if (opts.min !== undefined) input.min = opts.min;
    if (opts.max !== undefined) input.max = opts.max;

    row.append(label, input);
    return { row, input };
  }

  /**
   * Ergebnis-Zeile erstellen
   * @param {string} labelKey
   * @returns {{ row: HTMLElement, value: HTMLElement }}
   */
  function createOutput(labelKey) {
    const row = document.createElement('div');
    row.className = 'calc-game-output';

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

    const value = document.createElement('span');
    value.className = 'calc-game-value';

    row.append(label, value);
    return { row, value };
  }

  // ---- SUB-MODE RENDERERS ----

  function renderItemsPerMin(container) {
    const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
    const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
    const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });

    const output = createOutput('calculator.sat.output_per_min');

    function calc() {
      const items = parseFloat(itemsField.input.value) || 0;
      const time = parseFloat(timeField.input.value) || 1;
      const clock = parseFloat(clockField.input.value) || 100;
      const result = (items * 60) / time * (clock / 100);
      output.value.textContent = Calculator._formatResult(result) + ' items/min';
    }

    [itemsField, timeField, clockField].forEach(f => {
      f.input.addEventListener('input', calc);
    });

    container.append(itemsField.row, timeField.row, clockField.row, output.row);
    calc();
  }

  function renderPower(container) {
    const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
    const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });

    const powerOutput = createOutput('calculator.sat.power_usage');
    const effOutput = createOutput('calculator.sat.efficiency');

    function calc() {
      const basePower = parseFloat(basePowerField.input.value) || 0;
      const clock = parseFloat(clockField.input.value) || 100;
      const ratio = clock / 100;
      const power = basePower * Math.pow(ratio, POWER_EXPONENT);
      const effPerItem = Math.pow(ratio, POWER_EXPONENT - 1);

      powerOutput.value.textContent = Calculator._formatResult(power) + ' MW';

      if (clock > 100) {
        const overhead = (effPerItem - 1) * 100;
        effOutput.value.textContent = '+' + Calculator._formatResult(overhead) + '% ' + t('calculator.sat.per_item');
        effOutput.row.style.display = '';
      } else {
        effOutput.row.style.display = 'none';
      }
    }

    [basePowerField, clockField].forEach(f => {
      f.input.addEventListener('input', calc);
    });

    container.append(basePowerField.row, clockField.row, powerOutput.row, effOutput.row);
    calc();
  }

  function renderMachines(container) {
    const targetField = createField('calculator.sat.target_output', 60, { step: 1, min: 1 });
    const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
    const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
    const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
    const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });

    const machinesOutput = createOutput('calculator.sat.machines_needed');
    const totalPowerOutput = createOutput('calculator.sat.total_power');

    function calc() {
      const target = parseFloat(targetField.input.value) || 0;
      const items = parseFloat(itemsField.input.value) || 1;
      const time = parseFloat(timeField.input.value) || 1;
      const clock = parseFloat(clockField.input.value) || 100;
      const basePower = parseFloat(basePowerField.input.value) || 0;

      const ratio = clock / 100;
      const itemsPerMin = (items * 60) / time * ratio;
      const machines = itemsPerMin > 0 ? Math.ceil(target / itemsPerMin) : 0;
      const totalPower = machines * basePower * Math.pow(ratio, POWER_EXPONENT);

      machinesOutput.value.textContent = machines;
      totalPowerOutput.value.textContent = Calculator._formatResult(totalPower) + ' MW';
    }

    [targetField, itemsField, timeField, clockField, basePowerField].forEach(f => {
      f.input.addEventListener('input', calc);
    });

    container.append(targetField.row, itemsField.row, timeField.row, clockField.row, basePowerField.row, machinesOutput.row, totalPowerOutput.row);
    calc();
  }

  // ---- STATE ----

  async function loadState() {
    const data = await Store.get(Calculator.STORAGE_KEY);
    if (data && data.calculator && data.calculator.satisfactory) {
      const s = data.calculator.satisfactory;
      if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
    }
  }

  async function saveState() {
    const data = await Store.get(Calculator.STORAGE_KEY) || {};
    if (!data.calculator) data.calculator = {};
    data.calculator.satisfactory = { lastSubMode: _activeSubMode };
    await Store.set(Calculator.STORAGE_KEY, data);
  }

  // ---- RENDER ----

  function renderSubMode(container) {
    container.textContent = '';
    switch (_activeSubMode) {
      case 'itemsPerMin': renderItemsPerMin(container); break;
      case 'power':       renderPower(container); break;
      case 'machines':    renderMachines(container); break;
    }
  }

  // ---- REGISTRATION ----

  Calculator.registerMode('satisfactory', {
    label: '⚙️',
    shortName: 'SAT',
    titleKey: 'calculator.tab.satisfactory',

    render(bodyEl) {
      bodyEl.style.padding = '8px';
      bodyEl.style.display = 'flex';
      bodyEl.style.flexDirection = 'column';
      bodyEl.style.gap = '8px';

      loadState().then(() => {
        const subContent = document.createElement('div');
        subContent.className = 'calc-game-content';

        createSubTabs(bodyEl, SUB_MODES, _activeSubMode, 'calculator.sat.tab.', (mode) => {
          _activeSubMode = mode;
          renderSubMode(subContent);
          saveState();
        });

        bodyEl.appendChild(subContent);
        renderSubMode(subContent);
      });
    },

    destroy() {
      saveState();
    }
  });
})();
  • Step 2: Script-Tag in newtab.html

Nach dem calc-converter.js Script-Tag:

  <script src="src/js/calc-satisfactory.js"></script>
  • Step 3: Shared Game-Calculator CSS

In src/css/main.css, nach den Converter-Styles:

/* Calculator Game Modes (shared) */
.calc-game-subtabs {
  display: flex;
  gap: 4px;
  margin-bottom: 8px;
}
.calc-game-subtab {
  flex: 1;
  padding: 5px 4px;
  background: rgba(255,255,255,0.04);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-size: 10px;
  font-family: 'Rajdhani', sans-serif;
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  transition: all 0.15s;
}
.calc-game-subtab:hover {
  color: var(--text-secondary);
}
.calc-game-subtab.active {
  background: var(--accent-dim);
  border-color: var(--accent);
  color: var(--accent);
  font-weight: 600;
}
.calc-game-field {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
}
.calc-game-field label {
  font-size: 11px;
  color: var(--text-secondary);
  min-width: 90px;
  flex-shrink: 0;
}
.calc-game-input {
  flex: 1;
  padding: 5px 8px;
  background: rgba(0,0,0,0.3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 13px;
  font-family: 'Rajdhani', monospace;
}
.calc-game-output {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 6px 8px;
  background: rgba(0,0,0,0.2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  margin-top: 4px;
}
.calc-game-output span:first-child {
  font-size: 11px;
  color: var(--text-muted);
}
.calc-game-value {
  font-size: 14px;
  color: var(--accent);
  font-weight: 600;
  font-family: 'Rajdhani', monospace;
}
.calc-game-warning {
  font-size: 10px;
  color: var(--danger);
  padding: 2px 0;
}
.calc-game-content {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
  • Step 4: i18n-Keys für Satisfactory

DE-Block:

'calculator.tab.satisfactory':       'Satisfactory',
'calculator.sat.tab.itemsPerMin':    'Items/Min',
'calculator.sat.tab.power':          'Strom',
'calculator.sat.tab.machines':       'Maschinen',
'calculator.sat.items_per_craft':    'Items/Craft',
'calculator.sat.craft_time':         'Craftzeit (s)',
'calculator.sat.clock_speed':        'Taktrate (%)',
'calculator.sat.base_power':         'Grundleistung (MW)',
'calculator.sat.target_output':      'Ziel Output/Min',
'calculator.sat.output_per_min':     'Output',
'calculator.sat.power_usage':        'Stromverbrauch',
'calculator.sat.efficiency':         'Effizienz',
'calculator.sat.per_item':           'pro Item',
'calculator.sat.machines_needed':    'Maschinen benötigt',
'calculator.sat.total_power':        'Gesamtleistung',

EN-Block:

'calculator.tab.satisfactory':       'Satisfactory',
'calculator.sat.tab.itemsPerMin':    'Items/Min',
'calculator.sat.tab.power':          'Power',
'calculator.sat.tab.machines':       'Machines',
'calculator.sat.items_per_craft':    'Items/Craft',
'calculator.sat.craft_time':         'Craft Time (s)',
'calculator.sat.clock_speed':        'Clock Speed (%)',
'calculator.sat.base_power':         'Base Power (MW)',
'calculator.sat.target_output':      'Target Output/Min',
'calculator.sat.output_per_min':     'Output',
'calculator.sat.power_usage':        'Power Usage',
'calculator.sat.efficiency':         'Efficiency',
'calculator.sat.per_item':           'per item',
'calculator.sat.machines_needed':    'Machines needed',
'calculator.sat.total_power':        'Total Power',
  • Step 5: Testen
  1. SAT-Tab klicken → 3 Sub-Tabs (Items/Min, Strom, Maschinen)
  2. Items/Min: 1 Item, 4s Craft, 100% → 15 items/min
  3. Power: 30 MW Base, 250% Clock → ~115 MW (wegen Exponent 1.321928)
  4. Machines: 60 Target, 1 Item, 4s, 100%, 30 MW → 4 Maschinen, 120 MW
  • Step 6: Commit
git add src/js/calc-satisfactory.js newtab.html src/css/main.css src/js/i18n.js
git commit -m "feat(calculator): Satisfactory Calculator mit Overclock-Power"

Task 6: Factorio Calculator

Files:

  • Create: src/js/calc-factorio.js
  • Modify: newtab.html (Script-Tag nach calc-satisfactory.js)
  • Modify: src/js/i18n.js (neue Keys)

Kontext: 3 Sub-Modi: Ratio, Belt, Machines. Assembler-Speeds: Asm1=0.5, Asm2=0.75, Asm3=1.25. Belt-Throughput: Yellow=15/s, Red=30/s, Blue=45/s. Nutzt die shared .calc-game-* CSS-Klassen aus Task 5.

  • Step 1: calc-factorio.js erstellen
/* =============================================
   HELLION NEWTAB — calc-factorio.js
   Factorio Calculator Modus
   ============================================= */

(function() {
  'use strict';

  const ASSEMBLERS = [
    { key: 'asm1', speed: 0.5 },
    { key: 'asm2', speed: 0.75 },
    { key: 'asm3', speed: 1.25 }
  ];

  const BELTS = [
    { key: 'yellow', throughput: 15, perSide: 7.5 },
    { key: 'red',    throughput: 30, perSide: 15 },
    { key: 'blue',   throughput: 45, perSide: 22.5 }
  ];

  const SUB_MODES = ['ratio', 'belt', 'machines'];
  let _activeSubMode = 'ratio';

  /**
   * Assembler-Dropdown erstellen
   * @param {string} selectedKey
   * @returns {{ row: HTMLElement, select: HTMLSelectElement }}
   */
  function createAssemblerSelect(selectedKey) {
    const row = document.createElement('div');
    row.className = 'calc-game-field';

    const label = document.createElement('label');
    label.textContent = t('calculator.fac.assembler');

    const select = document.createElement('select');
    select.className = 'calc-game-input';

    ASSEMBLERS.forEach(asm => {
      const opt = document.createElement('option');
      opt.value = asm.key;
      opt.textContent = t('calculator.fac.asm.' + asm.key) + ' (' + asm.speed + 'x)';
      if (asm.key === selectedKey) opt.selected = true;
      select.appendChild(opt);
    });

    row.append(label, select);
    return { row, select };
  }

  /**
   * Belt-Dropdown erstellen
   * @param {string} selectedKey
   * @returns {{ row: HTMLElement, select: HTMLSelectElement }}
   */
  function createBeltSelect(selectedKey) {
    const row = document.createElement('div');
    row.className = 'calc-game-field';

    const label = document.createElement('label');
    label.textContent = t('calculator.fac.belt');

    const select = document.createElement('select');
    select.className = 'calc-game-input';

    BELTS.forEach(belt => {
      const opt = document.createElement('option');
      opt.value = belt.key;
      opt.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + belt.throughput + '/s)';
      if (belt.key === selectedKey) opt.selected = true;
      select.appendChild(opt);
    });

    row.append(label, select);
    return { row, select };
  }

  function getAssemblerSpeed(key) {
    const asm = ASSEMBLERS.find(a => a.key === key);
    return asm ? asm.speed : 1;
  }

  function getBelt(key) {
    return BELTS.find(b => b.key === key) || BELTS[0];
  }

  /**
   * Findet den kleinsten Belt der den Throughput schafft
   * @param {number} throughput
   * @returns {Object|null}
   */
  function findSmallestBelt(throughput) {
    for (const belt of BELTS) {
      if (belt.throughput >= throughput) return belt;
    }
    return null;
  }

  // ---- Field/Output helpers (reuse pattern from Satisfactory) ----

  function createField(labelKey, defaultVal, opts) {
    opts = opts || {};
    const row = document.createElement('div');
    row.className = 'calc-game-field';
    const label = document.createElement('label');
    label.textContent = t(labelKey);
    const input = document.createElement('input');
    input.type = 'number';
    input.className = 'calc-game-input';
    input.value = defaultVal;
    if (opts.step) input.step = opts.step;
    if (opts.min !== undefined) input.min = opts.min;
    row.append(label, input);
    return { row, input };
  }

  function createOutput(labelKey) {
    const row = document.createElement('div');
    row.className = 'calc-game-output';
    const label = document.createElement('span');
    label.textContent = t(labelKey);
    const value = document.createElement('span');
    value.className = 'calc-game-value';
    row.append(label, value);
    return { row, value };
  }

  // ---- SUB-MODE RENDERERS ----

  function renderRatio(container) {
    const asmSelect = createAssemblerSelect('asm3');
    const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
    const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });

    const perSecOutput = createOutput('calculator.fac.items_per_sec');
    const perMinOutput = createOutput('calculator.fac.items_per_min');

    function calc() {
      const speed = getAssemblerSpeed(asmSelect.select.value);
      const output = parseFloat(outputField.input.value) || 0;
      const time = parseFloat(timeField.input.value) || 1;
      const perSec = output * speed / time;
      const perMin = perSec * 60;
      perSecOutput.value.textContent = Calculator._formatResult(perSec) + ' /s';
      perMinOutput.value.textContent = Calculator._formatResult(perMin) + ' /min';
    }

    [outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
    asmSelect.select.addEventListener('change', calc);

    container.append(asmSelect.row, outputField.row, timeField.row, perSecOutput.row, perMinOutput.row);
    calc();
  }

  function renderBelt(container) {
    const beltSelect = createBeltSelect('yellow');
    const consumeField = createField('calculator.fac.consume_per_sec', 1, { step: 0.1, min: 0.1 });

    const machinesOutput = createOutput('calculator.fac.machines_per_belt');
    const utilOutput = createOutput('calculator.fac.belt_utilization');

    function calc() {
      const belt = getBelt(beltSelect.select.value);
      const consume = parseFloat(consumeField.input.value) || 1;
      const machines = Math.floor(belt.throughput / consume);
      const util = (consume * machines) / belt.throughput * 100;
      machinesOutput.value.textContent = machines;
      utilOutput.value.textContent = Calculator._formatResult(util) + '%';
    }

    consumeField.input.addEventListener('input', calc);
    beltSelect.select.addEventListener('change', calc);

    container.append(beltSelect.row, consumeField.row, machinesOutput.row, utilOutput.row);
    calc();
  }

  function renderMachines(container) {
    const asmSelect = createAssemblerSelect('asm3');
    const targetField = createField('calculator.fac.target_output_sec', 10, { step: 0.1, min: 0.1 });
    const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
    const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });

    const machinesOutput = createOutput('calculator.fac.machines_needed');
    const beltOutput = createOutput('calculator.fac.belt_needed');

    function calc() {
      const speed = getAssemblerSpeed(asmSelect.select.value);
      const target = parseFloat(targetField.input.value) || 0;
      const output = parseFloat(outputField.input.value) || 1;
      const time = parseFloat(timeField.input.value) || 1;

      const perMachine = output * speed / time;
      const machines = perMachine > 0 ? Math.ceil(target / perMachine) : 0;
      const totalThroughput = machines * perMachine;
      const belt = findSmallestBelt(totalThroughput);

      machinesOutput.value.textContent = machines;
      if (belt) {
        const util = (totalThroughput / belt.throughput) * 100;
        beltOutput.value.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + Calculator._formatResult(util) + '%)';
      } else {
        beltOutput.value.textContent = t('calculator.fac.exceeds_belt');
      }
    }

    [targetField, outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
    asmSelect.select.addEventListener('change', calc);

    container.append(asmSelect.row, targetField.row, outputField.row, timeField.row, machinesOutput.row, beltOutput.row);
    calc();
  }

  // ---- STATE ----

  async function loadState() {
    const data = await Store.get(Calculator.STORAGE_KEY);
    if (data && data.calculator && data.calculator.factorio) {
      const s = data.calculator.factorio;
      if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
    }
  }

  async function saveState() {
    const data = await Store.get(Calculator.STORAGE_KEY) || {};
    if (!data.calculator) data.calculator = {};
    data.calculator.factorio = { lastSubMode: _activeSubMode };
    await Store.set(Calculator.STORAGE_KEY, data);
  }

  function renderSubMode(container) {
    container.textContent = '';
    switch (_activeSubMode) {
      case 'ratio':    renderRatio(container); break;
      case 'belt':     renderBelt(container); break;
      case 'machines': renderMachines(container); break;
    }
  }

  // ---- REGISTRATION ----

  Calculator.registerMode('factorio', {
    label: '🏭',
    shortName: 'FAC',
    titleKey: 'calculator.tab.factorio',

    render(bodyEl) {
      bodyEl.style.padding = '8px';
      bodyEl.style.display = 'flex';
      bodyEl.style.flexDirection = 'column';
      bodyEl.style.gap = '8px';

      loadState().then(() => {
        const subContent = document.createElement('div');
        subContent.className = 'calc-game-content';

        // Sub-Tab helper aus Satisfactory ist nicht verfügbar (IIFE-Scope),
        // daher lokale Implementierung
        const bar = document.createElement('div');
        bar.className = 'calc-game-subtabs';

        SUB_MODES.forEach(mode => {
          const btn = document.createElement('button');
          btn.type = 'button';
          btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
          btn.textContent = t('calculator.fac.tab.' + mode);
          btn.dataset.mode = mode;
          btn.addEventListener('click', () => {
            bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
            btn.classList.add('active');
            _activeSubMode = mode;
            renderSubMode(subContent);
            saveState();
          });
          bar.appendChild(btn);
        });

        bodyEl.append(bar, subContent);
        renderSubMode(subContent);
      });
    },

    destroy() {
      saveState();
    }
  });
})();
  • Step 2: Script-Tag in newtab.html

Nach dem calc-satisfactory.js Script-Tag:

  <script src="src/js/calc-factorio.js"></script>
  • Step 3: i18n-Keys für Factorio

DE-Block:

'calculator.tab.factorio':           'Factorio',
'calculator.fac.tab.ratio':          'Ratio',
'calculator.fac.tab.belt':           'Belt',
'calculator.fac.tab.machines':       'Maschinen',
'calculator.fac.assembler':          'Assembler',
'calculator.fac.asm.asm1':           'Assembler 1',
'calculator.fac.asm.asm2':           'Assembler 2',
'calculator.fac.asm.asm3':           'Assembler 3',
'calculator.fac.belt':               'Belt-Typ',
'calculator.fac.belt.yellow':        'Gelb',
'calculator.fac.belt.red':           'Rot',
'calculator.fac.belt.blue':          'Blau',
'calculator.fac.recipe_output':      'Rezept-Output',
'calculator.fac.recipe_time':        'Rezeptzeit (s)',
'calculator.fac.consume_per_sec':    'Verbrauch/s',
'calculator.fac.target_output_sec':  'Ziel Output/s',
'calculator.fac.items_per_sec':      'Items/s',
'calculator.fac.items_per_min':      'Items/min',
'calculator.fac.machines_per_belt':  'Maschinen/Belt',
'calculator.fac.belt_utilization':   'Belt-Auslastung',
'calculator.fac.machines_needed':    'Maschinen benötigt',
'calculator.fac.belt_needed':        'Belt benötigt',
'calculator.fac.exceeds_belt':       'Übersteigt max. Belt',

EN-Block:

'calculator.tab.factorio':           'Factorio',
'calculator.fac.tab.ratio':          'Ratio',
'calculator.fac.tab.belt':           'Belt',
'calculator.fac.tab.machines':       'Machines',
'calculator.fac.assembler':          'Assembler',
'calculator.fac.asm.asm1':           'Assembler 1',
'calculator.fac.asm.asm2':           'Assembler 2',
'calculator.fac.asm.asm3':           'Assembler 3',
'calculator.fac.belt':               'Belt Type',
'calculator.fac.belt.yellow':        'Yellow',
'calculator.fac.belt.red':           'Red',
'calculator.fac.belt.blue':          'Blue',
'calculator.fac.recipe_output':      'Recipe Output',
'calculator.fac.recipe_time':        'Recipe Time (s)',
'calculator.fac.consume_per_sec':    'Consume/s',
'calculator.fac.target_output_sec':  'Target Output/s',
'calculator.fac.items_per_sec':      'Items/s',
'calculator.fac.items_per_min':      'Items/min',
'calculator.fac.machines_per_belt':  'Machines/Belt',
'calculator.fac.belt_utilization':   'Belt Utilization',
'calculator.fac.machines_needed':    'Machines needed',
'calculator.fac.belt_needed':        'Belt needed',
'calculator.fac.exceeds_belt':       'Exceeds max belt',
  • Step 4: Testen
  1. FAC-Tab klicken → 3 Sub-Tabs (Ratio, Belt, Maschinen)
  2. Ratio: Asm3 (1.25x), 1 Output, 1s → 1.25/s, 75/min
  3. Belt: Yellow (15/s), 1/s consume → 15 Machines, 100%
  4. Machines: Asm3, 10/s target, 1 output, 1s → 8 machines, Blue (22.2%)
  • Step 5: Commit
git add src/js/calc-factorio.js newtab.html src/js/i18n.js
git commit -m "feat(calculator): Factorio Calculator mit Assembler-Ratios"

Task 7: Stationeers Calculator

Files:

  • Create: src/js/calc-stationeers.js
  • Modify: newtab.html (Script-Tag nach calc-factorio.js)
  • Modify: src/js/i18n.js (neue Keys)
  • Modify: src/css/main.css (Stationeers-spezifische Styles)

Kontext: 4 Sub-Modi: Gas (PV=nRT), Furnace, Solar/Battery, Atmosphere. R=8314.46261815324, Combustion Energy=563452 J/mol. Komplexester Modus. Nutzt shared .calc-game-* CSS.

  • Step 1: calc-stationeers.js erstellen
/* =============================================
   HELLION NEWTAB — calc-stationeers.js
   Stationeers Calculator Modus
   ============================================= */

(function() {
  'use strict';

  // ---- CONSTANTS ----
  const R = 8314.46261815324;          // L·Pa / (mol·K)
  const COMBUSTION_ENERGY = 563452;    // J/mol bei 95% Effizienz
  const HEAT_CAP_PURE_FUEL = 61.9;    // J/(mol·K) für 1:2 O₂:H₂
  const HEAT_CAP_DELTA = 172.615;     // 0.95 × (243.6  61.9)
  const BATTERY_CAPACITY = 50000;     // Ws (Station Battery)

  const HEAT_CAPS = [
    { gas: 'O\u2082',        cp: 21.1 },
    { gas: 'H\u2082',        cp: 20.4 },
    { gas: 'CO\u2082',       cp: 28.2 },
    { gas: 'N\u2082',        cp: 20.6 },
    { gas: 'H\u2082O',       cp: 72.0 },
    { gas: 'N\u2082O',       cp: 23.0 },
    { gas: 'Pollutant', cp: 24.8 }
  ];

  const GAS_VARS = ['P', 'V', 'n', 'T'];
  const SUB_MODES = ['gas', 'furnace', 'solar', 'atmo'];
  let _activeSubMode = 'gas';

  // ---- Field/Output helpers ----

  function createField(labelKey, defaultVal, opts) {
    opts = opts || {};
    const row = document.createElement('div');
    row.className = 'calc-game-field';
    const label = document.createElement('label');
    label.textContent = t(labelKey);
    const input = document.createElement('input');
    input.type = 'number';
    input.className = 'calc-game-input';
    input.value = defaultVal;
    if (opts.step) input.step = opts.step;
    if (opts.min !== undefined) input.min = opts.min;
    if (opts.max !== undefined) input.max = opts.max;
    if (opts.disabled) input.disabled = true;
    row.append(label, input);
    return { row, input };
  }

  function createOutput(labelKey) {
    const row = document.createElement('div');
    row.className = 'calc-game-output';
    const label = document.createElement('span');
    label.textContent = t(labelKey);
    const value = document.createElement('span');
    value.className = 'calc-game-value';
    row.append(label, value);
    return { row, value };
  }

  // ---- GAS (PV=nRT) ----

  function renderGas(container) {
    // Solve-for Dropdown
    const solveRow = document.createElement('div');
    solveRow.className = 'calc-game-field';
    const solveLabel = document.createElement('label');
    solveLabel.textContent = t('calculator.sta.solve_for');
    const solveSelect = document.createElement('select');
    solveSelect.className = 'calc-game-input';

    GAS_VARS.forEach(v => {
      const opt = document.createElement('option');
      opt.value = v;
      opt.textContent = t('calculator.sta.var.' + v);
      solveSelect.appendChild(opt);
    });

    solveRow.append(solveLabel, solveSelect);
    container.appendChild(solveRow);

    // Eingabefelder für alle 4 Variablen
    const fields = {};
    const units = { P: 'kPa', V: 'L', n: 'mol', T: 'K' };
    const defaults = { P: 101.325, V: 1000, n: 1, T: 293.15 };

    GAS_VARS.forEach(v => {
      const f = createField(
        'calculator.sta.var.' + v + '_label',
        defaults[v],
        { step: 'any' }
      );
      fields[v] = f;
      container.appendChild(f.row);
    });

    // Hilfstext für Temperatur
    const tempHelper = document.createElement('div');
    tempHelper.className = 'calc-game-hint';
    container.appendChild(tempHelper);

    const resultOutput = createOutput('calculator.sta.result');
    container.appendChild(resultOutput.row);

    function calc() {
      const solveFor = solveSelect.value;

      // Felder aktivieren/deaktivieren
      GAS_VARS.forEach(v => {
        fields[v].input.disabled = (v === solveFor);
        fields[v].input.style.opacity = (v === solveFor) ? '0.5' : '1';
      });

      const P_kPa = parseFloat(fields.P.input.value) || 0;
      const P = P_kPa * 1000; // kPa → Pa
      const V = parseFloat(fields.V.input.value) || 0;
      const n = parseFloat(fields.n.input.value) || 0;
      const T = parseFloat(fields.T.input.value) || 0;

      let result = null;
      let unit = '';

      switch (solveFor) {
        case 'P':
          if (V > 0) { result = (n * R * T) / V; result /= 1000; unit = 'kPa'; }
          break;
        case 'V':
          if (P > 0) { result = (n * R * T) / P; unit = 'L'; }
          break;
        case 'n':
          if (R * T > 0) { result = (P * V) / (R * T); unit = 'mol'; }
          break;
        case 'T':
          if (n * R > 0) { result = (P * V) / (n * R); unit = 'K'; }
          break;
      }

      if (result !== null && isFinite(result)) {
        fields[solveFor].input.value = Calculator._formatResult(result);
        resultOutput.value.textContent = Calculator._formatResult(result) + ' ' + unit;
      } else {
        resultOutput.value.textContent = '-';
      }

      // Temperatur-Hilfstext
      const tempVal = parseFloat(fields.T.input.value) || 0;
      tempHelper.textContent = Calculator._formatResult(tempVal - 273.15) + ' \u00B0C';
    }

    // Events
    GAS_VARS.forEach(v => {
      fields[v].input.addEventListener('input', calc);
    });
    solveSelect.addEventListener('change', calc);
    calc();
  }

  // ---- FURNACE ----

  function renderFurnace(container) {
    const fuelField = createField('calculator.sta.fuel_ratio', 0.5, { step: 0.01, min: 0, max: 1 });
    const tempField = createField('calculator.sta.start_temp', 293.15, { step: 1, min: 0 });
    const pressField = createField('calculator.sta.start_pressure', 101.325, { step: 0.1, min: 0 });

    const tempOutput = createOutput('calculator.sta.temp_after');
    const pressOutput = createOutput('calculator.sta.pressure_after');
    const warningEl = document.createElement('div');
    warningEl.className = 'calc-game-warning';

    function calc() {
      const fuel = parseFloat(fuelField.input.value) || 0;
      const T_vor = parseFloat(tempField.input.value) || 293.15;
      const P_vor = parseFloat(pressField.input.value) || 101.325;

      warningEl.textContent = '';
      if (fuel < 0.05) {
        warningEl.textContent = t('calculator.sta.warn_low_fuel');
      }
      if (P_vor < 10) {
        warningEl.textContent += (warningEl.textContent ? ' ' : '') + t('calculator.sta.warn_low_pressure');
      }

      const specificHeat = HEAT_CAP_PURE_FUEL;
      const T_nach = (T_vor * specificHeat + fuel * COMBUSTION_ENERGY) / (specificHeat + fuel * HEAT_CAP_DELTA);
      const P_nach = P_vor * T_nach * (1 + 5.7 * fuel) / T_vor;

      tempOutput.value.textContent = Calculator._formatResult(T_nach) + ' K (' + Calculator._formatResult(T_nach - 273.15) + ' \u00B0C)';
      pressOutput.value.textContent = Calculator._formatResult(P_nach) + ' kPa';
    }

    [fuelField, tempField, pressField].forEach(f => f.input.addEventListener('input', calc));

    container.append(fuelField.row, tempField.row, pressField.row, warningEl, tempOutput.row, pressOutput.row);
    calc();
  }

  // ---- SOLAR / BATTERY ----

  function renderSolar(container) {
    const panelField = createField('calculator.sta.panels', 12, { step: 1, min: 1 });
    const wattField = createField('calculator.sta.watts_per_panel', 500, { step: 10, min: 1 });
    const dayField = createField('calculator.sta.day_length', 600, { step: 1, min: 1 });
    const nightField = createField('calculator.sta.night_length', 600, { step: 1, min: 1 });
    const consumeField = createField('calculator.sta.consumption', 2000, { step: 10, min: 0 });

    const genOutput = createOutput('calculator.sta.generation');
    const surplusOutput = createOutput('calculator.sta.surplus');
    const nightOutput = createOutput('calculator.sta.night_energy');
    const battOutput = createOutput('calculator.sta.batteries_needed');

    function calc() {
      const panels = parseFloat(panelField.input.value) || 0;
      const wpp = parseFloat(wattField.input.value) || 0;
      const nightLen = parseFloat(nightField.input.value) || 0;
      const consume = parseFloat(consumeField.input.value) || 0;

      const generation = panels * wpp;
      const surplus = generation - consume;
      const nightEnergy = consume * nightLen;
      const batteries = nightEnergy > 0 ? Math.ceil(nightEnergy / BATTERY_CAPACITY) : 0;

      genOutput.value.textContent = Calculator._formatResult(generation) + ' W';

      surplusOutput.value.textContent = Calculator._formatResult(surplus) + ' W';
      if (surplus < 0) {
        surplusOutput.value.style.color = 'var(--danger)';
      } else {
        surplusOutput.value.style.color = '';
      }

      nightOutput.value.textContent = Calculator._formatResult(nightEnergy) + ' Ws';
      battOutput.value.textContent = batteries;
    }

    [panelField, wattField, dayField, nightField, consumeField].forEach(f => f.input.addEventListener('input', calc));

    container.append(panelField.row, wattField.row, dayField.row, nightField.row, consumeField.row,
                     genOutput.row, surplusOutput.row, nightOutput.row, battOutput.row);
    calc();
  }

  // ---- ATMOSPHERE / MIXER ----

  function renderAtmo(container) {
    const targetField = createField('calculator.sta.target_temp', 293.15, { step: 1 });
    const gas1Field = createField('calculator.sta.gas1_temp', 200, { step: 1 });
    const gas2Field = createField('calculator.sta.gas2_temp', 400, { step: 1 });

    const m1Output = createOutput('calculator.sta.mixer_input1');
    const m2Output = createOutput('calculator.sta.mixer_input2');

    function calc() {
      const T0 = parseFloat(targetField.input.value) || 0;
      const T1 = parseFloat(gas1Field.input.value) || 0;
      const T2 = parseFloat(gas2Field.input.value) || 0;

      const denom = Math.abs(T1 - T0) + Math.abs(T2 - T0);
      if (denom === 0) {
        m1Output.value.textContent = '50%';
        m2Output.value.textContent = '50%';
        return;
      }

      const M1 = Math.abs(T2 - T0) / denom;
      const M2 = 1 - M1;

      m1Output.value.textContent = Calculator._formatResult(M1 * 100) + '%';
      m2Output.value.textContent = Calculator._formatResult(M2 * 100) + '%';
    }

    [targetField, gas1Field, gas2Field].forEach(f => f.input.addEventListener('input', calc));

    container.append(targetField.row, gas1Field.row, gas2Field.row, m1Output.row, m2Output.row);
    calc();

    // Aufklappbare Wärmekapazität-Referenz
    const details = document.createElement('details');
    details.className = 'calc-game-details';

    const summary = document.createElement('summary');
    summary.textContent = t('calculator.sta.heat_cap_ref');
    details.appendChild(summary);

    const table = document.createElement('table');
    table.className = 'calc-game-table';

    const thead = document.createElement('thead');
    const headerRow = document.createElement('tr');
    const thGas = document.createElement('th');
    thGas.textContent = t('calculator.sta.gas');
    const thCp = document.createElement('th');
    thCp.textContent = 'Cp (J/mol\u00B7K)';
    headerRow.append(thGas, thCp);
    thead.appendChild(headerRow);

    const tbody = document.createElement('tbody');
    HEAT_CAPS.forEach(entry => {
      const tr = document.createElement('tr');
      const tdGas = document.createElement('td');
      tdGas.textContent = entry.gas;
      const tdCp = document.createElement('td');
      tdCp.textContent = entry.cp;
      tr.append(tdGas, tdCp);
      tbody.appendChild(tr);
    });

    table.append(thead, tbody);
    details.appendChild(table);
    container.appendChild(details);
  }

  // ---- STATE ----

  async function loadState() {
    const data = await Store.get(Calculator.STORAGE_KEY);
    if (data && data.calculator && data.calculator.stationeers) {
      const s = data.calculator.stationeers;
      if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
    }
  }

  async function saveState() {
    const data = await Store.get(Calculator.STORAGE_KEY) || {};
    if (!data.calculator) data.calculator = {};
    data.calculator.stationeers = { lastSubMode: _activeSubMode };
    await Store.set(Calculator.STORAGE_KEY, data);
  }

  function renderSubMode(container) {
    container.textContent = '';
    switch (_activeSubMode) {
      case 'gas':     renderGas(container); break;
      case 'furnace': renderFurnace(container); break;
      case 'solar':   renderSolar(container); break;
      case 'atmo':    renderAtmo(container); break;
    }
  }

  // ---- REGISTRATION ----

  Calculator.registerMode('stationeers', {
    label: '🚀',
    shortName: 'STA',
    titleKey: 'calculator.tab.stationeers',

    render(bodyEl) {
      bodyEl.style.padding = '8px';
      bodyEl.style.display = 'flex';
      bodyEl.style.flexDirection = 'column';
      bodyEl.style.gap = '8px';

      loadState().then(() => {
        const subContent = document.createElement('div');
        subContent.className = 'calc-game-content';

        const bar = document.createElement('div');
        bar.className = 'calc-game-subtabs';

        SUB_MODES.forEach(mode => {
          const btn = document.createElement('button');
          btn.type = 'button';
          btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
          btn.textContent = t('calculator.sta.tab.' + mode);
          btn.dataset.mode = mode;
          btn.addEventListener('click', () => {
            bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
            btn.classList.add('active');
            _activeSubMode = mode;
            renderSubMode(subContent);
            saveState();
          });
          bar.appendChild(btn);
        });

        bodyEl.append(bar, subContent);
        renderSubMode(subContent);
      });
    },

    destroy() {
      saveState();
    }
  });
})();
  • Step 2: Script-Tag in newtab.html

Nach dem calc-factorio.js Script-Tag:

  <script src="src/js/calc-stationeers.js"></script>
  • Step 3: CSS für Stationeers-spezifische Elemente

In src/css/main.css, nach den Game-Calculator shared Styles:

/* Calculator Stationeers specifics */
.calc-game-hint {
  font-size: 10px;
  color: var(--text-muted);
  font-style: italic;
  margin-top: -4px;
  text-align: right;
}
.calc-game-details {
  border-top: 1px solid var(--border);
  padding-top: 6px;
  margin-top: 4px;
}
.calc-game-details summary {
  font-size: 10px;
  color: var(--text-muted);
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}
.calc-game-table {
  width: 100%;
  font-size: 11px;
  border-collapse: collapse;
  margin-top: 4px;
}
.calc-game-table th {
  text-align: left;
  color: var(--text-muted);
  font-weight: 600;
  padding: 2px 6px;
  border-bottom: 1px solid var(--border);
}
.calc-game-table td {
  padding: 2px 6px;
  color: var(--text-secondary);
}
.calc-game-table tr:nth-child(even) td {
  background: rgba(0,0,0,0.1);
}
  • Step 4: i18n-Keys für Stationeers

DE-Block:

'calculator.tab.stationeers':        'Stationeers',
'calculator.sta.tab.gas':            'Gas',
'calculator.sta.tab.furnace':        'Ofen',
'calculator.sta.tab.solar':          'Solar',
'calculator.sta.tab.atmo':           'Atmo',
'calculator.sta.solve_for':          'Gesucht',
'calculator.sta.var.P':              'Druck (P)',
'calculator.sta.var.V':              'Volumen (V)',
'calculator.sta.var.n':              'Stoffmenge (n)',
'calculator.sta.var.T':              'Temperatur (T)',
'calculator.sta.var.P_label':        'Druck (kPa)',
'calculator.sta.var.V_label':        'Volumen (L)',
'calculator.sta.var.n_label':        'Stoffmenge (mol)',
'calculator.sta.var.T_label':        'Temperatur (K)',
'calculator.sta.result':             'Ergebnis',
'calculator.sta.fuel_ratio':         'Fuel-Anteil (0-1)',
'calculator.sta.start_temp':         'Start-Temperatur (K)',
'calculator.sta.start_pressure':     'Start-Druck (kPa)',
'calculator.sta.temp_after':         'T nach Zündung',
'calculator.sta.pressure_after':     'P nach Zündung',
'calculator.sta.warn_low_fuel':      '⚠ Fuel unter 5%',
'calculator.sta.warn_low_pressure':  '⚠ Druck unter 10 kPa',
'calculator.sta.panels':             'Anzahl Panels',
'calculator.sta.watts_per_panel':    'Watt/Panel',
'calculator.sta.day_length':         'Taglänge (s)',
'calculator.sta.night_length':       'Nachtlänge (s)',
'calculator.sta.consumption':        'Verbrauch (W)',
'calculator.sta.generation':         'Erzeugung',
'calculator.sta.surplus':            'Überschuss',
'calculator.sta.night_energy':       'Nacht-Energie',
'calculator.sta.batteries_needed':   'Batterien benötigt',
'calculator.sta.target_temp':        'Ziel-Temperatur (K)',
'calculator.sta.gas1_temp':          'Gas 1 Temperatur (K)',
'calculator.sta.gas2_temp':          'Gas 2 Temperatur (K)',
'calculator.sta.mixer_input1':       'Mixer Input 1',
'calculator.sta.mixer_input2':       'Mixer Input 2',
'calculator.sta.heat_cap_ref':       'Wärmekapazitäten (Referenz)',
'calculator.sta.gas':                'Gas',

EN-Block:

'calculator.tab.stationeers':        'Stationeers',
'calculator.sta.tab.gas':            'Gas',
'calculator.sta.tab.furnace':        'Furnace',
'calculator.sta.tab.solar':          'Solar',
'calculator.sta.tab.atmo':           'Atmo',
'calculator.sta.solve_for':          'Solve for',
'calculator.sta.var.P':              'Pressure (P)',
'calculator.sta.var.V':              'Volume (V)',
'calculator.sta.var.n':              'Amount (n)',
'calculator.sta.var.T':              'Temperature (T)',
'calculator.sta.var.P_label':        'Pressure (kPa)',
'calculator.sta.var.V_label':        'Volume (L)',
'calculator.sta.var.n_label':        'Amount (mol)',
'calculator.sta.var.T_label':        'Temperature (K)',
'calculator.sta.result':             'Result',
'calculator.sta.fuel_ratio':         'Fuel Ratio (0-1)',
'calculator.sta.start_temp':         'Start Temperature (K)',
'calculator.sta.start_pressure':     'Start Pressure (kPa)',
'calculator.sta.temp_after':         'T after ignition',
'calculator.sta.pressure_after':     'P after ignition',
'calculator.sta.warn_low_fuel':      '⚠ Fuel below 5%',
'calculator.sta.warn_low_pressure':  '⚠ Pressure below 10 kPa',
'calculator.sta.panels':             'Panel Count',
'calculator.sta.watts_per_panel':    'Watts/Panel',
'calculator.sta.day_length':         'Day Length (s)',
'calculator.sta.night_length':       'Night Length (s)',
'calculator.sta.consumption':        'Consumption (W)',
'calculator.sta.generation':         'Generation',
'calculator.sta.surplus':            'Surplus',
'calculator.sta.night_energy':       'Night Energy',
'calculator.sta.batteries_needed':   'Batteries needed',
'calculator.sta.target_temp':        'Target Temperature (K)',
'calculator.sta.gas1_temp':          'Gas 1 Temperature (K)',
'calculator.sta.gas2_temp':          'Gas 2 Temperature (K)',
'calculator.sta.mixer_input1':       'Mixer Input 1',
'calculator.sta.mixer_input2':       'Mixer Input 2',
'calculator.sta.heat_cap_ref':       'Heat Capacities (Reference)',
'calculator.sta.gas':                'Gas',
  • Step 5: Testen
  1. STA-Tab klicken → 4 Sub-Tabs (Gas, Ofen, Solar, Atmo)
  2. Gas: P gesucht, V=1000, n=1, T=293.15 → P=2437.66 kPa (Stationeers-R ist groß!)
  3. Furnace: Fuel=0.5, T=293K, P=101kPa → hohe Temperatur nach Zündung
  4. Solar: 12 Panels, 500W, 600s Tag/Nacht, 2000W → 6000W Gen, 4000W Surplus, 24 Batteries
  5. Atmo: Target=300K, Gas1=200K, Gas2=400K → M1=50%, M2=50%
  6. Wärmekapazität-Tabelle aufklappbar
  • Step 6: Commit
git add src/js/calc-stationeers.js newtab.html src/css/main.css src/js/i18n.js
git commit -m "feat(calculator): Stationeers Calculator mit Gas/Furnace/Solar/Atmo"

Task 8: Version Bump und CHANGELOG

Files:

  • Modify: manifest.json — Version → 2.1.0

  • Modify: manifest.firefox.json — Version → 2.1.0

  • Modify: manifest.opera.json — Version → 2.1.0

  • Modify: newtab.html — Version-String → 2.1.0

  • Modify: src/js/data.js — Export-Version → 2.1.0

  • Modify: src/js/app.js — Backup-Version → 2.1.0

  • Modify: CHANGELOG.md — v2.1.0 Eintrag

  • Step 1: Manifests aktualisieren

In allen drei Manifest-Dateien:

"version": "2.1.0"
  • Step 2: newtab.html Version-String

Den bestehenden Version-String von "2.0.1" auf "2.1.0" ändern.

  • Step 3: data.js Export-Version
version: '2.1.0',
  • Step 4: app.js Backup-Version
version: '2.1.0',
  • Step 5: CHANGELOG.md Eintrag

Am Anfang der Datei (nach dem Header):

## [2.1.0] — 2026-04-16

### Added
- **Calculator Tab-System:** 6 Modi über Tab-Leiste erreichbar (Standard, Scientific, Unit, SAT, FAC, STA)
- **Scientific-Modus:** Wurzel, Potenz, Pi, Euler, Vorzeichen-Wechsel + Formel-Helfer (Kreis, Pythagoras, Prozent, Temperatur)
- **Unit-Converter:** 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit Live-Konvertierung und Swap
- **Satisfactory Calculator:** Items/Min, Overclock-Power (Exponent 1.321928), Maschinen-Rechner
- **Factorio Calculator:** Assembler-Ratios, Belt-Throughput, Maschinen-Rechner mit Belt-Empfehlung
- **Stationeers Calculator:** Idealgas (PV=nRT), Furnace/Verbrennung, Solar/Batterie-Dimensionierung, Atmosphären-Mixer

### Changed
- Parser um `^` (Potenz, rechts-assoziativ) und `sqrt()` erweitert
- Calculator-Widget Auto-Resize auf 320×480 für komplexe Modi
- ~110 neue i18n-Keys (DE + EN)
  • Step 6: Commit
git add manifest.json manifest.firefox.json manifest.opera.json newtab.html src/js/data.js src/js/app.js CHANGELOG.md
git commit -m "feat(release): Version auf v2.1.0 bumpen — Calculator Upgrade"