mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
Fix JLPT underline color drift and AniList skipped-threshold sync
- Replace JLPT `text-decoration` underlines with `border-bottom` so Chromium selection/hover cannot repaint them to another annotation's color - Lock JLPT underline color for combined annotation selectors (known, n+1, frequency) and character hover/selection states - Trigger AniList post-watch check on every mpv time-position update to catch skipped completion thresholds - Fall back to filename-parser season/episode when guessit omits them
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: TASK-325
|
||||
title: Keep JLPT underline color fixed with combined lookup annotations
|
||||
status: Done
|
||||
assignee:
|
||||
- '@Codex'
|
||||
created_date: '2026-05-04 00:25'
|
||||
updated_date: '2026-05-04 00:28'
|
||||
labels:
|
||||
- overlay
|
||||
- jlpt
|
||||
- renderer
|
||||
dependencies: []
|
||||
references:
|
||||
- TASK-318
|
||||
- TASK-308
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Yomitan lookup on a subtitle token that has a JLPT level plus another annotation such as frequency or known-word highlighting can make the JLPT underline take the other annotation color. The underline must always remain the token's JLPT level color; other annotation classes may still control text color.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 A JLPT token combined with frequency styling keeps its underline set to the configured JLPT level color during lookup/selection styling.
|
||||
- [x] #2 A JLPT token combined with known-word styling keeps its underline set to the configured JLPT level color during lookup/selection styling.
|
||||
- [x] #3 Regression coverage exercises combined JLPT plus non-JLPT annotation selectors, including character span selection/hover styling used by lookup.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add focused renderer CSS regression coverage for combined `word-jlpt-n*` plus known/frequency classes, including `.c::selection`/`.c:hover` lookup paths.
|
||||
2. Run `bun test src/renderer/subtitle-render.test.ts` and confirm the new assertion fails on the current CSS.
|
||||
3. Update `src/renderer/style.css` so JLPT decoration color is locked on the token and child character spans without changing text color priority for known/frequency/name/N+1 annotations.
|
||||
4. Re-run the focused renderer test, then run typecheck/changelog checks as scope requires.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added red/green renderer CSS regression for combined JLPT plus known/N+1/frequency annotation classes and character hover lookup paths. Current CSS failed before the lock selectors were added; focused test passes after the CSS change.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed JLPT underline color drift for tokens that also carry known-word, N+1, or frequency annotation classes. The renderer CSS now explicitly locks the underline decoration color for combined JLPT annotation selectors, hover, character hover, and selection states while preserving the existing text color priority for other annotations.
|
||||
|
||||
Added renderer regression coverage for combined JLPT plus non-JLPT annotation selectors and lookup character hover paths. Added a user-visible changelog fragment.
|
||||
|
||||
Checks: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`; `bun run typecheck`; `bun run format:check:src`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: TASK-326
|
||||
title: Fix AniList post-watch update after skipped completion threshold
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-05-04 00:33'
|
||||
labels:
|
||||
- anilist
|
||||
- bug
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
AniList episode progress should sync reliably when playback reaches or passes the watched trigger point, even if mpv progress events jump over the exact threshold. Investigate why a completed watched episode did not update AniList and fix the root cause for post-watch tracking.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 When playback moves from before the completion threshold to any later position at or beyond the threshold, AniList queues or sends the episode progress update once.
|
||||
- [ ] #2 If playback is already past the completion threshold and the update has not yet been recorded for the current media/episode, AniList still queues or sends the update.
|
||||
- [ ] #3 AniList progress updates remain deduplicated for the same media/episode watch completion.
|
||||
- [ ] #4 A regression test covers the skipped-threshold or already-past-threshold case.
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Overlay: Kept JLPT subtitle underlines on their JLPT color when lookup selection overlaps known-word or frequency annotation colors.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Anilist: Run post-watch progress checks on mpv time-position updates and fill missing `guessit` episode metadata from the filename parser so completed episodes are less likely to miss progress sync.
|
||||
@@ -22,6 +22,32 @@ test('guessAnilistMediaInfo uses guessit output when available', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo fills missing guessit episode from filename parser', async () => {
|
||||
const result = await guessAnilistMediaInfo('/tmp/Guessit Title S01E09.mkv', null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
season: 1,
|
||||
episode: 9,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo parses Little Witch Academia release filename', async () => {
|
||||
const filename =
|
||||
'/tmp/Little Witch Academia (2017) - S01E02 - 002 - Papiliodia [Bluray-1080p][10bit][h265][AC3 2.0][JA].mkv';
|
||||
const result = await guessAnilistMediaInfo(filename, null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Little Witch Academia' }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Little Witch Academia',
|
||||
season: 1,
|
||||
episode: 2,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => {
|
||||
const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null, {
|
||||
runGuessit: async () => {
|
||||
@@ -54,7 +80,7 @@ test('guessAnilistMediaInfo uses basename for guessit input', async () => {
|
||||
]);
|
||||
assert.deepEqual(result, {
|
||||
title: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
season: null,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
});
|
||||
|
||||
@@ -236,12 +236,13 @@ export async function guessAnilistMediaInfo(
|
||||
const season = firstPositiveInteger(parsed.season);
|
||||
const year = firstYear(parsed.year);
|
||||
if (title) {
|
||||
const fallback = parseMediaInfo(target);
|
||||
return {
|
||||
title: buildGuessitTitle(title, alternativeTitle),
|
||||
...(alternativeTitle ? { alternativeTitle } : {}),
|
||||
...(year ? { year } : {}),
|
||||
season,
|
||||
episode,
|
||||
season: season ?? fallback.season,
|
||||
episode: episode ?? fallback.episode,
|
||||
source: 'guessit',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,6 +199,10 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
||||
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
},
|
||||
logError: () => calls.push('post-watch-error'),
|
||||
});
|
||||
const pauseHandler = createHandleMpvPauseChangeHandler({
|
||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||
@@ -212,6 +216,7 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
||||
'time:12.5',
|
||||
'progress:normal',
|
||||
'presence',
|
||||
'post-watch',
|
||||
'pause:yes',
|
||||
'progress:force',
|
||||
'presence',
|
||||
|
||||
@@ -105,12 +105,17 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
return ({ time }: { time: number }): void => {
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.refreshDiscordPresence();
|
||||
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
|
||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||
});
|
||||
deps.onTimePosUpdate?.(time);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
||||
});
|
||||
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
||||
|
||||
+65
-25
@@ -794,11 +794,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
|
||||
@@ -806,11 +803,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
|
||||
@@ -818,11 +812,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
|
||||
@@ -830,11 +821,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
|
||||
@@ -842,11 +830,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
|
||||
@@ -997,29 +982,84 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-5-color, #8aadf4) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n1:hover,
|
||||
#subtitleRoot .word.word-jlpt-n1 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n1::selection,
|
||||
#subtitleRoot .word.word-jlpt-n1 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n2:hover,
|
||||
#subtitleRoot .word.word-jlpt-n2 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n2::selection,
|
||||
#subtitleRoot .word.word-jlpt-n2 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n3:hover,
|
||||
#subtitleRoot .word.word-jlpt-n3 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n3::selection,
|
||||
#subtitleRoot .word.word-jlpt-n3 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n4:hover,
|
||||
#subtitleRoot .word.word-jlpt-n4 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n4::selection,
|
||||
#subtitleRoot .word.word-jlpt-n4 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n5:hover,
|
||||
#subtitleRoot .word.word-jlpt-n5 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n5::selection,
|
||||
#subtitleRoot .word.word-jlpt-n5 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot
|
||||
|
||||
@@ -220,8 +220,20 @@ function normalizeCssSelector(selector: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildJlptUnderlineSelector(level: number): string {
|
||||
return `#subtitleRoot .word.word-jlpt-n${level}`;
|
||||
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', () => {
|
||||
@@ -887,20 +899,31 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
const cssText = fs.readFileSync(cssPath, 'utf-8');
|
||||
|
||||
for (let level = 1; level <= 5; level += 1) {
|
||||
const block = extractClassBlock(cssText, buildJlptUnderlineSelector(level));
|
||||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||||
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
|
||||
assert.doesNotMatch(block, /-webkit-text-fill-color\s*:/);
|
||||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||||
const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
|
||||
// JLPT tagging must never recolor the token text — other annotations own
|
||||
// text color. JLPT also must not use `text-decoration: underline`,
|
||||
// because Chromium repaints text-decoration during ::selection and the
|
||||
// underline would adopt the other annotation's color during a Yomitan
|
||||
// lookup. The underline is drawn by `border-bottom`, which is unaffected
|
||||
// by ::selection and stays locked on the JLPT level color regardless of
|
||||
// popup/selection state.
|
||||
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
|
||||
assert.doesNotMatch(plainJlptBlock, /text-decoration-line:\s*underline;/);
|
||||
assert.match(
|
||||
block,
|
||||
new RegExp(`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`),
|
||||
plainJlptBlock,
|
||||
new RegExp(`border-bottom:\\s*2px\\s+solid\\s+var\\(--subtitle-jlpt-n${level}-color,`),
|
||||
`JLPT level must paint a permanent 2px border-bottom in the level color`,
|
||||
);
|
||||
|
||||
// JLPT tagging must communicate level *only* via the underline; it must
|
||||
// never recolor the token text. Other annotations (known, n+1, frequency,
|
||||
// name match) are responsible for token text color.
|
||||
const jlptOnlyColorBlock = extractClassBlock(cssText, buildJlptColorSelector(level));
|
||||
assert.equal(
|
||||
jlptOnlyColorBlock,
|
||||
'',
|
||||
`word-jlpt-n${level} (without other annotations) must not set text color — JLPT only paints the underline`,
|
||||
);
|
||||
assert.doesNotMatch(block, /border-bottom\s*:/);
|
||||
assert.doesNotMatch(block, /padding-bottom\s*:/);
|
||||
assert.doesNotMatch(block, /box-decoration-break\s*:/);
|
||||
assert.doesNotMatch(block, /-webkit-box-decoration-break\s*:/);
|
||||
assert.doesNotMatch(block, /text-shadow\s*:/);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
@@ -1077,6 +1100,39 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
'i',
|
||||
),
|
||||
);
|
||||
|
||||
for (const annotationClass of [
|
||||
'word-known',
|
||||
'word-n-plus-one',
|
||||
'word-frequency-single',
|
||||
'word-frequency-band-2',
|
||||
]) {
|
||||
const combinedAnnotationBlock = extractClassBlock(
|
||||
cssText,
|
||||
`#subtitleRoot .word.word-jlpt-n${level}.${annotationClass}`,
|
||||
);
|
||||
assert.match(
|
||||
combinedAnnotationBlock,
|
||||
new RegExp(
|
||||
`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,\\s*#[0-9a-f]{6}\\)\\s*!important;`,
|
||||
'i',
|
||||
),
|
||||
`combined JLPT ${annotationClass} selector should lock underline color`,
|
||||
);
|
||||
}
|
||||
|
||||
const jlptCharHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
`#subtitleRoot .word.word-jlpt-n${level} .c:hover`,
|
||||
);
|
||||
assert.match(
|
||||
jlptCharHoverBlock,
|
||||
new RegExp(
|
||||
`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,\\s*#[0-9a-f]{6}\\)\\s*!important;`,
|
||||
'i',
|
||||
),
|
||||
'JLPT character hover selector should lock underline color',
|
||||
);
|
||||
}
|
||||
|
||||
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
|
||||
|
||||
Reference in New Issue
Block a user