fix(characters): keep rating stars visible beside the sticky name column on phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4d8d71a046
commit
4d4dc334e7
@ -17,6 +17,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
- **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before.
|
||||
|
||||
### Fixed
|
||||
- **Characters table on phones: the rating stars can be tapped again.** At 320-414 px the pinned checkbox, picture and name columns (328 px) covered the stars of the voice rating; on phones they now scroll with the table, the rating column is wide enough for five 44 px stars, and each star has a *Rate n of 5* label. Browser tests check at 320, 375 and 414 px that no star touches a pinned column, is hit-testable, is at least 44 px and that the page itself does not scroll sideways; the phone case of the profile/rating test is no longer skipped.
|
||||
- **Highlight stays in step through seeking, speed changes and background tabs.** The highlighted sentence is now read from the audio's own position on every animation frame (and immediately on seeking, seeked, rate change, play, pause and when the tab becomes visible) using a binary search over the sentence start times, so there is no cached position to go stale; a jump into a silent gap keeps the sentence just spoken, and the marked word follows the sentence's own start and end, weighted by word length. New browser tests cover seeking back and forward, 1.5x and 2x, speed changes and rapid repeated seeks.
|
||||
- **Audio and text now line up when the voice actually starts.** Measured on real rendered articles (an 8-sentence and a 71-sentence MP3): the recorded sentence boundaries are exact (no drift over 10 minutes, no encoder-delay offset: the MP3 carries a LAME/Info header, so browsers trim the encoder delay), but the voice engine pads every sentence with 0.4 to 0.9 s of silence before the first word, so the highlight lit up about 0.7 s early. New renders now record where speech begins in each sentence (`onsets` in `<id>.timings.json`), older audio is measured from the decoded MP3 on request (in memory, nothing is written), and `/timings` returns it as `speech` per sentence.
|
||||
- **The item pictures on the character concept sheet show only objects, never hands, feet or people.** The lower-right tiles are now asked for as "a high-quality concept art prop sheet featuring isolated character accessories" on a plain, neutral studio background, still life, strictly inanimate objects, with no people, hands, faces or body parts; the item name (German names included) and the book's world and style stay in the prompt. A body-part list was also added to the item tile's negative prompt, which like the rest only acts if the cfg is raised above 1.0. Existing sheets need to be regenerated.
|
||||
|
||||
@ -1034,7 +1034,7 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
const voicePrompt = _libStr(voiceMeta?.voice_design_prompt || sh.voice_design_prompt);
|
||||
const stars = voiceId
|
||||
? '<div class="lib-chars-tbl-rating" role="group" aria-label="Voice rating">' + [1, 2, 3, 4, 5].map(function (n) {
|
||||
return '<button type="button" class="lib-chars-tbl-rating-star' + (n <= voiceRating ? ' is-on' : '') + '" data-voice-id="' + escHtml(voiceId) + '" data-rating="' + n + '" title="Rate ' + n + ' of 5"><span class="mdi mdi-star"></span></button>';
|
||||
return '<button type="button" class="lib-chars-tbl-rating-star' + (n <= voiceRating ? ' is-on' : '') + '" data-voice-id="' + escHtml(voiceId) + '" data-rating="' + n + '" title="Rate ' + n + ' of 5" aria-label="Rate ' + n + ' of 5"><span class="mdi mdi-star"></span></button>';
|
||||
}).join('') + '</div>'
|
||||
: '<span class="lib-chars-tbl-dash">—</span>';
|
||||
const voiceLang = _charLangLabel(rec);
|
||||
|
||||
@ -684,6 +684,17 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
border:0; background:transparent; padding:1px; color:#d1d5db; cursor:pointer; line-height:1;
|
||||
}
|
||||
.lib-chars-tbl-rating-star:hover, .lib-chars-tbl-rating-star.is-on { color:#f59e0b; }
|
||||
/* Phones: the pinned checkbox/avatar/name block (328px) would cover the rating stars on a
|
||||
320-414px screen, so it scrolls with the table there; the stars get 44px touch targets. */
|
||||
@media (max-width: 640px) {
|
||||
.lib-chars-tbl th:nth-child(1), .lib-chars-tbl td:nth-child(1),
|
||||
.lib-chars-tbl th:nth-child(2), .lib-chars-tbl td:nth-child(2),
|
||||
.lib-chars-tbl th:nth-child(3), .lib-chars-tbl td:nth-child(3) { position: static; box-shadow: none; }
|
||||
.lib-chars-tbl thead th:nth-child(n+1) { position: sticky; top: 0; left: auto; }
|
||||
.lib-chars-tbl { min-width:2580px; }
|
||||
.lib-chars-tbl col:nth-child(11) { width:232px !important; }
|
||||
.lib-chars-tbl-rating-star { min-width:44px; min-height:44px; padding:0; display:inline-flex; align-items:center; justify-content:center; font-size:18px; }
|
||||
}
|
||||
.lib-chars-tbl-prompt {
|
||||
color:var(--subtext); font-size:11.5px; font-style:italic;
|
||||
white-space:normal; overflow-wrap:anywhere; word-break:normal; line-height:1.4;
|
||||
|
||||
@ -13,9 +13,60 @@ const CHARACTERS = [
|
||||
{ id: 'c-bob', book: 'Smoke Book', name: 'Bob', tags: '', voice: 'de_bob', sheet: { gender: 'M', age: '42', profession: 'captain', physical: 'tall' } },
|
||||
];
|
||||
|
||||
test('Characters / Cast supports profile sorting, column visibility, and voice rating', async ({ page }, testInfo) => {
|
||||
test.fixme(testInfo.project.name === 'iphone',
|
||||
'Known, predates 2026-09-29: at phone width the sticky name cell covers the rating stars, so they cannot be tapped (TODO.md, characters table on phones)');
|
||||
async function openCharactersTable(page) {
|
||||
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } }));
|
||||
await page.route('**/api/voices*', route => route.fulfill({ json: VOICES }));
|
||||
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
||||
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
||||
await page.evaluate(() => { localStorage.setItem('ttsvc_libchars_view', 'table'); return window.libraryRenderCharacters(); });
|
||||
await page.waitForFunction(() => {
|
||||
const t = document.querySelector('#lib-chars-list .lib-chars-tbl');
|
||||
return !!t && getComputedStyle(t).display !== 'none' && t.offsetParent !== null;
|
||||
}, null, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
for (const width of [320, 375, 414]) {
|
||||
test(`Characters table: rating stars are not covered by the sticky name column at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await openCharactersTable(page);
|
||||
const star = page.locator('#lib-chars-list .lib-chars-tbl-rating-star[data-voice-id="de_alice"]').first();
|
||||
await star.scrollIntoViewIfNeeded();
|
||||
const r = await page.evaluate(() => {
|
||||
const stars = [...document.querySelectorAll('#lib-chars-list tr.lib-chars-tbl-row:nth-child(2) .lib-chars-tbl-rating-star')];
|
||||
const rowCells = [...document.querySelectorAll('#lib-chars-list tbody tr')].flatMap(tr => [...tr.querySelectorAll('td.lib-chars-tbl-name, td:nth-child(1), td:nth-child(2)')]);
|
||||
const rects = el => { const b = el.getBoundingClientRect(); return { l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height }; };
|
||||
const wrap = document.querySelector('#lib-chars-list .lib-chars-tbl-wrap');
|
||||
wrap.scrollLeft = wrap.scrollWidth; // worst case for a pinned column
|
||||
const sticky = rowCells.filter(c => getComputedStyle(c).position === 'sticky').map(rects);
|
||||
const out = stars.map(s => {
|
||||
s.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||||
const b = rects(s), hit = document.elementFromPoint(b.l + b.w / 2, b.t + b.h / 2);
|
||||
return { b, hit: s === hit || s.contains(hit) };
|
||||
});
|
||||
const stickyNow = rowCells.filter(c => getComputedStyle(c).position === 'sticky').map(rects);
|
||||
return { out, stickyNow, sticky, docScroll: document.documentElement.scrollWidth - document.documentElement.clientWidth, label: stars.every(s => s.getAttribute('aria-label')) };
|
||||
});
|
||||
expect(r.out).toHaveLength(5);
|
||||
for (const s of r.out) {
|
||||
expect(s.hit).toBe(true);
|
||||
expect(s.b.w).toBeGreaterThanOrEqual(44);
|
||||
expect(s.b.h).toBeGreaterThanOrEqual(44);
|
||||
for (const c of r.stickyNow) expect(s.b.l >= c.r || s.b.r <= c.l || s.b.b <= c.t || s.b.t >= c.b).toBe(true);
|
||||
}
|
||||
expect(r.label).toBe(true);
|
||||
expect(r.docScroll).toBeLessThanOrEqual(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('Characters / Cast supports profile sorting, column visibility, and voice rating', async ({ page }) => {
|
||||
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } }));
|
||||
await page.route('**/api/characters/visual-pair-review', route => route.fulfill({ json: { ok: true, checked: 2, issues: [] } }));
|
||||
await page.route('**/api/voices*', route => route.fulfill({ json: VOICES }));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user