Merge the server concept into the README; add CLAUDE.md

The README gets a chapter on storage, accounts and live collaboration
that describes what is built (local vs server documents, flows, roles,
architecture, Yjs layer, channel protocol, data model, API, .env
configuration, security, open points). The planning document with its
outdated parts (guest accounts, y-rb, edit lock, phases) is removed.
CLAUDE.md collects commands, layout and the rules that are easy to break.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 21:00:44 +02:00
co-authored by Claude Opus 5.5
parent f22963a666
commit 0368e96e0c
3 changed files with 364 additions and 751 deletions
+280 -20
View File
@@ -26,14 +26,12 @@ Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Alle
Bearbeiten-Recht arbeiten **gleichzeitig** am selben Dokument: Änderungen
kommen über ActionCable sofort bei den anderen an (Yjs, siehe
[`app/frontend/collab/`](app/frontend/collab/)), jede Person hat ihr eigenes
Undo, und die Auswahl der anderen ist als farbiger Rahmen zu sehen. Details: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md).
Undo, und die Auswahl der anderen ist als farbiger Rahmen zu sehen. Details:
[Speichern, Konten & Zusammenarbeit](#speichern-konten--zusammenarbeit).
**Konfiguration:** Datenbank-Zugang, Server-Name, SMTP und `SECRET_KEY_BASE`
stehen in einer `.env` im Projektverzeichnis – Vorlage kopieren und ausfüllen:
`cp .env.example .env`. Die Datei wird nie committet; echte
Umgebungsvariablen haben Vorrang. Solid Queue, Cache und Cable liegen in
derselben Datenbank wie die Daten. Mails landen in der Entwicklung nicht im
Postfach, sondern unter <http://localhost:3000/letter_opener>.
stehen in einer `.env` im Projektverzeichnis: `cp .env.example .env` und
ausfüllen (siehe [Konfiguration & Betrieb](#konfiguration--betrieb)).
---
@@ -179,7 +177,7 @@ Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen
Undo dieses Feldes benutzen soll.
**Performance bei großen Tabellen**: Undo-Tracking und die (debounced)
`localStorage`-Persistenz werden **nicht** über einen `watch(doc, ...,
Speicherung werden **nicht** über einen `watch(doc, ...,
{deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den
kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand
proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung
@@ -700,8 +698,8 @@ Zeilen-/Spaltengrenzen selbst ein und wird nicht zusätzlich pixelweise
gerastert.
Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` /
`Sheet`) und werden damit in **localStorage gespeichert und in `.json`
exportiert** – Round-Trip verlustfrei, ungültige Werte werden beim Import
`Sheet`) und werden damit **mit dem Dokument gespeichert und in `.json`
exportiert** (der Zoom bleibt bei geteilten Dokumenten pro Person) – Round-Trip verlustfrei, ungültige Werte werden beim Import
verworfen bzw. geklemmt.
### Zahlenformat (Ländereinstellung)
@@ -1112,7 +1110,8 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
### Import
`<input type="file">` **und** Drag & Drop auf die Canvas; `.num` `.json` `.csv` `.xlsx`.
- **`.num`** / **`.json`** → Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
- **`.num`** / **`.json`** → *als neues Dokument* öffnen (Standard), das offene
Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
**v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
@@ -1123,14 +1122,263 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
- Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh.
Schlägt ein Import fehl, bleibt das bestehende Dokument **unverändert**.
## Persistenz
## Speichern, Konten & Zusammenarbeit
Das gesamte Dokument wird (debounced) nach `localStorage` gespiegelt, robust
gegen leeren/kaputten State — Fallback ist ein **leeres Dokument** (ein Blatt
„Blatt 1" mit einer leeren „Tabelle 1"). Ein v1-State im Storage wird beim
Laden migriert. **Datei › Neu** legt ein neues leeres Dokument an, **Datei ›
Muster · Umsatzanalyse** lädt das vorbefüllte Musterdokument (beides nach
Rückfrage, die bisherigen Änderungen gehen verloren).
Ohne Konto bleibt alles **im Browser**. Ein Konto braucht es nur, wenn man
seine Dokumente auf mehreren Geräten haben oder sie mit anderen teilen will.
Mit Konto liegen Dokumente auf dem Server und werden **live gemeinsam**
bearbeitet.
### Grundsätze
- **Anonym zuerst.** App öffnen, arbeiten, ohne Registrierung. Kein
Gast-Konto auf dem Server.
- **Konto nur bei Bedarf**, Anmeldung nur per **Magic Link** (E-Mail, dazu ein
6-stelliger Code). Keine Passwörter. Die erste Anmeldung legt das Konto an.
- **Nichts geht verloren.** Nach der ersten Anmeldung fragt die App, welche
lokalen Dokumente ins Konto hochgeladen werden sollen.
- **Links sind Zugänge.** Ein Lesen- oder Bearbeiten-Link funktioniert auch
ohne Konto und lässt sich jederzeit widerrufen.
- **Gleichzeitig statt nacheinander.** Änderungen werden per CRDT (Yjs)
zusammengeführt, ohne Sperren; jede Person hat ihr eigenes Undo.
- **Dateien bleiben.** `.num`, `.json`, CSV und XLSX bleiben als
Import/Export.
### Wo Dokumente liegen
| | Lokal | Server |
| --- | --- | --- |
| Wer | alle, auch ohne Konto | mit Konto, oder per Link |
| Wo | IndexedDB dieses Browsers | PostgreSQL |
| Adresse | `/?doc=<id>` | `/d/<uuid>` (Link: `/d/<uuid>#k=<token>`) |
| Speichern | automatisch, 250 ms nach der Änderung | live, jede Änderung sofort an alle |
| Undo | Snapshots des Dokuments | pro Person (`Y.UndoManager`) |
| Teilen | – (erst hochladen) | per Link oder Einladung |
**Datei › Dokumente …** listet alles in bis zu drei Gruppen: *Mein Konto*,
*Mit mir geteilt* (Einladungen und Links) und *In diesem Browser*. Dort lässt
sich öffnen, umbenennen, duplizieren, löschen bzw. aus der Liste entfernen und
ein lokales Dokument ins Konto hochladen. Der Dokumentname steht in der
Toolbar und ist direkt umbenennbar; er ist auch der Dateiname beim Export.
**Neues Dokument** und **Muster** legen je ein weiteres Dokument an: mit Konto
auf dem Server, sonst lokal. Ein alter Einzel-Speicherstand aus
`localStorage` wird beim ersten Start als „Mein Dokument“ übernommen.
**Mehrere Tabs:** Jeder Tab kann ein anderes Dokument offen haben. Dieselbe
lokale Datei in zwei Tabs folgt den Speicherungen des anderen Tabs
(`BroadcastChannel`); tippen beide gleichzeitig, gewinnt die letzte
Speicherung, darauf weist ein Hinweis hin. Server-Dokumente führen
gleichzeitige Änderungen immer zusammen.
### Abläufe
1. **Anonym arbeiten** – Dokumente liegen in IndexedDB. Ein anderer Browser
oder gelöschte Browserdaten bedeuten: diese Dokumente sind dort nicht da.
2. **Anmelden** – E-Mail eingeben, Mail mit Link und Code. Der Link öffnet eine
Bestätigungsseite; angemeldet wird erst per Klick (`POST`), damit
Mail-Scanner, die Links vorab aufrufen, ihn nicht verbrauchen. Alternativ
den Code in der App eingeben (Link am Handy, App am Desktop). Danach: Rückfrage
zum Hochladen lokaler Dokumente; hochgeladene werden lokal entfernt.
3. **Teilen per Link** – *Teilen* → Link „kann ansehen“ bzw. „kann bearbeiten“
erstellen und kopieren. Funktioniert ohne Konto; der Token wird im Browser
gemerkt (`localStorage`) und nach einer späteren Anmeldung dem Konto
zugeordnet. *Neu* erzeugt einen neuen Link; der alte und alle darüber
entstandenen Zugänge sind dann ungültig.
4. **Einladen** – E-Mail + Rolle. Die Einladung (7 Tage gültig) meldet beim
Öffnen gleichzeitig an und legt bei Bedarf das Konto an.
5. **Gemeinsam bearbeiten** – Änderungen kommen in ~150 ms bei den anderen an.
Die Auswahl der anderen erscheint als farbiger Rahmen mit Namen, oben
rechts die Avatare. Anonyme Personen heißen zwei zufällige Begriffe
(„Quelle Mond“). Undo nimmt nur eigene Änderungen zurück. Wer nur lesen
darf, sieht alles live, kann aber nichts ändern („Nur Lesen“).
Ein lokales Dokument teilen: *Teilen* → ohne Konto erst anmelden, dann
*Hochladen und teilen*.
### Rollen
| Aktion | Besitzer | Bearbeiten | Ansehen |
| --- | --- | --- | --- |
| Lesen, Anwesenheit der anderen sehen | ja | ja | ja |
| Bearbeiten, Umbenennen | ja | ja | nein |
| Exportieren, Duplizieren (ins eigene Konto) | ja | ja | ja |
| Links und Personen verwalten | ja | nein | nein |
| Löschen | ja | – | – |
| Aus der eigenen Liste entfernen | – | ja | ja |
Zugang entsteht durch **Besitz** (wer anlegt), **Einladung** (Mitgliedschaft
`invite`, bleibt beim Widerruf eines Links bestehen) oder **Link**
(angemeldet: Mitgliedschaft `link`; anonym: nur der Token im Browser). Ein
Link schwächt nie eine bestehende Rolle ab. Der Token steht im URL-Fragment
(`#k=…`), das Browser nicht an Server schicken; die App entfernt es nach dem
Einlösen aus der Adresszeile.
Geprüft wird zentral über `Document#role_for(user:, token:)`: im REST-API per
Controller (kein Zugang → `404`, nicht `403`), im WebSocket beim Verbinden.
Ändern sich Links oder Mitgliedschaften, prüft jede offene Verbindung ihre
Rolle sofort neu: Wer den Zugang verliert, wird getrennt; wer herabgestuft
wird, ist sofort im Nur-Lesen-Modus.
### Architektur
```
┌─ Browser (je Person) ─────────┐ ┌─ Rails 8 ──────────────────────┐
│ Vue-App (app/frontend) │ HTTPS │ REST-API /api, Login /auth │ ┌────────────┐
│ Store (DocumentState, │◄────────►│ Magic Link, Liste, Teilen │────►│ PostgreSQL │
│ positionsbasiert "c,r") │ │ │ │ Daten + │
│ ▲ ▼ collab/ydoc.ts │ WebSocket│ DocumentChannel (ActionCable) │────►│ Solid Queue│
│ Y.Doc (feste Zeilen-/ │◄────────►│ speichert + verteilt Updates │ │ Cache,Cable│
│ Spalten-IDs), Awareness │ /cable │ │ └────────────┘
│ IndexedDB (lokale Dokumente) │ │ Mailer (Solid Queue) ──► SMTP │
└───────────────────────────────┘ └────────────────────────────────┘
```
- **Rails 8** mit dem Vue-Frontend über `vite_rails`; `SpaController` liefert
die App für `/` und `/d/:id` und bettet den angemeldeten User ein
(`#numolus-boot`).
- **Solid Queue, Solid Cache, Solid Cable** liegen als normale Migrationen in
**derselben** PostgreSQL-Datenbank wie die Daten; kein Redis.
- Der Server versteht den Inhalt der Yjs-Updates nicht und muss es nicht: er
speichert und verteilt sie nur. Formeln rechnet weiterhin jeder Browser selbst.
### Live-Bearbeitung (Yjs)
Der **Store bleibt unverändert positionsbasiert** (`cells["col,row"]`), Engine
und Dateiformat ebenso. Die Schicht [`app/frontend/collab/`](app/frontend/collab/)
übersetzt:
- [`ydoc.ts`](app/frontend/collab/ydoc.ts) – Yjs-Form des Dokuments und die
Übersetzung in beide Richtungen. Jede Tabelle hat `rowOrder`/`colOrder` mit
**festen IDs**; Zellen, Formate, Breiten und Höhen hängen an diesen IDs
(`"colId|rowId"`). `Binding.sync()` schreibt nur, was sich geändert hat
(eine Zelle, eine Eigenschaft), damit Änderungen verschiedener Personen
zusammengeführt statt überschrieben werden. Zeile/Spalte einfügen oder
löschen melden die vier Store-Funktionen `insertRow`/`deleteRow`/
`insertColumn`/`deleteColumn` ausdrücklich (`onStructure`), denn aus
verschobenen Zellen allein ist das nicht erkennbar. So landet eine Eingabe
in Zeile 7 in der richtigen Zeile, auch wenn gleichzeitig jemand darüber
eine Zeile einfügt.
- [`session.ts`](app/frontend/collab/session.ts) – eine Sitzung je offenem
Server-Dokument: ActionCable-Abo, `Y.UndoManager` (nur eigene Änderungen),
Awareness (Name, Farbe, Auswahl als Zeilen-/Spalten-IDs). Reihenfolge ist
wichtig: Vor jeder fremden Änderung gehen eigene, noch nicht übertragene
Änderungen zuerst nach Yjs; danach wird der Store mit
`applyExternalState` neu aufgebaut, wobei Auswahl, aktives Blatt und Zoom
der Person erhalten bleiben (die eigene Auswahl folgt eingefügten Zeilen).
- **Erste Umwandlung** eines JSON-Dokuments in Yjs geschieht im Browser,
deterministisch (feste Client-ID, IDs `r0…`/`c0…`): Öffnen zwei Personen es
gleichzeitig, entstehen identische Updates, die Yjs zu einem verschmilzt.
- **Zusammenfassen:** Ab 200 gespeicherten Updates schickt ein Editor den
Gesamtstand; der Server ersetzt genau die aufgelisteten Updates durch ihn.
Editoren halten außerdem `documents.content` als JSON-Snapshot aktuell
(alle 30 s bei Änderung, beim Verlassen, beim Zusammenfassen) – für
Duplizieren, Export und die Liste.
- **Wiederverbinden:** ActionCable verbindet neu, der Server schickt alle
Updates, der Browser schickt, was dem Server fehlt. Anzeige „Offline“ in der
Toolbar, solange die Verbindung weg ist.
- **Zoom** ist pro Person und wird nicht geteilt; Auswahl, aktives Blatt und
aktives Objekt ebenso nicht. Die Z-Reihenfolge (Klick holt ein Objekt nach
vorn) ist Dokumentinhalt; im Nur-Lesen-Modus ändert ein Klick sie nicht.
**Nur-Lesen im Store:** Ist `store.ui.readOnly` gesetzt, sind alle
inhaltsändernden Store-Funktionen wirkungslos (Liste `READ_ONLY_GUARDED` in
[`document.ts`](app/frontend/store/document.ts)); ein Versuch zeigt den Grund als Hinweis.
**DocumentChannel** ([`document_channel.rb`](app/channels/document_channel.rb)),
Binärdaten Base64:
| Richtung | Nachricht | Inhalt |
| --- | --- | --- |
| Browser → Server | `update` | ein Yjs-Update (nur Editoren) |
| Browser → Server | `awareness` | Auswahl/Name, wird nicht gespeichert |
| Browser → Server | `compact` | Gesamtstand + Liste der ersetzten Update-IDs + JSON-Snapshot |
| Browser → Server | `snapshot` | JSON-Snapshot |
| Server → Browser | `init` | Rolle, Titel, alle Updates; solange es keine gibt: das JSON zum Umwandeln |
| Server → Browser | `update`, `awareness`, `compacted` | weitergereicht bzw. bestätigt |
| Server → Browser | `meta`, `role`, `revoked` | Titel geändert, Rolle geändert, Zugang weg |
### Datenmodell
| Tabelle | Wichtige Spalten |
| --- | --- |
| `users` | `email` (citext, eindeutig), `display_name`, `color`, `last_seen_at` |
| `sessions` | `user_id`, `user_agent`, `ip_address`, `last_active_at` – signierte Session-ID im Cookie |
| `magic_links` | `email`, `token_digest`, `code_digest` (nur SHA-256), `purpose` (login/invitation), `document_id`, `role`, `invited_by_id`, `return_to`, `failed_attempts`, `expires_at`, `used_at` |
| `documents` | `id` (UUID, in der URL), `owner_id`, `title`, `content` (JSON-Snapshot), `updated_by_id`, `content_updated_at` |
| `document_updates` | `document_id`, `user_id`, `data` (ein Yjs-Update oder ein zusammengefasster Stand) |
| `document_memberships` | `document_id`, `user_id` (eindeutig zusammen), `role` (viewer/editor), `source` (invite/link), `share_link_id` |
| `share_links` | `document_id`, `role`, `token` (Klartext, damit der Besitzer den Link erneut kopieren kann), `created_by_id`, `revoked_at` |
Der Besitzer steht nur in `documents.owner_id`. Pro Dokument gibt es höchstens
einen aktiven Link je Rolle.
### API
REST unter `/api`, JSON, Session-Cookie, CSRF-Token aus dem Meta-Tag;
Link-Zugang über den Header `X-Share-Token`.
| Methode & Pfad | Zweck |
| --- | --- |
| `POST /api/auth/magic_link` | Link + Code anfordern `{email, return_to}` – Antwort immer gleich |
| `POST /api/auth/code` | mit Code anmelden `{email, code}` |
| `DELETE /api/auth/session` | abmelden |
| `GET/PATCH /api/me` | angemeldeter User / Anzeigename |
| `GET/POST /api/documents` | eigene + geteilte Dokumente / anlegen oder hochladen `{title, content}` |
| `GET /api/documents/:id` | Metadaten, Rolle, JSON-Snapshot (`?content=0` ohne) |
| `PATCH /api/documents/:id` | umbenennen `{title}` |
| `DELETE /api/documents/:id` | Besitzer: löschen; sonst: aus der Liste entfernen |
| `POST /api/documents/:id/join` | Link-Token einlösen `{token}` |
| `GET /api/documents/:id/share` | Links, Mitglieder, offene Einladungen (Besitzer) |
| `POST/DELETE /api/documents/:id/share/links[/:id]` | Link je Rolle neu erzeugen / widerrufen |
| `POST /api/documents/:id/share/invitations` | einladen `{email, role}` |
| `PATCH/DELETE /api/documents/:id/share/members/:user_id` | Rolle ändern / entfernen |
| `GET/POST /auth/verify?token=` | Bestätigungsseite / Anmeldung aus dem Mail-Link |
### Konfiguration & Betrieb
Einstellungen stehen in einer **`.env`** im Projektverzeichnis (Gem `dotenv`;
auch `.env.production` usw.). Vorlage: [`.env.example`](.env.example) –
`cp .env.example .env` und ausfüllen. Echte Umgebungsvariablen haben Vorrang;
`.env` wird nie committet.
| Variable | Wofür |
| --- | --- |
| `NUMOLUS_DATABASE_NAME`, `_HOST`, `_PORT`, `_USER`, `_PASSWORD` | PostgreSQL (Standardname `numolus`; ohne Host/User: lokaler Socket) |
| `NUMOLUS_HOST` | Host in den Links der Mails |
| `SECRET_KEY_BASE` | Produktion (`bin/rails secret`) |
| `NUMOLUS_MAIL_FROM`, `SMTP_ADDRESS`, `SMTP_PORT`, `SMTP_USER_NAME`, `SMTP_PASSWORD` | Mailversand in Produktion |
In der Entwicklung werden Mails nicht verschickt, sondern unter
<http://localhost:3000/letter_opener> angezeigt. Produktion braucht einen
Rails-Server (Puma) mit PostgreSQL; `bin/jobs` bzw. `SOLID_QUEUE_IN_PUMA=1`
für den Mailversand.
### Sicherheit
- Magic-Link-Token und Code nur als SHA-256 gespeichert, 15 Minuten gültig,
einmal verwendbar; nach 5 falschen Codes ist der Link verbrannt.
- Rate Limits: 20 Link-Anforderungen pro IP und Stunde (dazu 5 pro E-Mail),
30 Code-Versuche pro IP in 15 Minuten.
- `return_to` nur als Pfad derselben Seite; `404` statt `403`; UUIDs.
- Cookies `HttpOnly`, `SameSite=Lax`, in Produktion `Secure`; CSRF-Schutz im
REST-API.
- Yjs-Updates max. 10 MB, JSON-Snapshots max. 20 MB und nur, wenn sie wie ein
numolus-Dokument aussehen.
### Offen
- **Formeln bei gleichzeitigem Einfügen:** Tippt jemand eine Formel, während
ein anderer eine Zeile einfügt, wird deren Bezug nicht angepasst (wie in
Google Sheets). Später denkbar: Bezüge intern über IDs.
- **Große Dokumente:** Jede fremde Änderung baut den ganzen Store neu auf;
später nur die betroffene Tabelle.
- **Makros aus fremder Hand** laufen im eigenen Browser, sobald man sie
startet; eine Bestätigung für fremde Makros fehlt noch.
- Papierkorb, Aufräumjobs (abgelaufene Magic Links, alte Sessions),
„Überall abmelden“, Konto löschen / Datenexport, Offline-Kopie von
Server-Dokumenten (`y-indexeddb`), Backups und Monitoring.
- Datenschutzerklärung (gespeichert werden E-Mail, Anzeigename, IP und
User-Agent je Session).
## Vorbefülltes Dokument (Datei › Muster)
@@ -1148,7 +1396,8 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests
`npm run test` — Vitest, 570 Tests:
`npm run test` — Vitest (Engine, Store, IO, Yjs-Abbildung);
`bin/rails test` — Minitest (Models, REST-API, Anmeldung, Teilen, DocumentChannel):
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -1513,10 +1762,21 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Projektstruktur
```
app/
models/ # User, Session, MagicLink, Document, DocumentUpdate, DocumentMembership, ShareLink
controllers/ # SpaController (liefert die App), AuthController (Mail-Link), api/* (REST)
channels/ # DocumentChannel (Live-Bearbeitung), ApplicationCable::Connection
mailers/, views/ # AuthMailer (Anmeldung, Einladung), Bestätigungsseite, SPA-Seite
config/, db/ # Rails-Konfiguration, Migrationen (inkl. Solid Queue/Cache/Cable)
test/ # Minitest: Models, API, Channel
.env.example # Vorlage für Datenbank, Host, SMTP
app/frontend/
entrypoints/ # application.ts – Einstieg (vite_rails)
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy)
store/ # Dokument-Store (v2 Canvas-Modell), Formatierung, library.ts (lokal/Server), session.ts (Konto)
collab/ # Live-Bearbeitung: ydoc.ts (Yjs-Abbildung), session.ts (ActionCable, Undo, Awareness)
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy),
# library.ts (IndexedDB), api.ts (REST-Client)
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
# TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
# StatusBar, TheLogo, …