diff --git a/apps/desktop/src/renderer/src/components/home/glyph-catalog/MissingGlyphsPopover.tsx b/apps/desktop/src/renderer/src/components/home/glyph-catalog/MissingGlyphsPopover.tsx
index 933d3e0e..e58cc3d9 100644
--- a/apps/desktop/src/renderer/src/components/home/glyph-catalog/MissingGlyphsPopover.tsx
+++ b/apps/desktop/src/renderer/src/components/home/glyph-catalog/MissingGlyphsPopover.tsx
@@ -106,7 +106,7 @@ export const MissingGlyphsPopover = ({
setScrollTop(event.currentTarget.scrollTop)}
>
diff --git a/apps/desktop/src/renderer/src/lib/themes/index.test.ts b/apps/desktop/src/renderer/src/lib/themes/index.test.ts
index 8e0b88db..7c0813c0 100644
--- a/apps/desktop/src/renderer/src/lib/themes/index.test.ts
+++ b/apps/desktop/src/renderer/src/lib/themes/index.test.ts
@@ -24,6 +24,28 @@ describe("color themes", () => {
expect(resolveThemeSelection("gruvbox-light", "dark").id).toBe("gruvbox-light");
});
+ it("keeps the toolbar distinct from the canvas and its hover in every override theme", () => {
+ for (const theme of colorThemes.filter((theme) => theme.id !== "shift-light")) {
+ const properties = new Map();
+ const root = {
+ dataset: {},
+ style: {
+ colorScheme: "",
+ setProperty: (name: string, value: string) => properties.set(name, value),
+ removeProperty: () => "",
+ },
+ } as unknown as HTMLElement;
+
+ applyResolvedTheme(theme, root);
+ expect(properties.get("--color-icon-button-hover"), theme.id).not.toBe(
+ properties.get("--color-chrome"),
+ );
+ expect(properties.get("--color-chrome"), theme.id).not.toBe(
+ properties.get("--color-surface-muted"),
+ );
+ }
+ });
+
it("applies theme metadata and removes overrides for Shift Light", () => {
const properties = new Map();
const root = {
diff --git a/apps/desktop/src/renderer/src/lib/themes/index.ts b/apps/desktop/src/renderer/src/lib/themes/index.ts
index 3b7637fa..0bec4023 100644
--- a/apps/desktop/src/renderer/src/lib/themes/index.ts
+++ b/apps/desktop/src/renderer/src/lib/themes/index.ts
@@ -281,21 +281,23 @@ function colorThemeVariables(theme: ColorTheme): Record {
const muted = dark ? withAlpha(palette.base05, 0.6) : palette.base03;
const handleOverlay = dark ? palette.base07 : palette.base00;
const surface = dark ? palette.base01 : palette.base00;
- const chrome =
- palette.base02 === surface ? withAlpha(palette.base05, dark ? 0.08 : 0.12) : palette.base02;
+ // Light palettes put a strong selection colour in base02, so light chrome tints the canvas (base01)
+ // toward the text colour instead; on Shift Light's palette this reproduces its #e2e2e2 chrome.
+ const darkChrome = palette.base02 === surface ? withAlpha(palette.base05, 0.08) : palette.base02;
+ const chrome = dark ? darkChrome : mix(palette.base01, palette.base05, 0.07);
return {
"--color-background": palette.base00,
"--color-surface": surface,
"--color-surface-muted": palette.base01,
"--color-chrome": chrome,
- "--color-hover": dark ? withAlpha(palette.base05, 0.16) : palette.base02,
+ "--color-hover": withAlpha(palette.base05, dark ? 0.16 : 0.1),
"--color-surface-hover": dark ? withAlpha(palette.base05, 0.12) : palette.base01,
"--color-surface-inverse": palette.base07,
"--color-on-surface-inverse": palette.base00,
"--color-input": dark ? withAlpha(palette.base05, 0.12) : palette.base01,
"--color-icon-button": dark ? withAlpha(palette.base05, 0.12) : palette.base01,
- "--color-icon-button-hover": dark ? withAlpha(palette.base05, 0.2) : palette.base02,
+ "--color-icon-button-hover": withAlpha(palette.base05, dark ? 0.2 : 0.14),
"--color-line": dark ? palette.base03 : palette.base06,
"--color-line-subtle": dark ? withAlpha(palette.base05, 0.2) : palette.base01,
"--color-shadow-ambient": dark ? "rgba(0, 0, 0, 0.55)" : "rgba(50, 50, 93, 0.25)",
@@ -341,6 +343,7 @@ function colorThemeVariables(theme: ColorTheme): Record {
"--editor-handle-overlay-anchor": withAlpha(handleOverlay, 0.75),
"--editor-snap-color": palette.base08,
"--editor-segment-hover-color": palette.base0D,
+ "--editor-component-hover-outline": palette.base0D,
"--editor-segment-selected-color": palette.base0D,
"--editor-preview-color": palette.base0D,
"--editor-variation-outline-color": withAlpha(palette.base0D, 0.45),
@@ -400,6 +403,17 @@ function relativeLuminance(color: string): number {
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
}
+function mix(color: string, tint: string, amount: number): string {
+ const channel = (offset: number) => {
+ const base = Number.parseInt(color.slice(offset, offset + 2), 16);
+ const target = Number.parseInt(tint.slice(offset, offset + 2), 16);
+ return Math.round(base + (target - base) * amount)
+ .toString(16)
+ .padStart(2, "0");
+ };
+ return `#${channel(1)}${channel(3)}${channel(5)}`;
+}
+
function withAlpha(color: string, alpha: number): string {
const red = Number.parseInt(color.slice(1, 3), 16);
const green = Number.parseInt(color.slice(3, 5), 16);
diff --git a/apps/desktop/src/renderer/src/lib/themes/scrollActivity.ts b/apps/desktop/src/renderer/src/lib/themes/scrollActivity.ts
new file mode 100644
index 00000000..eca65580
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/themes/scrollActivity.ts
@@ -0,0 +1,35 @@
+const SCROLLING_ATTRIBUTE = "data-scrolling";
+const IDLE_DELAY_MS = 800;
+
+/**
+ * Marks each scrolled element with `data-scrolling` until it has been idle for a moment,
+ * so the global scrollbar styles in `index.css` can show the thumb only while the user is scrolling.
+ *
+ * Listens once on the document in the capture phase, because `scroll` does not bubble.
+ * Returns a function that removes the listener and clears pending idle timers.
+ */
+export function trackScrollActivity(root: Document = document): () => void {
+ const idleTimers = new Map();
+
+ const onScroll = (event: Event) => {
+ const element = event.target;
+ if (!(element instanceof Element)) return;
+
+ element.setAttribute(SCROLLING_ATTRIBUTE, "");
+ window.clearTimeout(idleTimers.get(element));
+ idleTimers.set(
+ element,
+ window.setTimeout(() => {
+ element.removeAttribute(SCROLLING_ATTRIBUTE);
+ idleTimers.delete(element);
+ }, IDLE_DELAY_MS),
+ );
+ };
+
+ root.addEventListener("scroll", onScroll, { capture: true, passive: true });
+ return () => {
+ root.removeEventListener("scroll", onScroll, { capture: true });
+ for (const timer of idleTimers.values()) window.clearTimeout(timer);
+ idleTimers.clear();
+ };
+}
diff --git a/packages/editor/src/lib/editor/rendering/Theme.test.ts b/packages/editor/src/lib/editor/rendering/Theme.test.ts
index 89f0b4dc..0214da11 100644
--- a/packages/editor/src/lib/editor/rendering/Theme.test.ts
+++ b/packages/editor/src/lib/editor/rendering/Theme.test.ts
@@ -15,6 +15,7 @@ describe("editor render theme", () => {
["--editor-guides-color", "#123456"],
["--editor-variation-outline-color", "#654321"],
["--editor-read-only-lock-color", "#abcdef"],
+ ["--editor-component-hover-outline", "#83a598"],
]);
const style = {
getPropertyValue: (name: string) => colors.get(name) ?? "",
@@ -25,6 +26,7 @@ describe("editor render theme", () => {
expect(theme.guides.color).toBe("#123456");
expect(theme.variationOutline.color).toBe("#654321");
expect(theme.readOnlyLock.color).toBe("#abcdef");
+ expect(theme.component.hoverOutline.stroke).toBe("#83a598");
});
it("builds GPU marker colors from the active render theme", () => {
diff --git a/packages/editor/src/lib/editor/rendering/Theme.ts b/packages/editor/src/lib/editor/rendering/Theme.ts
index 549f75a5..03d83e78 100644
--- a/packages/editor/src/lib/editor/rendering/Theme.ts
+++ b/packages/editor/src/lib/editor/rendering/Theme.ts
@@ -247,6 +247,10 @@ export function readEditorRenderTheme(
theme.glyph.editableFill = readColor("--editor-glyph-editable-fill", theme.glyph.editableFill);
theme.glyph.stroke = readColor("--editor-glyph-stroke", theme.glyph.stroke);
theme.component.fill = readColor("--editor-component-fill", theme.component.fill);
+ theme.component.hoverOutline.stroke = readColor(
+ "--editor-component-hover-outline",
+ theme.component.hoverOutline.stroke,
+ );
theme.controlLine.color = readColor("--editor-control-line-color", theme.controlLine.color);
const handleFill = readColor("--editor-handle-fill", theme.handle.corner.idle.fill);