Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/desktop/src/renderer/src/components/recent/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ const newFile = () => {
margin-top: 20px;
}
& .el-button.is-text.is-has-bg {
background-color: var(--itemBgColor);
color: var(--themeColor);
background-color: var(--buttonPrimaryBgColor);
color: var(--buttonPrimaryFontColor);
border-color: transparent;
}
& .el-button.is-text.is-has-bg:hover,
& .el-button.is-text.is-has-bg:focus {
background-color: var(--floatHoverColor);
color: var(--themeColor);
background-color: var(--buttonPrimaryBgColorHover);
color: var(--buttonPrimaryFontColorHover);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,14 +439,14 @@ onMounted(() => {
margin-top: 20px;
}
& .no-data .el-button.is-text.is-has-bg {
background-color: var(--itemBgColor);
color: var(--themeColor);
background-color: var(--buttonPrimaryBgColor);
color: var(--buttonPrimaryFontColor);
border-color: transparent;
}
& .no-data .el-button.is-text.is-has-bg:hover,
& .no-data .el-button.is-text.is-has-bg:focus {
background-color: var(--floatHoverColor);
color: var(--themeColor);
background-color: var(--buttonPrimaryBgColorHover);
color: var(--buttonPrimaryFontColorHover);
}
}
</style>
8 changes: 4 additions & 4 deletions packages/desktop/src/renderer/src/components/sideBar/tree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -427,16 +427,16 @@ onMounted(() => {
}
.open-project .el-button.is-text.is-has-bg,
.empty-project .el-button.is-text.is-has-bg {
background-color: var(--itemBgColor);
color: var(--themeColor);
background-color: var(--buttonPrimaryBgColor);
color: var(--buttonPrimaryFontColor);
border-color: transparent;
}
.open-project .el-button.is-text.is-has-bg:hover,
.open-project .el-button.is-text.is-has-bg:focus,
.empty-project .el-button.is-text.is-has-bg:hover,
.empty-project .el-button.is-text.is-has-bg:focus {
background-color: var(--floatHoverColor);
color: var(--themeColor);
background-color: var(--buttonPrimaryBgColorHover);
color: var(--buttonPrimaryFontColorHover);
}
.new-input {
outline: none;
Expand Down
198 changes: 198 additions & 0 deletions packages/desktop/test/unit/specs/empty-state-button-contrast.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

// #4774: the sidebar "Open Folder" and editor "New File" empty-state buttons
// styled their label as `color: var(--themeColor)` on
// `background-color: var(--itemBgColor)`. Neither is a contrast-controlled
// pairing, so the label was unreadable in several themes (e.g. ayu-light,
// everforest-light). Every theme already defines a contrast-tuned primary
// pairing (--buttonPrimaryFontColor / --buttonPrimaryBgColor) that drives the
// app-wide `.button-primary`. These buttons must be at least as readable as
// that standard primary button in EVERY built-in theme.

const __dirname = dirname(fileURLToPath(import.meta.url))
const RENDERER = resolve(__dirname, '../../../src/renderer/src')
const THEME_DIR = resolve(RENDERER, 'assets/themes')
const BASE_CSS = resolve(RENDERER, 'assets/styles/index.css')

type Rgb = [number, number, number]
type Rgba = [number, number, number, number]

const parseVars = (css: string): Record<string, string> => {
const vars: Record<string, string> = {}
const re = /(--[\w-]+)\s*:\s*([^;]+);/g
let m: RegExpExecArray | null
while ((m = re.exec(css))) vars[m[1]] = m[2].trim()
return vars
}

const resolveVar = (value: string, vars: Record<string, string>): string => {
let v = value
let guard = 0
while (/var\(/.test(v) && guard++ < 20) {
v = v
.replace(/var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)/g, (_, name) => vars[name] ?? '')
.trim()
}
return v
}

const toRgba = (raw: string): Rgba | null => {
const str = raw.trim()
let m: RegExpMatchArray | null
if ((m = str.match(/^#([0-9a-fA-F]{3})$/))) {
const h = m[1]
return [
parseInt(h[0] + h[0], 16),
parseInt(h[1] + h[1], 16),
parseInt(h[2] + h[2], 16),
1
]
}
if ((m = str.match(/^#([0-9a-fA-F]{6})$/))) {
const h = m[1]
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
1
]
}
if ((m = str.match(/^rgba?\(([^)]+)\)/))) {
const p = m[1].split(',').map((s) => parseFloat(s.trim()))
return [p[0], p[1], p[2], p[3] === undefined ? 1 : p[3]]
}
// Some themes use a gradient for the primary button background; the solid
// stop is representative for a contrast estimate.
if (/^linear-gradient/.test(str)) {
const h = str.match(/#([0-9a-fA-F]{6})/)
if (h) return toRgba('#' + h[1])
}
return null
}

// Composite a translucent colour over an opaque backdrop.
const over = (fg: Rgba, bg: Rgb): Rgb => {
const a = fg[3]
return [
fg[0] * a + bg[0] * (1 - a),
fg[1] * a + bg[1] * (1 - a),
fg[2] * a + bg[2] * (1 - a)
]
}

const relLuminance = ([r, g, b]: Rgb): number => {
const f = (c: number): number => {
const s = c / 255
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
}
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}

const contrast = (a: Rgb, b: Rgb): number => {
const l1 = relLuminance(a)
const l2 = relLuminance(b)
const hi = Math.max(l1, l2)
const lo = Math.min(l1, l2)
return (hi + 0.05) / (lo + 0.05)
}

// Resolve a (foreground var, background var) pairing to its rendered WCAG
// contrast for a given theme, compositing any translucent colours over the
// surface the button sits on.
const pairContrast = (
fgVar: string,
bgVar: string,
surfaceVar: string,
vars: Record<string, string>
): number => {
const surface = toRgba(resolveVar(`var(${surfaceVar})`, vars))
const surfaceRgb: Rgb = surface ? [surface[0], surface[1], surface[2]] : [255, 255, 255]
const bgRaw = toRgba(resolveVar(`var(${bgVar})`, vars))
const fgRaw = toRgba(resolveVar(`var(${fgVar})`, vars))
if (!bgRaw || !fgRaw) throw new Error(`unresolved colour ${fgVar}/${bgVar}`)
const bg = bgRaw[3] < 1 ? over(bgRaw, surfaceRgb) : [bgRaw[0], bgRaw[1], bgRaw[2]] as Rgb
const fg = fgRaw[3] < 1 ? over(fgRaw, bg) : [fgRaw[0], fgRaw[1], fgRaw[2]] as Rgb
return contrast(fg, bg)
}

// Pull the foreground (color) and background-color custom-property names the
// empty-state button rule assigns, straight from the component's scoped CSS.
const extractButtonVars = (
componentPath: string
): { fgVar: string; bgVar: string } => {
const css = readFileSync(componentPath, 'utf8')
const ruleRe = /([^{}]+)\{([^{}]*)\}/g
let m: RegExpExecArray | null
while ((m = ruleRe.exec(css))) {
const selector = m[1]
const body = m[2]
if (!selector.includes('.is-text.is-has-bg')) continue
if (/:hover|:focus/.test(selector)) continue
const bg = body.match(/(?<![-\w])background-color:\s*var\(\s*(--[\w-]+)/)
const fg = body.match(/(?<![-\w])color:\s*var\(\s*(--[\w-]+)/)
if (bg && fg) return { fgVar: fg[1], bgVar: bg[1] }
}
throw new Error(`no .is-text.is-has-bg colour rule found in ${componentPath}`)
}

const baseVars = parseVars(readFileSync(BASE_CSS, 'utf8'))
const themeFiles = readdirSync(THEME_DIR).filter((f) => f.endsWith('.theme.css'))

const COMPONENTS = [
{
name: 'Open Folder (sidebar/tree.vue)',
path: resolve(RENDERER, 'components/sideBar/tree.vue'),
surfaceVar: '--sideBarBgColor'
},
{
name: 'New File (recent/index.vue)',
path: resolve(RENDERER, 'components/recent/index.vue'),
surfaceVar: '--editorBgColor'
},
{
name: 'Open Folder (sideBar/search.vue no-data)',
path: resolve(RENDERER, 'components/sideBar/search.vue'),
surfaceVar: '--sideBarBgColor'
}
]

describe('empty-state button readability (#4774)', () => {
it('found theme files and base variables to test against', () => {
expect(themeFiles.length).toBeGreaterThan(20)
expect(baseVars['--buttonPrimaryFontColor']).toBeTruthy()
expect(baseVars['--buttonPrimaryBgColor']).toBeTruthy()
})

for (const component of COMPONENTS) {
it(`${component.name} label is at least as readable as the standard primary button, in every theme`, () => {
const { fgVar, bgVar } = extractButtonVars(component.path)
const failures: string[] = []

for (const file of themeFiles) {
const vars = {
...baseVars,
...parseVars(readFileSync(resolve(THEME_DIR, file), 'utf8'))
}
const buttonContrast = pairContrast(fgVar, bgVar, component.surfaceVar, vars)
const primaryContrast = pairContrast(
'--buttonPrimaryFontColor',
'--buttonPrimaryBgColor',
component.surfaceVar,
vars
)
// Equal is fine (the button reuses the primary pairing); only a
// strictly-worse contrast than the standard primary button fails.
if (buttonContrast < primaryContrast - 0.01) {
failures.push(
`${file.replace('.theme.css', '')}: button ${buttonContrast.toFixed(2)} < primary ${primaryContrast.toFixed(2)}`
)
}
}

expect(failures, `\n ${failures.join('\n ')}\n`).toEqual([])
})
}
})
27 changes: 27 additions & 0 deletions packages/muya/src/block/base/__tests__/formatToggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,33 @@ describe('format.format() apply-ON over a non-collapsed selection', () => {
});
});

// #2166 — double-clicking a word selects the word PLUS the trailing whitespace.
// Wrapping that whitespace inside the markers (`**foo **`) is invalid emphasis
// per CommonMark's flanking rules, so it renders as literal text. The markers
// must hug the non-whitespace content, leaving the whitespace outside.
describe('format.format() trims selection whitespace before wrapping (#2166)', () => {
it('strong: selecting `foo ` (with trailing space) wraps only `foo`', () => {
// `foo bar`: offsets 0..4 cover `foo ` including the trailing space.
const content = selectInFirstBlock(bootMuya('foo bar\n'), 0, 4);
content.format('strong');
expect(content.text).toBe('**foo** bar');
});

it('em: selecting ` bar` (with leading space) wraps only `bar`', () => {
// `foo bar`: offsets 3..7 cover ` bar` including the leading space.
const content = selectInFirstBlock(bootMuya('foo bar\n'), 3, 7);
content.format('em');
expect(content.text).toBe('foo *bar*');
});

it('strong: selecting `foo ` then ` bar` style both-side padding wraps only the words', () => {
// ` foo ` at offsets 0..5 of ` foo bar` → only `foo` gets wrapped.
const content = selectInFirstBlock(bootMuya(' foo bar\n'), 0, 5);
content.format('strong');
expect(content.text).toBe(' **foo** bar');
});
});

describe('format.format(\'clear\') with the caret inside the run', () => {
it('strips a strong run to plain text', () => {
const content = caretInFirstBlock(bootMuya('**word**\n'), 2);
Expand Down
76 changes: 76 additions & 0 deletions packages/muya/src/block/base/__tests__/imageSelfClose.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom

import type Format from '../format';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Muya } from '../../../muya';
import { getImageInfo } from '../../../utils/image';

// #2505 — aligning an image rewrites `![alt](src)` into an inline HTML `<img …>`
// so it can carry the alignment attribute. The generated tag was left OPEN
// (`<img …>`), which is valid Markdown/HTML but breaks JSX/MDX (Docusaurus:
// "Unterminated JSX contents"). It must be self-closed (`<img … />`). This
// drives the real alignment path the image toolbar uses
// (block.updateImage(info, 'data-align', …)) and the edit path
// (block.replaceImage) on a booted engine.

const bootedHosts: HTMLElement[] = [];
let originalVersion: string | undefined;
let hadVersion = false;

beforeEach(() => {
hadVersion = 'MUYA_VERSION' in window;
originalVersion = window.MUYA_VERSION;
window.MUYA_VERSION = 'test';
});

afterEach(() => {
while (bootedHosts.length)
bootedHosts.pop()!.remove();
document.getSelection()?.removeAllRanges();
if (hadVersion)
window.MUYA_VERSION = originalVersion as string;
else
delete (window as Partial<Window>).MUYA_VERSION;
});

function bootMuya(markdown: string): Muya {
const host = document.createElement('div');
document.body.appendChild(host);
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[1]);
muya.init();
bootedHosts.push(muya.domNode);
return muya;
}

function firstBlock(muya: Muya): Format {
return muya.editor.scrollPage!.firstContentInDescendant() as unknown as Format;
}

describe('#2505 — aligned/edited images emit a self-closing <img/> (JSX-safe)', () => {
it('aligning a markdown image produces a self-closed <img …/>', () => {
const muya = bootMuya('![cat](https://example.com/cat.png)\n');
const block = firstBlock(muya);
const imageEl = muya.domNode.querySelector<HTMLElement>('[data-raw]');
expect(imageEl).not.toBeNull();

const imageInfo = getImageInfo(imageEl!);
block.updateImage(imageInfo, 'data-align', 'center');

expect(block.text).toMatch(/<img\b[^>]*\/>/);
// and never an unterminated open tag
expect(block.text).not.toMatch(/<img\b[^>]*[^/]>/);
});

it('editing an existing inline HTML <img> keeps it self-closed', () => {
const muya = bootMuya('<img src="https://example.com/a.png" alt="a" data-align="left">\n');
const block = firstBlock(muya);
const imageEl = muya.domNode.querySelector<HTMLElement>('[data-raw]');
expect(imageEl).not.toBeNull();

const imageInfo = getImageInfo(imageEl!);
block.replaceImage(imageInfo, { alt: 'b', src: 'https://example.com/b.png', title: '' });

expect(block.text).toMatch(/<img\b[^>]*\/>/);
expect(block.text).not.toMatch(/<img\b[^>]*[^/]>/);
});
});
Loading
Loading