feat: integrate n+1 target highlighting

- Merge feature branch changes for n+1 target-only highlight flow

- Extend merged token model and token-merger to mark exactly-one unknown targets

- Thread n+1 candidate metadata through tokenizer and config systems

- Update subtitle renderer/state to route configured colors and new token class

- Resolve merge conflicts in core service tests, including subtitle and subsync behavior
This commit is contained in:
2026-02-15 02:36:48 -08:00
parent 88099e2ffa
commit 3a27c026b6
16 changed files with 494 additions and 66 deletions

View File

@@ -69,6 +69,9 @@ export type RendererState = {
lastHoverSelectionKey: string;
lastHoverSelectionNode: Text | null;
knownWordColor: string;
nPlusOneColor: string;
keybindingsMap: Map<string, (string | number)[]>;
chordPending: boolean;
chordTimeout: ReturnType<typeof setTimeout> | null;
@@ -125,6 +128,9 @@ export function createRendererState(): RendererState {
lastHoverSelectionKey: "",
lastHoverSelectionNode: null,
knownWordColor: "#a6da95",
nPlusOneColor: "#c6a0f6",
keybindingsMap: new Map(),
chordPending: false,
chordTimeout: null,

View File

@@ -248,6 +248,8 @@ body {
font-size: 35px;
line-height: 1.5;
color: #cad3f5;
--subtitle-known-word-color: #a6da95;
--subtitle-n-plus-one-color: #c6a0f6;
text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8),
-1px -1px 2px rgba(0, 0, 0, 0.5);
@@ -285,10 +287,15 @@ body.settings-modal-open #subtitleContainer {
}
#subtitleRoot .word.word-known {
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 {
color: var(--subtitle-n-plus-one-color, #c6a0f6);
text-shadow: 0 0 6px rgba(198, 160, 246, 0.35);
}
#subtitleRoot .word:hover {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;

View File

@@ -23,13 +23,13 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
if (surface.includes("\n")) {
const parts = surface.split("\n");
for (let i = 0; i < parts.length; i += 1) {
if (parts[i]) {
const span = document.createElement("span");
span.className = token.isKnown ? "word word-known" : "word";
span.textContent = parts[i];
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
for (let i = 0; i < parts.length; i += 1) {
if (parts[i]) {
const span = document.createElement("span");
span.className = computeWordClass(token);
span.textContent = parts[i];
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span);
}
if (i < parts.length - 1) {
@@ -40,7 +40,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
}
const span = document.createElement("span");
span.className = token.isKnown ? "word word-known" : "word";
span.className = computeWordClass(token);
span.textContent = surface;
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
@@ -50,6 +50,18 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
root.appendChild(fragment);
}
function computeWordClass(token: MergedToken): string {
if (token.isNPlusOneTarget) {
return "word word-n-plus-one";
}
if (token.isKnown) {
return "word word-known";
}
return "word";
}
function renderCharacterLevel(root: HTMLElement, text: string): void {
const fragment = document.createDocumentFragment();
@@ -173,6 +185,19 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.dom.subtitleContainer.style.background = style.backgroundColor;
}
const knownWordColor =
style.knownWordColor ?? ctx.state.knownWordColor ?? "#a6da95";
const nPlusOneColor =
style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? "#c6a0f6";
ctx.state.knownWordColor = knownWordColor;
ctx.state.nPlusOneColor = nPlusOneColor;
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-known-word-color",
knownWordColor,
);
ctx.dom.subtitleRoot.style.setProperty("--subtitle-n-plus-one-color", nPlusOneColor);
const secondaryStyle = style.secondary;
if (!secondaryStyle) return;