docs: where the ribbon lives and why it is built that way

The palette outside the theme system, the token remap that lets a reused picker
adopt it, and the two things the fixed-height band forces — a menu that leaves
the flow, and a wrapper that names the menu it owns.

Also the deletion the ribbon would unlock if classic were ever retired, so the
option stays visible without anything being removed now.
This commit is contained in:
Steffen Becker committed 2026-08-12 01:55:48 +02:00
1 parent ad3f0507fd
commit 0d8f396c23
4 files changed
+101

No files matched your search

+2
View File
@@ -77,6 +77,7 @@ src/
App.svelte – app shell + app-level state
lib/
components/ – all Svelte UI (toolbars, pickers, dialogs, Editor, HeaderFooterLayer)
ribbon/ – the Word-style ribbon chrome (shell, controls, one file per tab)
editor/
extensions.ts – the TipTap extension registry
extensions/ – the custom TipTap/ProseMirror extensions, one file per feature
@@ -100,6 +101,7 @@ Directory-level `CLAUDE.md` files load automatically when you touch that directo
| ODF/DOCX import, style resolver, image formats | `src/lib/import/CLAUDE.md` |
| Paragraph + character styles, style manager | `src/lib/styles/CLAUDE.md` |
| localStorage keys, page margins/orientation, themes | `src/lib/storage/CLAUDE.md` |
| The ribbon chrome, its palette and the mode switch | `docs/architecture/ribbon.md` |
| Pagination, widow/orphan, columns flow | `docs/architecture/pagination.md` |
| Images, text boxes, wrap modes | `docs/architecture/frames.md` |
| Table extensions + table styles | `docs/architecture/tables.md` |
+94
View File
@@ -0,0 +1,94 @@
# The ribbon
A second chrome above the document, laid out like Word's ribbon. It does not replace the
floating command island — `chromeMode` picks which of the two mounts, so both can be used and
compared. Everything the ribbon drives is the same editor, through the same commands.
## The mode switch
`chromeMode: 'classic' | 'ribbon'` lives in `storage/theme.ts` beside the other UI-chrome prefs
(`odf-editor-chrome`, default `'classic'`). `App.svelte` forks on it: the ribbon docks as a plain
flex child, the island keeps its absolute overlay. Reachable from both sides — the theme dropdown
in classic, the strip's appearance menu in the ribbon.
`--toolbar-overlay-h` is written **only** in classic mode. `editor.css` reads it as
`var(--toolbar-overlay-h, 0px)`, so the ribbon's docked layout needs no second rule.
**Classic is the baseline the ribbon is measured against, so it stays untouched.** Where a shared
helper would improve both, the ribbon takes it and classic keeps its copy; where a dialog is
mounted inside `ToolbarExpanded`, the ribbon mounts its own rather than hoisting it. Only one
chrome is mounted at a time, so the two sets never coexist.
## Palette
Word's greys live in `--w-*` in `global.css`, deliberately **outside** the `[data-theme]` blocks:
the theme switcher styles the canvas, the page, the status bar and the classic toolbar, never the
ribbon. Accent `--w-accent: #185abd`; the tab's active underline is 2.5px **inset 13px from each
side**, not a full-width border.
`.ribbon` remaps the app's own tokens onto that palette (`--color-surface`, `--color-primary`,
`--toolbar-btn-size`, …). Custom properties inherit, so every reused picker — `ColorPicker`,
`HistoryButton`, `TablePicker`, `TableStylePicker`, the border pickers — adopts the ribbon's look
with no edit of its own, while the classic toolbar keeps the themed set.
Consequence, accepted: in dark and AllBlack the ribbon stays light while the rest of the app goes
dark.
## Layout
```
Ribbon.svelte
├─ .ribbon-tabs File pill · quick access (save, undo, redo) · tabs · contextual tabs
│ · spacer · document name · appearance · UI language
└─ .ribbon-body the active tab's groups, a fixed --w-ribbon-h (96px) band
```
The band's height never changes: it scrolls horizontally with a hidden scrollbar instead of
collapsing groups into popovers the way Word does when the window narrows. That is the single
biggest simplification here, and the reason two things below are necessary.
- **A menu must leave the flow.** `.ribbon-body` clips its own overflow, so the `anchored` action
(`ribbon/menu.svelte.ts`) switches a panel to `position: fixed` and pins it under its wrapper,
clamped inside the window.
- **Each wrapper names the menu it owns.** One shared open-menu id means every `clickOutside`
sees every mousedown; without the id each wrapper would close a sibling's open menu, and the
click on one of its rows would never land.
## Pieces
| File | Role |
| --- | --- |
| `Ribbon.svelte` | Shell, tab state, the File menu, which contextual tabs are shown |
| `RibbonGroup.svelte` | A group: its controls, its visible label, Word's ↘ dialog launcher |
| `RibbonButton.svelte` | `big` / `small` / `icon`, plus the split button. Hover paints the **icon box**, not the whole button |
| `RibbonMenu.svelte` | The dropdown panel; its look is global CSS, since its rows come from a caller's snippet |
| `Icon.svelte`, `icons.ts` | Path data on a 16-unit canvas. `pinnedStroke(size)` holds the painted stroke at ~1.5px on large glyphs and ~1.1px on small ones, so 14px and 28px icons read as one family |
| `menu.svelte.ts` | The one open-menu id, `anchored`, `clickOutside` |
| `selection.ts` | `saveRange` / `withRange` — a popover steals focus, so the range is replayed before the command |
| `fontList.svelte.ts` | Recent, web-safe and detected fonts, shared by every font picker |
| `controls/` | Font family box, font size box, style gallery |
| `tabs/` | One file per tab |
Reading what the selection formats is **not** here: `utils/selectionFormat.ts` answers it
framework-free (`uniformFont`, `uniformFontSize`, `uniformMarkColor`, `uniformBlockAttr`), each
returning the shared value or `''` when the selection mixes two.
## Tabs
Fixed: **File** (a menu, not a backstage) · **Home** · **Insert** · **Layout** · **References** ·
**Review** · **View**. Contextual, shown while the caret is in the object: **Table Design**,
**Table Layout**, **Picture Format**, **Shape Format** — a tab that disappears hands the strip
back to Home.
The ribbon surfaces a good deal the engine already carried with nothing to reach it: change case,
underline and strikethrough line styles, Find and Replace as buttons, the page break, section
breaks, absolute left/right indent, the table-of-contents depth, cell margins, Save As.
## What retiring classic would free
Recorded so the option stays visible, not as a plan: `Toolbar.svelte` and
`ToolbarExpanded.svelte` (~3200 lines), the overlay island and its hand-rolled horizontal
scrollbar in `App.svelte` (~120 lines plus CSS), the `pointer-events` opt-ins,
`--toolbar-overlay-h`, the collapsed-toolbar re-fire shim, `loadToolbarExpanded` /
`saveToolbarExpanded`, the duplicate dialog mounts, and `ToolbarExpanded`'s own copies of the
selection readers.
+4
View File
@@ -3,6 +3,10 @@
All Svelte UI. `Editor.svelte` mounts TipTap and owns pagination wiring, zoom, the floating
toolbars and the header/footer layer.
Two chromes sit above the document and `chromeMode` (`storage/theme.ts`) picks which mounts:
the floating command island below, or `ribbon/` — see `docs/architecture/ribbon.md`. Both drive
the same editor through `activeEditor`/`activeTick`, and only one is mounted at a time.
## Data flow
```
+1
View File
@@ -19,6 +19,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
- **Document:** `odf-editor-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `odf-editor-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `odf-editor-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it.
- **Theme:** `odf-editor-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `odf-editor-toolbar-expanded` — boolean string.
- **Chrome:** `odf-editor-chrome` — `'classic' | 'ribbon'`; absent = classic.
- **Formatting marks:** `odf-editor-formatting-marks` — boolean string.
- **Ruler:** `odf-editor-ruler` — boolean string; absent = on.
- **Zoom:** `odf-editor-zoom` — integer percent.