diff --git a/docs/architecture/ribbon.md b/docs/architecture/ribbon.md
index 360fcf4..fb6fc37 100644
--- a/docs/architecture/ribbon.md
+++ b/docs/architecture/ribbon.md
@@ -49,7 +49,11 @@ 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.
+ 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 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 `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.
@@ -63,10 +67,11 @@ biggest simplification here, and the reason two things below are necessary.
| `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` |
+| `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 34px 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
diff --git a/src/lib/components/ColorPicker.svelte b/src/lib/components/ColorPicker.svelte
index a526047..54000f4 100644
--- a/src/lib/components/ColorPicker.svelte
+++ b/src/lib/components/ColorPicker.svelte
@@ -86,7 +86,10 @@
async function placePanel() {
const anchor = split?.getBoundingClientRect();
if (!anchor) return;
- pos = { top: anchor.bottom + 3, left: anchor.left };
+ // A ribbon panel drops below the whole band, or it would cover the controls
+ // under it. The classic toolbar has no band, so this finds nothing there.
+ const band = split?.closest('.ribbon-body')?.getBoundingClientRect();
+ pos = { top: Math.max(anchor.bottom, band?.bottom ?? 0) + 3, left: anchor.left };
await tick();
const box = panel?.getBoundingClientRect();
if (!box) return;
diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte
index 5a7643e..d7ee389 100644
--- a/src/lib/components/ribbon/Ribbon.svelte
+++ b/src/lib/components/ribbon/Ribbon.svelte
@@ -15,7 +15,7 @@
import UiLanguagePicker from '../UiLanguagePicker.svelte';
import ParagraphDialog from '../ParagraphDialog.svelte';
import TabsDialog from '../TabsDialog.svelte';
- import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte';
+ import { clickOutside, isMenuOpen, pinPanels, toggleMenu, closeMenu } from './menu.svelte';
import { t } from '../../i18n/i18n.svelte';
import { withShortcut } from '../../i18n/shortcut';
import { shortcutHint } from '../../editor/shortcuts';
@@ -259,7 +259,7 @@
-
+
{#if tab === 'home'}
(paragraphDialogOpen = true)} />
{:else if tab === 'insert'}
diff --git a/src/lib/components/ribbon/menu.svelte.ts b/src/lib/components/ribbon/menu.svelte.ts
index 5780980..d3526a1 100644
--- a/src/lib/components/ribbon/menu.svelte.ts
+++ b/src/lib/components/ribbon/menu.svelte.ts
@@ -23,6 +23,13 @@ export function closeMenu(): void {
current = null;
}
+// A panel drops below the whole band, not just below its own button: opened
+// inside the band it would cover the controls and group labels underneath.
+function dropTop(anchor: DOMRect, panel: HTMLElement): number {
+ const band = panel.closest('.ribbon-body')?.getBoundingClientRect();
+ return Math.max(anchor.bottom, band?.bottom ?? 0) + 3;
+}
+
// The ribbon band clips its own overflow, so a panel inside it has to leave the
// flow: this pins the menu under its wrapper in viewport coordinates and keeps it
// inside the window. Attach to the panel; its parent is the anchor.
@@ -32,7 +39,7 @@ export function anchored(node: HTMLElement, align: 'left' | 'right' = 'left') {
if (!anchor) return;
const r = anchor.getBoundingClientRect();
node.style.position = 'fixed';
- node.style.top = `${r.bottom + 3}px`;
+ node.style.top = `${dropTop(r, node)}px`;
if (align === 'right') {
node.style.left = 'auto';
node.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
@@ -52,6 +59,46 @@ export function anchored(node: HTMLElement, align: 'left' | 'right' = 'left') {
};
}
+// A reused picker positions its panel against its own trigger, so the band clips
+// it to a sliver. Attach to the band: whatever panel opens inside gets the same
+// pinning `anchored` gives the ribbon's own menus, with no prop threading.
+export function pinPanels(node: HTMLElement) {
+ let panel: HTMLElement | null = null;
+
+ function place() {
+ const anchor = panel?.isConnected ? panel.parentElement : null;
+ if (!panel || !anchor) return void (panel = null);
+ const r = anchor.getBoundingClientRect();
+ panel.style.position = 'fixed';
+ panel.style.right = 'auto';
+ panel.style.top = `${dropTop(r, panel)}px`;
+ panel.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - panel.offsetWidth - 8))}px`;
+ }
+
+ // Only what a picker just mounted, so a permanently absolute element in the
+ // band (the hidden file input) is never mistaken for a panel.
+ const observer = new MutationObserver((records) => {
+ for (const record of records) {
+ for (const added of record.addedNodes) {
+ if (!(added instanceof HTMLElement)) continue;
+ if (getComputedStyle(added).position !== 'absolute') continue;
+ panel = added;
+ return place();
+ }
+ }
+ });
+ observer.observe(node, { childList: true, subtree: true });
+ window.addEventListener('resize', place);
+ window.addEventListener('scroll', place, true);
+ return {
+ destroy() {
+ observer.disconnect();
+ window.removeEventListener('resize', place);
+ window.removeEventListener('scroll', place, true);
+ },
+ };
+}
+
// Closes the menu when a mousedown lands outside `node`. Attach to the element
// wrapping a trigger and its panel, and name the menu it owns: every wrapper sees
// every mousedown, so without the id each would close a sibling's open menu — and
diff --git a/src/lib/components/ribbon/tabs/FrameTabs.svelte b/src/lib/components/ribbon/tabs/FrameTabs.svelte
index ebab11a..31c35cf 100644
--- a/src/lib/components/ribbon/tabs/FrameTabs.svelte
+++ b/src/lib/components/ribbon/tabs/FrameTabs.svelte
@@ -118,24 +118,15 @@
diff --git a/src/lib/components/ribbon/tabs/TableTabs.svelte b/src/lib/components/ribbon/tabs/TableTabs.svelte
index 6be7485..b44c400 100644
--- a/src/lib/components/ribbon/tabs/TableTabs.svelte
+++ b/src/lib/components/ribbon/tabs/TableTabs.svelte
@@ -270,26 +270,11 @@
.margin-field input:focus { outline: none; border-color: var(--w-accent); }
.shade-glyph {
- width: 14px;
- height: 14px;
+ width: 24px;
+ height: 24px;
border: 1px solid currentColor;
border-radius: 2px;
background: repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 3px);
}
- .rb-captioned {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 4px;
- padding: 4px 7px 6px;
- }
-
- .rb-caption {
- font-family: var(--w-font);
- font-size: 12px;
- color: var(--w-text);
- white-space: nowrap;
- }
diff --git a/src/styles/global.css b/src/styles/global.css
index e0f2a79..850359d 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -387,6 +387,63 @@
margin: 0 4px;
}
+/* The reused pickers style their trigger for the classic toolbar's tinted island:
+ a frame on hover, the accent as the open state. Beside a row of ribbon buttons
+ that reads as a different control, so both become the ribbon's own tints. */
+.ribbon .table-trigger:hover,
+.ribbon .char-trigger:hover,
+.ribbon .dt-trigger:hover,
+.ribbon .tsp-trigger:hover,
+.ribbon .bp-trigger:hover { border-color: transparent; }
+
+.ribbon .table-trigger[aria-pressed='true'],
+.ribbon .char-trigger[aria-pressed='true'],
+.ribbon .dt-trigger[aria-pressed='true'],
+.ribbon .tsp-trigger[aria-pressed='true'],
+.ribbon .bp-trigger[aria-pressed='true'] {
+ background: var(--w-active);
+ color: var(--w-text);
+}
+
+/* A picker standing alone in a group is a labelled button, so its own compact
+ trigger grows to the big variant's box and icon size. The stroke is pinned to
+ the same 1.5px the ribbon's own icons paint at 28px. */
+.rb-captioned {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 4px 7px 6px;
+ --toolbar-btn-size: 34px;
+}
+
+/* The first svg is the glyph; a second one is the trigger's own caret and keeps
+ the size every other caret in the ribbon has. */
+.rb-captioned > * > button > svg:first-of-type { width: 28px; height: 28px; }
+.rb-captioned > * > button > svg:first-of-type :is(path, rect, line, circle, polyline) { stroke-width: 0.86; }
+.rb-captioned .color-bar { width: 24px; }
+
+/* The spell-check picker is chromeless for the status bar; in a group it needs
+ the combo frame and the same box as the labelled buttons beside it. */
+.rb-captioned .lang-picker {
+ height: 34px;
+ padding: 0 6px;
+ border: 1px solid var(--w-border-strong);
+ border-radius: 3px;
+ background: var(--w-surface);
+}
+
+.rb-captioned .lang-picker:hover,
+.rb-captioned .lang-picker:focus-within { border-color: var(--w-accent); }
+
+.rb-caption {
+ font-family: var(--w-font);
+ font-size: 12px;
+ color: var(--w-text);
+ white-space: nowrap;
+}
+
/* Combo box: a borderless input welded to a caret button, as in Word's font and
size fields. */
.rb-combo-wrap { position: relative; }