The three dialogs placed themselves, so the ribbon's viewport coordinates met CSS that hangs the box above its anchor: the formula popover landed under the band, clipped. Each caller now owns the placement — `anchored` in the ribbon, like every other menu there, and a wrapper in the editor that clamps a tall one into the scroll area instead of letting the overflow cut it.
8.1 KiB
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
(edentext-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 — its own token set, not the app's, so the two
chromes can look nothing alike while sharing a theme. 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.
Dark flips only the colours — every metric holds, so it is the same ribbon. The values are
Word's dark mode: Fluent's dark neutrals, chrome #1f1f1f on surface #292929, and the accent
lightened to #479ef5 because #185abd on a dark chrome is unreadable. AllBlack goes on to
Word's Black theme (#0f0f0f), since the app's AllBlack canvas is darker than its dark one.
That lightened accent cannot also carry white text, so a second pair exists for the one place
the accent is a surface rather than a line: --w-accent-fill / -fill-open, on the File pill and
on --color-primary for the pickers. In light mode both equal --w-accent / --w-accent-dark,
so the split is invisible there.
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 (84px) 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.
84px is the tallest control in any tab — Table Layout's two-line Insert above, 65px — plus the
group footer and the band's padding, so no tab carries dead space above its buttons. Every metric
feeding that sum is pinned in px, line-heights included: a ratio hands the band's height to
whatever font resolves, and the group footers then hang out of the band.
A chevron in the tab strip drops the band entirely, leaving the strip (edentext-ribbon-collapsed).
Clicking any tab brings it back — Word instead floats it over the document until the next click
lands in the text, which is the part not built.
Word puts that chevron in the band's bottom-right corner, and this one did too. Both ways of reserving that corner failed: as a layer it covered whichever group's dialog launcher reached the edge, and since the band scrolls, which one that is varies; in the flow it cost the band a column at every height. A flex row cannot reserve width in part of its height, so it moved to the strip.
- A menu must leave the flow.
.ribbon-bodyclips its own overflow, so theanchoredaction (ribbon/menu.svelte.ts) switches a panel toposition: fixedand pins it under its wrapper, clamped inside the window. Panels drop below the whole band, not below their own button — inside it they would cover the controls and group labels underneath. A panel has to be out of the flow (position: absolute) before it mounts: in it, it stretches the wrapperanchoredmeasures and lands its own height too low. - A reused picker anchors its panel itself, and no prop says when it opened.
pinPanels, one action on the band, watches for a mounted absolutely-positioned node and pins that; the ColorPicker places its own and only needs the band-aware top. - Each wrapper names the menu it owns. One shared open-menu id means every
clickOutsidesees 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, pinPanels, 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 |
.rb-captioned (global.css) |
Wraps a reused picker standing alone in a group: its trigger grows to the big button's 32px box and 28px glyph, so a group of pickers and a group of buttons are the same size |
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.
Dialogs
ParagraphDialog and TabsDialog open from Word's ↘ launcher in a group's corner. They are
chrome-agnostic, so classic could gain launchers of its own at no cost.
There is no Font dialog: the Home tab already carries change case and the underline and
strikethrough line styles, and the two things left over — letterSpacingPt and kerning — live
on a named style, not on a run. Setting them as direct formatting would mean a new mark attribute
and export work on both formats, which is more than a chrome rebuild. The style manager is where
they belong.
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.