fix: honor subtitle annotation style priority

This commit is contained in:
2026-04-25 17:10:54 -07:00
parent c9df5b7624
commit 76b546c8f6
5 changed files with 271 additions and 55 deletions

View File

@@ -0,0 +1,32 @@
---
id: TASK-294
title: Fix annotated subtitle tokens to honor subtitleStyle
status: Done
assignee: []
created_date: '2026-04-25 23:04'
updated_date: '2026-04-25 23:07'
labels:
- subtitles
- renderer
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Annotated token spans should inherit the configured subtitleStyle typography and only use annotation metadata to change token color.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tokenized/annotated subtitles preserve configured base subtitle typography such as font family, size, weight, line height, letter spacing, text rendering, and text shadow.
- [x] #2 Known/N+1/JLPT/frequency/name-match annotations affect token color only, plus existing token metadata/hover affordances.
- [x] #3 A renderer regression test covers annotated token rendering with custom subtitleStyle.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Updated renderer subtitle annotation CSS so known/N+1/name/JLPT/frequency classes no longer override typography with token-specific shadows, underlines, padding, or hover font-weight. Added regression coverage using the user's custom subtitleStyle shape to verify annotated token spans inherit base typography and annotation CSS changes token color only. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,32 @@
---
id: TASK-297
title: Fix subtitle annotation color priority after typography cleanup
status: Done
assignee: []
created_date: '2026-04-25 23:44'
updated_date: '2026-04-25 23:46'
labels:
- subtitles
- renderer
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Known-word and frequency subtitle token colors should keep their configured priority after annotation CSS stopped using JLPT underlines.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Known-word token color takes priority over JLPT and frequency color classes.
- [x] #2 Frequency single-mode token color takes priority over JLPT color classes when frequency annotation is active.
- [x] #3 Regression coverage verifies CSS selectors do not allow JLPT color rules to override higher-priority annotation colors.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Scoped JLPT token color selectors so they only apply when no higher-priority known-word, N+1, name-match, or frequency class is present. This keeps known words green and frequency single-mode tokens using the single frequency color instead of being visually overridden by JLPT colors. Added CSS regression assertions for this priority behavior. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed annotated subtitle token colors so `subtitleStyle` typography is preserved and higher-priority known-word/frequency colors are not overridden by JLPT colors.

View File

@@ -783,88 +783,121 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-known { #subtitleRoot .word.word-known {
color: var(--subtitle-known-word-color, #a6da95); color: var(--subtitle-known-word-color, #a6da95);
text-shadow: 0 0 6px rgba(166, 218, 149, 0.35);
} }
#subtitleRoot .word.word-n-plus-one { #subtitleRoot .word.word-n-plus-one {
color: var(--subtitle-n-plus-one-color, #c6a0f6); color: var(--subtitle-n-plus-one-color, #c6a0f6);
text-shadow: 0 0 6px rgba(198, 160, 246, 0.35);
} }
#subtitleRoot .word.word-name-match { #subtitleRoot .word.word-name-match {
color: var(--subtitle-name-match-color, #f5bde6); color: var(--subtitle-name-match-color, #f5bde6);
text-shadow: 0 0 6px rgba(245, 189, 230, 0.35);
} }
#subtitleRoot .word.word-jlpt-n1 { #subtitleRoot
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n1-color, #ed8796); .word.word-jlpt-n1:not(
border-bottom: 2px solid var(--subtitle-jlpt-underline-color); :is(
padding-bottom: 1px; .word-known,
box-decoration-break: clone; .word-n-plus-one,
-webkit-box-decoration-break: clone; .word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n1-color, #ed8796);
} }
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n1-color, #ed8796); color: var(--subtitle-jlpt-n1-color, #ed8796);
} }
#subtitleRoot .word.word-jlpt-n2 { #subtitleRoot
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n2-color, #f5a97f); .word.word-jlpt-n2:not(
border-bottom: 2px solid var(--subtitle-jlpt-underline-color); :is(
padding-bottom: 1px; .word-known,
box-decoration-break: clone; .word-n-plus-one,
-webkit-box-decoration-break: clone; .word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n2-color, #f5a97f);
} }
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n2-color, #f5a97f); color: var(--subtitle-jlpt-n2-color, #f5a97f);
} }
#subtitleRoot .word.word-jlpt-n3 { #subtitleRoot
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n3-color, #f9e2af); .word.word-jlpt-n3:not(
border-bottom: 2px solid var(--subtitle-jlpt-underline-color); :is(
padding-bottom: 1px; .word-known,
box-decoration-break: clone; .word-n-plus-one,
-webkit-box-decoration-break: clone; .word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n3-color, #f9e2af);
} }
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n3-color, #f9e2af); color: var(--subtitle-jlpt-n3-color, #f9e2af);
} }
#subtitleRoot .word.word-jlpt-n4 { #subtitleRoot
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n4-color, #a6e3a1); .word.word-jlpt-n4:not(
border-bottom: 2px solid var(--subtitle-jlpt-underline-color); :is(
padding-bottom: 1px; .word-known,
box-decoration-break: clone; .word-n-plus-one,
-webkit-box-decoration-break: clone; .word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
} }
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n4-color, #a6e3a1); color: var(--subtitle-jlpt-n4-color, #a6e3a1);
} }
#subtitleRoot .word.word-jlpt-n5 { #subtitleRoot
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n5-color, #8aadf4); .word.word-jlpt-n5:not(
border-bottom: 2px solid var(--subtitle-jlpt-underline-color); :is(
padding-bottom: 1px; .word-known,
box-decoration-break: clone; .word-n-plus-one,
-webkit-box-decoration-break: clone; .word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n5-color, #8aadf4);
} }
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n5-color, #8aadf4); color: var(--subtitle-jlpt-n5-color, #8aadf4);
} }
#subtitleRoot .word.word-frequency-single,
#subtitleRoot .word.word-frequency-band-1,
#subtitleRoot .word.word-frequency-band-2,
#subtitleRoot .word.word-frequency-band-3,
#subtitleRoot .word.word-frequency-band-4,
#subtitleRoot .word.word-frequency-band-5 {
text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
}
#subtitleRoot .word.word-frequency-single { #subtitleRoot .word.word-frequency-single {
color: var(--subtitle-frequency-single-color, #f5a97f); color: var(--subtitle-frequency-single-color, #f5a97f);
} }
@@ -912,7 +945,6 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-frequency-band-5:hover { #subtitleRoot .word.word-frequency-band-5:hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)); background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
border-radius: 3px; border-radius: 3px;
font-weight: 800;
} }
#subtitleRoot .word.word-known .c:hover, #subtitleRoot .word.word-known .c:hover,

View File

@@ -220,6 +220,22 @@ function normalizeCssSelector(selector: string): string {
.trim(); .trim();
} }
function buildJlptColorSelector(level: number): string {
const higherPriorityClasses = [
'.word-known',
'.word-n-plus-one',
'.word-name-match',
'.word-frequency-single',
'.word-frequency-band-1',
'.word-frequency-band-2',
'.word-frequency-band-3',
'.word-frequency-band-4',
'.word-frequency-band-5',
].join(', ');
return `#subtitleRoot .word.word-jlpt-n${level}:not(:is(${higherPriorityClasses}))`;
}
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => { test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
const knownJlpt = createToken({ const knownJlpt = createToken({
isKnown: true, isKnown: true,
@@ -410,6 +426,96 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
} }
}); });
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35,
fontColor: '#cad3f5',
fontWeight: 700,
lineHeight: 1.35,
letterSpacing: '-0.01em',
textRendering: 'geometricPrecision',
textShadow: '3px 0 0 #000, -3px 0 0 #000, 0 3px 0 #000, 0 -3px 0 #000, 2px 2px 0 #000',
frequencyDictionary: {
enabled: true,
topX: 10000,
mode: 'single',
singleColor: '#f5a97f',
},
enableJlpt: true,
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
N3: '#f9e2af',
N4: '#a6e3a1',
N5: '#8aadf4',
},
nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95',
} as never);
renderer.renderSubtitle({
text: 'お礼をされるようなことしてない',
tokens: [
createToken({ surface: 'お礼', isKnown: true }),
createToken({ surface: 'を' }),
createToken({ surface: 'される', jlptLevel: 'N4' }),
createToken({ surface: 'ような', frequencyRank: 15 }),
],
});
const rootStyle = subtitleRoot.style as unknown as Record<string, string>;
assert.equal(rootStyle.fontFamily, 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP');
assert.equal(rootStyle.fontSize, '35px');
assert.equal(rootStyle.color, '#cad3f5');
assert.equal(rootStyle.fontWeight, '700');
assert.equal(rootStyle.lineHeight, '1.35');
assert.equal(rootStyle.letterSpacing, '-0.01em');
assert.equal(rootStyle.textRendering, 'geometricPrecision');
assert.match(rootStyle.textShadow ?? '', /3px 0 0 #000/);
const wordNodes = collectWordNodes(subtitleRoot);
assert.deepEqual(
wordNodes.map((node) => [node.textContent, node.className]),
[
['お礼', 'word word-known'],
['を', 'word'],
['される', 'word word-jlpt-n4'],
['ような', 'word word-frequency-single'],
],
);
for (const wordNode of wordNodes) {
const tokenStyle = wordNode.style as unknown as Record<string, string>;
assert.equal(tokenStyle.fontFamily, undefined);
assert.equal(tokenStyle.fontSize, undefined);
assert.equal(tokenStyle.fontWeight, undefined);
assert.equal(tokenStyle.lineHeight, undefined);
assert.equal(tokenStyle.letterSpacing, undefined);
assert.equal(tokenStyle.textRendering, undefined);
assert.equal(tokenStyle.textShadow, undefined);
}
} finally {
restoreDocument();
}
});
test('computeWordClass adds frequency class for single mode when rank is within topX', () => { test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
const token = createToken({ const token = createToken({
surface: '猫', surface: '猫',
@@ -749,7 +855,7 @@ test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist',
assert.equal(shouldRenderTokenizedSubtitle(0), false); assert.equal(shouldRenderTokenizedSubtitle(0), false);
}); });
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { test('subtitle annotation CSS changes token color without overriding typography', () => {
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css'); const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css'); const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
@@ -763,17 +869,27 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
const cssText = fs.readFileSync(cssPath, 'utf-8'); const cssText = fs.readFileSync(cssPath, 'utf-8');
for (let level = 1; level <= 5; level += 1) { for (let level = 1; level <= 5; level += 1) {
const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`); const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
const block = extractClassBlock(cssText, buildJlptColorSelector(level));
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`); assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
assert.match( assert.match(block, new RegExp(`color:\\s*var\\(--subtitle-jlpt-n${level}-color,`));
block, assert.doesNotMatch(block, /border-bottom\s*:/);
new RegExp(`--subtitle-jlpt-underline-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`), assert.doesNotMatch(block, /padding-bottom\s*:/);
); assert.doesNotMatch(block, /box-decoration-break\s*:/);
assert.match(block, /border-bottom:\s*2px solid var\(--subtitle-jlpt-underline-color\);/); assert.doesNotMatch(block, /-webkit-box-decoration-break\s*:/);
assert.match(block, /padding-bottom:\s*1px;/); assert.doesNotMatch(block, /text-shadow\s*:/);
assert.match(block, /box-decoration-break:\s*clone;/); }
assert.match(block, /-webkit-box-decoration-break:\s*clone;/);
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m); for (const selector of [
'#subtitleRoot .word.word-known',
'#subtitleRoot .word.word-n-plus-one',
'#subtitleRoot .word.word-name-match',
]) {
const block = extractClassBlock(cssText, selector);
assert.match(block, /color:\s*var\(/);
assert.doesNotMatch(block, /text-shadow\s*:/);
} }
for (let band = 1; band <= 5; band += 1) { for (let band = 1; band <= 5; band += 1) {
@@ -873,7 +989,7 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/, /background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
); );
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/); assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
assert.match(coloredWordHoverBlock, /font-weight:\s*800;/); assert.doesNotMatch(coloredWordHoverBlock, /font-weight\s*:/);
assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/); assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/);
assert.doesNotMatch( assert.doesNotMatch(
coloredWordHoverBlock, coloredWordHoverBlock,