style: introduce island toolbar

This commit is contained in:
Steffen Becker committed 2026-07-04 23:19:01 +02:00
1 parent d018092cce
commit b20ea051ad
4 files changed
+173 -67

No files matched your search

+131 -60
View File
@@ -166,14 +166,23 @@
const MIN_THUMB = 24;
let toolbarClipEl: HTMLDivElement | null = $state(null);
let toolbarStackEl: HTMLDivElement | null = $state(null);
// The track lives inside the island (header / expanded row) so its dropdowns can
// paint above it, and is counter-translated so it stays viewport-fixed while the
// stack scrolls. Its width spans the visible island minus an edge inset.
const STACK_PAD_PX = 13.6; // 0.85rem — keep in sync with .toolbar-stack padding
const TRACK_EDGE = 20; // track inset from the island's side edges
let toolbarStackWidth = $state(0); // full content width of the stack
let toolbarViewWidth = $state(0); // visible (clipped) width
let tbScroll = $state(0); // current left offset in px
let tbOverflow = $derived(Math.max(0, toolbarStackWidth - toolbarViewWidth));
let tbTrackW = $derived(Math.max(0, toolbarViewWidth - 2 * (STACK_PAD_PX + TRACK_EDGE)));
// Thumb: proportional, but capped short (25% of the track) so it reads clearly.
let thumbWidth = $derived(
toolbarStackWidth > 0 ? Math.max(MIN_THUMB, (toolbarViewWidth / toolbarStackWidth) * toolbarViewWidth) : 0,
toolbarStackWidth > 0
? Math.max(MIN_THUMB, Math.min(0.25 * tbTrackW, (toolbarViewWidth / toolbarStackWidth) * tbTrackW))
: 0,
);
let thumbTravel = $derived(Math.max(0, toolbarViewWidth - thumbWidth));
let thumbTravel = $derived(Math.max(0, tbTrackW - thumbWidth));
let thumbLeft = $derived(tbOverflow > 0 ? (tbScroll / tbOverflow) * thumbTravel : 0);
// Track the stack's content width and the visible width; clamp the offset into
@@ -209,6 +218,10 @@
function onScrollbarPointerDown(e: PointerEvent) {
if (thumbTravel <= 0) return;
const track = e.currentTarget as HTMLElement;
// The track overlays the island: suppress native text selection under the
// drag, and re-focus manually since preventDefault also blocks click-focus.
e.preventDefault();
track.focus();
const thumb = track.querySelector('.toolbar-scrollbar-thumb');
if (!thumb?.contains(e.target as Node)) {
const rect = track.getBoundingClientRect();
@@ -552,7 +565,28 @@
}
</script>
<main>
<main style="--toolbar-overlay-h: {toolbarRegionH}px">
{#snippet toolbarScrollbar()}
{#if tbOverflow > 0}
<div
class="toolbar-scrollbar"
style="width: {tbTrackW}px; transform: translateX({tbScroll + TRACK_EDGE}px);"
role="scrollbar"
tabindex={0}
aria-orientation="horizontal"
aria-controls="primary-toolbar"
aria-valuemin={0}
aria-valuemax={Math.round(tbOverflow)}
aria-valuenow={Math.round(tbScroll)}
onpointerdown={onScrollbarPointerDown}
onpointermove={onScrollbarPointerMove}
onpointerup={onScrollbarPointerUp}
onkeydown={onScrollbarKeydown}
>
<div class="toolbar-scrollbar-thumb" style="width: {thumbWidth}px; transform: translateX({thumbLeft}px);"></div>
</div>
{/if}
{/snippet}
<div class="toolbar-region" bind:clientHeight={toolbarRegionH}>
<div class="toolbar-clip" id="primary-toolbar" bind:this={toolbarClipEl} onwheel={onToolbarWheel}>
<div class="toolbar-stack" bind:this={toolbarStackEl} style="transform: translateX(-{tbScroll}px);">
@@ -716,6 +750,9 @@
onchange={handleImportFile}
/>
</div>
{#if !toolbarExpanded}
{@render toolbarScrollbar()}
{/if}
</header>
<div class="toolbar-secondary" class:expanded={toolbarExpanded}>
<button
@@ -753,28 +790,11 @@
onDebugDump={handleDebugDump}
/>
</div>
{@render toolbarScrollbar()}
{/if}
</div>
</div>
</div>
{#if tbOverflow > 0}
<div
class="toolbar-scrollbar"
role="scrollbar"
tabindex={0}
aria-orientation="horizontal"
aria-controls="primary-toolbar"
aria-valuemin={0}
aria-valuemax={Math.round(tbOverflow)}
aria-valuenow={Math.round(tbScroll)}
onpointerdown={onScrollbarPointerDown}
onpointermove={onScrollbarPointerMove}
onpointerup={onScrollbarPointerUp}
onkeydown={onScrollbarKeydown}
>
<div class="toolbar-scrollbar-thumb" style="width: {thumbWidth}px; transform: translateX({thumbLeft}px);"></div>
</div>
{/if}
</div>
<EditorComponent
bind:editor
@@ -864,6 +884,7 @@
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
/* Find & Replace bar: floats at the top-right of the editing area, just under the
@@ -874,12 +895,24 @@
z-index: 190;
}
/* Toolbar scroll region. A stacking context (z-index) so the whole toolbar — incl.
its dropdowns, which live inside the translated stack — paints above the editor
and its overlays. */
/* Toolbar scroll region: an overlay pinned over the editor (which fills the full
height), so the document scrolls under the floating island and stays visible in
the transparent gaps around it. pointer-events pass through everywhere except
the island pieces themselves. The z-index keeps the whole toolbar — incl. its
dropdowns, which live inside the translated stack — above the editor overlays. */
.toolbar-region {
position: relative;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 200;
pointer-events: none;
}
.toolbar-region header,
.toolbar-region .expand-toggle,
.toolbar-region .toolbar-secondary.expanded {
pointer-events: auto;
}
/* Clips the off-screen part of the toolbar horizontally only, so the page never
@@ -897,52 +930,83 @@
min-width: 100%;
position: relative;
z-index: 1;
/* Inset that floats the toolbar island off the window edges. */
padding: 0.55rem 0.85rem 0.35rem;
}
/* Custom scrollbar strip under the toolbar (a native one auto-hides on macOS).
The thumb's width/position track the visible fraction of the stack; only rendered
when the toolbar overflows. */
/* Runs along the inside of the island's bottom edge. Lives inside the island
(header / expanded row) and is counter-translated (inline style) so it stays
put while the stack scrolls; z:1 paints it above the island surface but below
the dropdowns (z ≥ 200 in the same stacking context). */
.toolbar-scrollbar {
position: relative;
height: 9px;
background: var(--color-toolbar-bg);
border-bottom: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: 4px;
height: 3px;
z-index: 1;
background: color-mix(in srgb, var(--color-btn-hover) 35%, transparent);
border-radius: 2px;
cursor: pointer;
touch-action: none;
}
.toolbar-scrollbar-thumb {
position: absolute;
top: 2px;
top: 0;
left: 0;
height: 5px;
border-radius: 3px;
background: var(--color-btn-hover);
height: 100%;
border-radius: 2px;
background: var(--scrollbar-thumb, var(--color-btn-hover));
cursor: grab;
touch-action: none;
}
.toolbar-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: var(--scrollbar-thumb-hover, var(--color-text-muted));
}
.toolbar-scrollbar-thumb:active {
cursor: grabbing;
}
/* Basic toolbar: a complete toolbar with its own divider + elevation. When the
extended toolbar opens, those move to .toolbar-secondary so the two rows read
as one connected toolbar (border-color/shadow transition for a smooth merge). */
/* Basic toolbar: a floating rounded "command island" (frosted card) rather than
an edge-to-edge bar. When the extended toolbar opens, the island's bottom half
moves to .toolbar-secondary so the two rows read as one card. */
header {
display: flex;
align-items: center;
background: var(--color-toolbar-bg);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow);
transition: border-color 0.18s, box-shadow 0.18s;
position: relative;
/* backdrop-filter makes this a stacking context; z-index keeps its dropdowns
above .toolbar-secondary (160). */
z-index: 200;
background: color-mix(in srgb, var(--color-toolbar-bg) 92%, transparent);
backdrop-filter: blur(12px) saturate(1.35);
-webkit-backdrop-filter: blur(12px) saturate(1.35);
border: 1px solid var(--color-border);
border-radius: var(--island-radius);
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.22), 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.18s, box-shadow 0.18s, border-radius 0.18s;
}
/* Brand hairline: the signature gradient along the island's top edge. */
header::before {
content: '';
position: absolute;
top: -1px;
left: 1.25rem;
right: 1.25rem;
height: 2px;
border-radius: 2px;
background: var(--brand-gradient);
opacity: 0.65;
pointer-events: none;
}
header.expanded {
border-radius: var(--island-radius) var(--island-radius) 0 0;
border-bottom-color: transparent;
box-shadow: none;
}
@@ -951,7 +1015,7 @@
display: inline-flex;
align-items: center;
padding: 4px;
margin: 0 0.25rem 0 0.5rem;
margin: 0 0.25rem 0 1.4rem;
border: none;
border-radius: var(--radius);
background: transparent;
@@ -1005,7 +1069,7 @@
.doc-name-input {
width: 13rem;
height: 1.9rem;
height: var(--toolbar-btn-size);
padding: 0 0.25rem;
border: 1px solid transparent;
border-radius: var(--radius);
@@ -1037,8 +1101,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
width: var(--toolbar-btn-size);
height: var(--toolbar-btn-size);
border: none;
border-radius: var(--radius);
background: transparent;
@@ -1078,7 +1142,7 @@
.save-control {
display: inline-flex;
align-items: stretch;
height: 2rem;
height: var(--toolbar-btn-size);
border: 1px solid transparent;
border-radius: var(--radius);
overflow: hidden;
@@ -1140,17 +1204,22 @@
padding-left reserves the tab's column. */
padding: 0 1rem 0 6.5rem;
background: transparent;
border-bottom: 1px solid transparent;
border: 1px solid transparent;
border-top: none;
border-radius: 0 0 var(--island-radius) var(--island-radius);
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
}
/* Shadow casts downward only (negative spread) so it doesn't bleed up into the
junction with the basic toolbar — the two read as one seamless toolbar. */
/* Expanded it becomes the island's bottom half: same frosted fill, side/bottom
borders and the bottom corner radius complete the card. Shadow casts downward
only so it doesn't bleed up into the junction with the basic toolbar. */
.toolbar-secondary.expanded {
padding-bottom: 0.4rem;
background: var(--color-toolbar-bg);
border-bottom-color: var(--color-border);
box-shadow: 0 4px 4px -2px rgba(0, 0, 0, 0.08);
padding-bottom: 0.5rem;
background: color-mix(in srgb, var(--color-toolbar-bg) 92%, transparent);
backdrop-filter: blur(12px) saturate(1.35);
-webkit-backdrop-filter: blur(12px) saturate(1.35);
border-color: var(--color-border);
box-shadow: 0 10px 26px -14px rgba(0, 0, 0, 0.25);
}
.extended-wrap {
@@ -1168,8 +1237,9 @@
height: var(--toolbar-btn-size);
padding: 0 0.45rem;
position: absolute;
left: 0.75rem;
top: -1px;
/* Clear of the island's rounded bottom-left corner. */
left: 1.35rem;
top: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-toolbar-bg);
@@ -1180,10 +1250,11 @@
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
/* Collapsed: the tab hangs from the basic toolbar's bottom edge — drop the top
border and round only the bottom so the divider bulges around it, and carry
the toolbar's elevation so it reads as part of it. */
/* Collapsed: the tab hangs from the basic toolbar's bottom edge — straddle the
island's border, drop the top border and round only the bottom so the divider
bulges around it, and carry the toolbar's elevation so it reads as part of it. */
.toolbar-secondary:not(.expanded) .expand-toggle {
top: -1px;
border-top-color: transparent;
border-radius: 0 0 var(--radius) var(--radius);
box-shadow: var(--shadow);
@@ -1213,8 +1284,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
width: var(--toolbar-btn-size);
height: var(--toolbar-btn-size);
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
+5 -5
View File
@@ -284,15 +284,15 @@
</div>
<style>
/* Sits inside the header island (which paints the frosted background). Not a
stacking context: its dropdowns (z:200) must join the header's context so they
paint above the island scrollbar (z:1) while it stays above the island surface. */
.toolbar {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 1rem;
background: var(--color-toolbar-bg);
position: sticky;
top: 0;
z-index: 200;
padding: 0.5rem 1rem;
background: transparent;
}
.toolbar-group {
+3 -1
View File
@@ -1,6 +1,8 @@
.editor {
flex: 1;
padding: 2rem 1rem 3rem; /* extra bottom padding clears the fixed status bar */
/* Top padding clears the toolbar island overlaying this scroll area (height set
by App.svelte); extra bottom padding clears the fixed status bar. */
padding: calc(var(--toolbar-overlay-h, 0px) + 1.25rem) 1rem 3rem;
overflow: auto;
/* Positioning context for the floating table toolbar (TableToolbar.svelte),
which is an absolutely-positioned child that scrolls with the content. */
+34 -1
View File
@@ -210,12 +210,39 @@
:root {
--radius: 6px;
--island-radius: 14px; /* corner radius of the floating toolbar card */
--toolbar-btn-size: 1.7rem; /* height of all toolbar controls (matches the floating toolbars) */
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-serif: 'Liberation Serif', 'Times New Roman', Georgia, serif;
/* Brand accents: the primary slate + the logo's warm coral; the gradient is
the app's signature (island hairline). */
--accent-warm: #d96b4a;
--brand-gradient: linear-gradient(90deg, var(--color-primary), var(--accent-warm));
}
/* Signature micro-interactions, shared across all toolbar components. */
@keyframes menu-in {
from { opacity: 0; transform: translateY(-5px); }
}
.toolbar-region button:active:not(:disabled) {
transform: scale(0.93);
}
.theme-dropdown, .ol-dropdown, .align-dropdown, .font-dropdown, .size-dropdown,
.color-dropdown, .char-dropdown, .history-dropdown, .layout-dropdown,
.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown {
animation: menu-in 0.14s ease;
}
@media (prefers-reduced-motion: reduce) {
.toolbar-region button:active:not(:disabled) { transform: none; }
.theme-dropdown, .ol-dropdown, .align-dropdown, .font-dropdown, .size-dropdown,
.color-dropdown, .char-dropdown, .history-dropdown, .layout-dropdown,
.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown { animation: none; }
}
[data-theme="light"] {
--color-bg: #e6e9eb;
--color-surface: #ffffff;
@@ -228,10 +255,11 @@
--color-page-bg: #ffffff;
--color-page-text: #1e293b;
--color-page-border: #e2e8f0;
--scrollbar-thumb: #cbd5e1;
}
[data-theme="dark"] {
--color-bg: #0d1117;
--color-bg: #2f2f31;
--color-surface: #161b22;
--color-border: #30363d;
--color-text: #f2e6f3;
@@ -242,6 +270,9 @@
--color-page-bg: #ffffff;
--color-page-text: #1e293b;
--color-page-border: #e2e8f0;
/* Primary (active-button color) so the toolbar scrollbar stands out on dark. */
--scrollbar-thumb: var(--color-primary);
--scrollbar-thumb-hover: color-mix(in srgb, var(--color-primary) 75%, white);
}
[data-theme="allBlack"] {
@@ -256,6 +287,8 @@
--color-page-bg: #161b22;
--color-page-text: #e6edf3;
--color-page-border: #30363d;
--scrollbar-thumb: var(--color-primary);
--scrollbar-thumb-hover: color-mix(in srgb, var(--color-primary) 75%, white);
}
html, body {