diff --git a/README.md b/README.md
index 5587915..7dba08c 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash
npm install
npm run dev # Vite-Devserver
-npm run test # Vitest-Suite (Engine + Store + IO), 102 Tests
+npm run test # Vitest-Suite (Engine + Store + IO), 105 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
@@ -66,6 +66,23 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
- Pfeiltasten / Enter kollabieren wieder auf eine Zelle. Die aktive Zelle
(Formelleiste) ist stets die zuletzt angeklickte.
+**Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle:
+
+| Taste | Verhalten |
+|---|---|
+| Pfeiltasten | eine Zelle; **Shift+Pfeil** erweitert die Auswahl |
+| **`Strg`/`Cmd`+Pfeil** | zum Rand des Datenblocks springen (leer↔gefüllt) |
+| **Tab / Shift+Tab** | rechts / links |
+| **Enter / Shift+Enter** | runter / hoch — nach einer Tab-Folge **zurück zur Startspalte + eine Zeile** |
+| bei aktivem **Bereich** | Tab/Enter **rotieren den Cursor im Bereich** (mit Umbruch), der Bereich bleibt markiert |
+| Tippen / `F2` | Zelle editieren; Enter/Tab committen und ziehen weiter (auch im Bereich) |
+| `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle |
+| `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) |
+
+Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation
+automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
+Zeilen/Spalten).
+
### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
@@ -268,7 +285,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests
-`npm run test` — Vitest, 102 Tests:
+`npm run test` — Vitest, 105 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -337,7 +354,9 @@ src/
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy)
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
- # TableGrid, ChartView, TextBox, SheetTabs, StatusBar, …
+ # TableGrid, ChartView, TextBox, SheetTabs, StatusBar, TheLogo, …
+ numolus-logo.svg # Marke: drei überlappende Pop-Art-Quadrate + Schriftzug
+ config/brand.ts # Herausgeber-Metadaten (wicom.li) + Social-Media-Liste
composables/ # Theme, Toasts
data/seed.ts # vorbefülltes Dokument
styles/main.css # Design-Tokens (Light/Dark)
diff --git a/index.html b/index.html
index 8ee4156..772ed01 100644
--- a/index.html
+++ b/index.html
@@ -4,11 +4,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
Numolus · Tabellenkalkulation
+ Numolus · Tabellenkalkulation — wicom
diff --git a/numolus-logo.svg b/numolus-logo.svg
new file mode 100644
index 0000000..be7c56c
--- /dev/null
+++ b/numolus-logo.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ numolus
+
+
+
diff --git a/package.json b/package.json
index 9edc7f2..f5c5441 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "module",
"description": "Browser-Tabellenkalkulation nach dem Vorbild von Apple Numbers – mit selbst geschriebener Formel-Engine.",
"scripts": {
- "dev": "vite",
+ "dev": "vite --port 3000",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
diff --git a/src/components/AppToolbar.vue b/src/components/AppToolbar.vue
index 1c6ccef..6e73e51 100644
--- a/src/components/AppToolbar.vue
+++ b/src/components/AppToolbar.vue
@@ -4,6 +4,8 @@ import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
+import TheLogo from './TheLogo.vue';
+import { WICOM, WICOM_SOCIAL } from '../config/brand';
import {
exportJson,
exportActiveTableCsv,
@@ -24,9 +26,12 @@ const { mode: themeMode, cycle: cycleTheme } = useTheme();
const toasts = useToasts();
const fileMenuOpen = ref(false);
+const aboutOpen = ref(false);
const busy = ref(false);
const fileInput = ref(null);
+const addr = `${WICOM.address.street}, ${WICOM.address.city}, ${WICOM.address.country}`;
+
const themeLabel = computed(
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
);
@@ -187,6 +192,13 @@ function onReset() {
+
+
+
+
+ Numolus — browserbasierte Tabellenkalkulation nach dem Vorbild von
+ Apple Numbers, mit selbst geschriebener Formel-Engine. Case- und Designstudie.
+
+
+
+ Herausgeber
+
+ {{ WICOM.name }}
+ — {{ WICOM.descriptor }}
+ „{{ WICOM.tagline }}" · seit {{ WICOM.since }}
+
+
+ Anschrift
+ {{ addr }}
+
+ Kontakt
+
+ {{ WICOM.email }}
+ {{ WICOM.phone }}
+
+
+ Autor
+ {{ WICOM.author }}
+
+ Social Media
+
+
+ {{ s.label }}
+
+ wicom.li führt keine Social-Media-Profile.
+
+
+
+
+ wicom.li ↗
+ Schließen
+
+
diff --git a/src/config/brand.ts b/src/config/brand.ts
new file mode 100644
index 0000000..77d031e
--- /dev/null
+++ b/src/config/brand.ts
@@ -0,0 +1,37 @@
+/**
+ * Publisher / company metadata.
+ * Source: https://wicom.li (fetched 2026-08-29).
+ */
+export const WICOM = {
+ name: 'wicom',
+ legalName: 'wicom – Wild Karl-Heinz',
+ descriptor: 'Webentwicklung & digitale Lösungen',
+ tagline: 'Unsere Arbeiten – Ihr Erfolg',
+ since: 1997,
+ url: 'https://wicom.li',
+ email: 'office@wicom.li',
+ phone: '+41 79 446 2901',
+ phoneHref: 'tel:+41794462901',
+ address: {
+ street: 'Birkenweg 6',
+ city: 'FL-9490 Vaduz',
+ country: 'Liechtenstein',
+ },
+ author: 'Karl-Heinz Wild',
+} as const;
+
+/** One-line description of this app, used for ` ` / OG. */
+export const APP_DESCRIPTION =
+ 'Numolus — browserbasierte Tabellenkalkulation nach dem Vorbild von Apple Numbers, ' +
+ 'mit selbst geschriebener Formel-Engine. Eine Case- und Designstudie von wicom (Vaduz, FL).';
+
+/**
+ * Social-media profiles of wicom.
+ * wicom.li lists none — add `{ label, url }` entries here and they appear in the
+ * header and the "Über"-Dialog automatically.
+ */
+export const WICOM_SOCIAL: ReadonlyArray<{ label: string; url: string }> = [
+ // { label: 'LinkedIn', url: 'https://www.linkedin.com/company/…' },
+ // { label: 'GitHub', url: 'https://github.com/…' },
+ // { label: 'X', url: 'https://x.com/…' },
+];
diff --git a/src/numolus-logo.svg b/src/numolus-logo.svg
new file mode 100644
index 0000000..dcaf0a4
--- /dev/null
+++ b/src/numolus-logo.svg
@@ -0,0 +1,11 @@
+
+ numolus
+ Drei am oberen Rand ausgerichtete, leicht überlappende Quadrate in kräftigen Pop-Art-Farben mit schwarzer Umrandung, darunter der Schriftzug numolus.
+
+
+
+
+
+ numolus
+
diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts
index eedd922..02e8fb3 100644
--- a/src/store/__tests__/document.spec.ts
+++ b/src/store/__tests__/document.spec.ts
@@ -515,6 +515,149 @@ describe('per-object background & text colour', () => {
});
});
+describe('full-fidelity JSON export — backgrounds, formats, sheet settings', () => {
+ /** Populate *every* persisted setting, then verify the export string carries
+ * it and a round-trip is byte-for-byte identical. */
+ function loadEverything(s: DocumentStore) {
+ const analyse = s.doc.sheets[0].id;
+ const blatt2 = s.doc.sheets[1].id;
+ const umsatzId = tableId(s, 'Umsatz');
+ const chartId = s.doc.sheets[0].objects.find((o) => o.kind === 'chart')!.id;
+
+ // document
+ s.setDocumentLocale('en-GB');
+ s.setCsvDelimiter(';');
+
+ // sheet ("Arbeitsblatt") settings — on both sheets
+ s.setSheetLocale(analyse, 'de-CH');
+ s.setSheetBackground(analyse, '#eef2ff');
+ s.setSheetForeground(analyse, '#101828');
+ s.setSheetShowGrid(analyse, false);
+ s.setSheetZoom(analyse, 1.25);
+ s.renameSheet(blatt2, 'Notizen');
+ s.setSheetBackground(blatt2, '#fff7ed');
+
+ // table object: colours + geometry + view + per-row/col + cell formats
+ s.setObjectBg(umsatzId, '#0b1220');
+ s.setObjectTextColor(umsatzId, '#f8fafc');
+ s.setObjectLocale(umsatzId, 'fr-FR');
+ s.setTableView(umsatzId, { mode: 'fixed', width: 520, height: 300, frozenRows: 1, frozenCols: 1 });
+ s.setTableRowHeight(umsatzId, 30);
+ s.setRowHeight(umsatzId, 4, 52);
+ s.setColWidth(umsatzId, 0, 130);
+ s.setRangeFormat(umsatzId, { col: 1, row: 1 }, { col: 3, row: 4 }, {
+ align: 'right',
+ valign: 'middle',
+ numFmt: 'pct2',
+ color: '#dc2626',
+ bg: '#fee2e2',
+ bold: true,
+ italic: true,
+ });
+
+ // chart object
+ s.setObjectBg(chartId, '#111827');
+ s.setObjectTextColor(chartId, '#e5e7eb');
+ s.setChartView(chartId, 'line');
+ s.resizeChart(chartId, 600, 360);
+
+ // text object with full formatting
+ const txtId = s.addText(analyse);
+ s.setTextContent(txtId, 'Bericht\nZeile 2');
+ s.setTextFormat(txtId, { fontSize: 20, bold: true, italic: true, align: 'center' });
+ s.setObjectTextColor(txtId, '#334155');
+ s.setObjectBg(txtId, '#f1f5f9');
+
+ // multi-range selection
+ s.setSelection({ tableId: umsatzId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
+ s.addToSelection(umsatzId, 2, 2);
+ s.addToSelection(umsatzId, 4, 5);
+
+ return { analyse, umsatzId, chartId, txtId };
+ }
+
+ it('the exported JSON text literally contains every setting', () => {
+ const s = fresh();
+ loadEverything(s);
+ const json = serializeDocument(s.snapshot());
+ const doc = JSON.parse(json);
+
+ // document
+ expect(doc.locale).toBe('en-GB');
+ expect(doc.csvDelimiter).toBe(';');
+
+ // sheet settings
+ const a = doc.sheets[0];
+ expect(a).toMatchObject({
+ locale: 'de-CH',
+ background: '#eef2ff',
+ foreground: '#101828',
+ showGrid: false,
+ zoom: 1.25,
+ });
+ expect(doc.sheets[1]).toMatchObject({ name: 'Notizen', background: '#fff7ed' });
+
+ // table object
+ const tbl = a.objects.find((o: { kind: string }) => o.kind === 'table');
+ expect(tbl).toMatchObject({
+ bg: '#0b1220',
+ fg: '#f8fafc',
+ locale: 'fr-FR',
+ rowHeight: 30,
+ rowHeights: { '4': 52 },
+ colWidths: { '0': 130 },
+ view: { mode: 'fixed', width: 520, height: 300, frozenRows: 1, frozenCols: 1 },
+ });
+ expect(tbl.formats['1,1']).toEqual({
+ align: 'right',
+ valign: 'middle',
+ numFmt: 'pct2',
+ color: '#dc2626',
+ bg: '#fee2e2',
+ bold: true,
+ italic: true,
+ });
+
+ // chart object
+ const cht = a.objects.find((o: { kind: string }) => o.kind === 'chart');
+ expect(cht).toMatchObject({ bg: '#111827', fg: '#e5e7eb', view: 'line', width: 600, height: 360 });
+
+ // text object
+ const txt = a.objects.find((o: { kind: string }) => o.kind === 'text');
+ expect(txt).toMatchObject({
+ text: 'Bericht\nZeile 2',
+ fontSize: 20,
+ bold: true,
+ italic: true,
+ align: 'center',
+ color: '#334155',
+ bg: '#f1f5f9',
+ });
+
+ // multi-range selection
+ expect(doc.selection.ranges).toHaveLength(2);
+ });
+
+ it('export -> import is lossless for the fully-loaded document', () => {
+ const s = fresh();
+ loadEverything(s);
+ const before = s.snapshot();
+ s.replaceDocument(parseDocument(serializeDocument(before)));
+ expect(s.snapshot()).toEqual(before);
+ });
+
+ it('resetToSeed then re-import the exported file restores the exact state', () => {
+ const s = fresh();
+ loadEverything(s);
+ const exported = serializeDocument(s.snapshot());
+ const before = s.snapshot();
+ s.resetToSeed();
+ expect(s.snapshot()).not.toEqual(before);
+ s.replaceDocument(parseDocument(exported));
+ expect(s.snapshot()).toEqual(before);
+ });
+});
+
describe('v1 -> v2 migration', () => {
it('turns each old sheet into a canvas sheet holding one table of the same name', async () => {
const { migrateToV2 } = await import('../../io/json');
diff --git a/src/styles/main.css b/src/styles/main.css
index f89ca87..0eb6c20 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -18,6 +18,7 @@
--accent-weak: rgba(10, 132, 255, 0.14);
--accent-header: rgba(10, 132, 255, 0.22);
--danger: #ff3b30;
+ --logo-keyline: #111111;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 6px 20px rgba(0, 0, 0, 0.06);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
--radius: 12px;
@@ -44,6 +45,7 @@
--accent: #0a84ff;
--accent-weak: rgba(10, 132, 255, 0.22);
--accent-header: rgba(10, 132, 255, 0.32);
+ --logo-keyline: #f2f2f4;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
}
@@ -61,6 +63,7 @@
--accent: #0a84ff;
--accent-weak: rgba(10, 132, 255, 0.22);
--accent-header: rgba(10, 132, 255, 0.32);
+ --logo-keyline: #f2f2f4;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
}