Compare commits
2 Commits
258e3ec620
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| c1efa7b94e | |||
| e31b84c08e |
@@ -66,8 +66,9 @@ function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
|
|||||||
*
|
*
|
||||||
* Renders `null` unless we're inside Tauri **and** the user opted into custom
|
* Renders `null` unless we're inside Tauri **and** the user opted into custom
|
||||||
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
|
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
|
||||||
* draggable region (`data-tauri-drag-region`) with the app brand, plus
|
* draggable region (explicit `window_start_drag` on mousedown, double-press to
|
||||||
* minimize / maximize / close controls that call the native window commands.
|
* maximize) with the app brand, plus minimize / maximize / close controls that
|
||||||
|
* call the native window commands.
|
||||||
*
|
*
|
||||||
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
|
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
|
||||||
* the left (the native traffic-light position) since decorations — and thus the
|
* the left (the native traffic-light position) since decorations — and thus the
|
||||||
@@ -80,10 +81,18 @@ export function TitleBar() {
|
|||||||
|
|
||||||
const mac = isMacOS();
|
const mac = isMacOS();
|
||||||
|
|
||||||
const handleDoubleClick = (evt: MouseEvent<HTMLDivElement>): void => {
|
// Official Tauri custom-titlebar recipe: primary-button mousedown starts an
|
||||||
// Only the drag surface itself toggles maximize, not the brand/children.
|
// OS window drag; a double press (detail === 2) toggles maximize instead. An
|
||||||
if (evt.target !== evt.currentTarget) return;
|
// explicit `window_start_drag` invoke is used rather than
|
||||||
|
// `data-tauri-drag-region` because the attribute only fires when the exact
|
||||||
|
// element is the event target (children like the brand text wouldn't drag).
|
||||||
|
const handleDragMouseDown = (evt: MouseEvent<HTMLDivElement>): void => {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
if (evt.detail === 2) {
|
||||||
invokeTauri('window_toggle_maximize');
|
invokeTauri('window_toggle_maximize');
|
||||||
|
} else {
|
||||||
|
invokeTauri('window_start_drag');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const controls = (
|
const controls = (
|
||||||
@@ -108,7 +117,7 @@ export function TitleBar() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dragRegion = (
|
const dragRegion = (
|
||||||
<div className={css.DragRegion} data-tauri-drag-region onDoubleClick={handleDoubleClick}>
|
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
|
||||||
<span className={css.Brand}>
|
<span className={css.Brand}>
|
||||||
<Text as="span" size="T200" truncate>
|
<Text as="span" size="T200" truncate>
|
||||||
Lotus Chat
|
Lotus Chat
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
contrastingText,
|
contrastingText,
|
||||||
varNameFromToken,
|
varNameFromToken,
|
||||||
derivePrimaryPalette,
|
derivePrimaryPalette,
|
||||||
|
deriveAccentExtras,
|
||||||
|
buildAccentCss,
|
||||||
} from './accentColor';
|
} from './accentColor';
|
||||||
|
|
||||||
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
|
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
|
||||||
@@ -66,3 +68,22 @@ test('derivePrimaryPalette produces the full Primary token set', () => {
|
|||||||
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
|
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
|
||||||
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
|
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deriveAccentExtras derives focus ring, link and selection from one base', () => {
|
||||||
|
const base = { r: 255, g: 136, b: 0 };
|
||||||
|
const extras = deriveAccentExtras(base);
|
||||||
|
// focus ring keeps the translucent character in the accent hue
|
||||||
|
assert.equal(extras.focusRing, 'rgba(255, 136, 0, 0.5)');
|
||||||
|
// link + selection background are the solid base hex
|
||||||
|
assert.equal(extras.link, '#ff8800');
|
||||||
|
assert.equal(extras.selectionBg, '#ff8800');
|
||||||
|
// selection text is WCAG-aware contrasting text over the base
|
||||||
|
assert.equal(extras.selectionText, contrastingText(base));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAccentCss emits selection rules using the derived palette', () => {
|
||||||
|
const base = { r: 0, g: 0, b: 0 };
|
||||||
|
const css = buildAccentCss(base);
|
||||||
|
assert.match(css, /::selection\{background:#000000;color:#fff;\}/);
|
||||||
|
assert.match(css, /::-moz-selection\{background:#000000;color:#fff;\}/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -74,6 +74,45 @@ const PRIMARY_TOKENS: Record<string, string> = {
|
|||||||
OnContainer: color.Primary.OnContainer,
|
OnContainer: color.Primary.OnContainer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The neutral focus-ring token folds uses for the outline on inputs, buttons,
|
||||||
|
// switches, checkboxes and radios. Its default is a semi-transparent grey/black,
|
||||||
|
// so tinting it in the accent hue themes every focus ring without touching the
|
||||||
|
// neutral Secondary family (see below). We keep the same translucent character
|
||||||
|
// so it reads as a ring rather than a fill.
|
||||||
|
const FOCUS_RING_TOKEN = color.Other.FocusRing;
|
||||||
|
|
||||||
|
// `--tc-link` is the global anchor color (index.css `a { color: var(--tc-link) }`);
|
||||||
|
// overriding it themes plain links inside messages, room topics and URL previews.
|
||||||
|
const LINK_VAR = '--tc-link';
|
||||||
|
|
||||||
|
// Injected stylesheet id — carries rules that cannot be expressed as a single
|
||||||
|
// CSS variable (currently text ::selection).
|
||||||
|
const ACCENT_STYLE_ID = 'lotus-accent-style';
|
||||||
|
|
||||||
|
export type AccentExtras = {
|
||||||
|
focusRing: string;
|
||||||
|
link: string;
|
||||||
|
selectionBg: string;
|
||||||
|
selectionText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive the extra (non-Primary) accent values from the single base color, using
|
||||||
|
// the same helpers as the Primary palette so everything stays in one hue.
|
||||||
|
export const deriveAccentExtras = (base: Rgb): AccentExtras => ({
|
||||||
|
focusRing: rgba(base, 0.5),
|
||||||
|
link: rgbToHex(base),
|
||||||
|
selectionBg: rgbToHex(base),
|
||||||
|
selectionText: contrastingText(base),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the injected stylesheet body. Selection uses a solid accent fill with
|
||||||
|
// WCAG-aware contrasting text so highlighted text stays readable.
|
||||||
|
export const buildAccentCss = (base: Rgb): string => {
|
||||||
|
const { selectionBg, selectionText } = deriveAccentExtras(base);
|
||||||
|
const selection = `background:${selectionBg};color:${selectionText};`;
|
||||||
|
return `::selection{${selection}}::-moz-selection{${selection}}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Derive the 10 Primary sub-token values from a single chosen base color.
|
// Derive the 10 Primary sub-token values from a single chosen base color.
|
||||||
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
||||||
const baseHex = rgbToHex(base);
|
const baseHex = rgbToHex(base);
|
||||||
@@ -96,22 +135,46 @@ export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Apply a custom accent color by overriding the folds Primary CSS variables on
|
// Apply a custom accent color by overriding the folds Primary CSS variables on
|
||||||
// `document.body`. Returns true when applied, false when the input is invalid.
|
// `document.body`, tinting the focus-ring and link vars, and injecting a small
|
||||||
|
// stylesheet for text selection. Returns true when applied, false when the input
|
||||||
|
// is invalid.
|
||||||
export const applyCustomAccent = (hex: string): boolean => {
|
export const applyCustomAccent = (hex: string): boolean => {
|
||||||
const base = hexToRgb(hex);
|
const base = hexToRgb(hex);
|
||||||
if (!base) return false;
|
if (!base) return false;
|
||||||
|
|
||||||
const palette = derivePrimaryPalette(base);
|
const palette = derivePrimaryPalette(base);
|
||||||
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
|
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
|
||||||
const varName = varNameFromToken(token);
|
const varName = varNameFromToken(token);
|
||||||
if (varName) document.body.style.setProperty(varName, palette[key]);
|
if (varName) document.body.style.setProperty(varName, palette[key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extras = deriveAccentExtras(base);
|
||||||
|
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
||||||
|
if (focusRingVar) document.body.style.setProperty(focusRingVar, extras.focusRing);
|
||||||
|
document.body.style.setProperty(LINK_VAR, extras.link);
|
||||||
|
|
||||||
|
let styleEl = document.getElementById(ACCENT_STYLE_ID) as HTMLStyleElement | null;
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = ACCENT_STYLE_ID;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = buildAccentCss(base);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove all custom accent overrides, reverting to the active theme's defaults.
|
// Remove all custom accent overrides, reverting to the active theme's defaults.
|
||||||
|
// Idempotent — safe to call even when nothing was applied.
|
||||||
export const removeCustomAccent = (): void => {
|
export const removeCustomAccent = (): void => {
|
||||||
Object.values(PRIMARY_TOKENS).forEach((token) => {
|
Object.values(PRIMARY_TOKENS).forEach((token) => {
|
||||||
const varName = varNameFromToken(token);
|
const varName = varNameFromToken(token);
|
||||||
if (varName) document.body.style.removeProperty(varName);
|
if (varName) document.body.style.removeProperty(varName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
||||||
|
if (focusRingVar) document.body.style.removeProperty(focusRingVar);
|
||||||
|
document.body.style.removeProperty(LINK_VAR);
|
||||||
|
|
||||||
|
document.getElementById(ACCENT_STYLE_ID)?.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user