feat(reader): surface brightness on desktop, not just the mobile sheet #17
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.89.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.89.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ async function mockReaderApis(page: Page) {
|
|||||||
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
// Anonymous reader: read-progress reads/writes are unauthenticated. Without
|
||||||
|
// this the GET/PUT fall through to the (absent) backend and stall the load.
|
||||||
|
await page.route('**/api/v1/me/read-progress/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -126,6 +135,7 @@ test.beforeEach(async ({ context }) => {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem('mangalord-reader-mode');
|
localStorage.removeItem('mangalord-reader-mode');
|
||||||
localStorage.removeItem('mangalord-reader-gap');
|
localStorage.removeItem('mangalord-reader-gap');
|
||||||
|
localStorage.removeItem('mangalord-reader-brightness');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — about:blank doesn't expose localStorage yet.
|
// ignore — about:blank doesn't expose localStorage yet.
|
||||||
}
|
}
|
||||||
@@ -181,6 +191,64 @@ test('gap select updates the inline gap on the continuous container', async ({ p
|
|||||||
await expect(container).toHaveAttribute('style', /gap:\s*64px/);
|
await expect(container).toHaveAttribute('style', /gap:\s*64px/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('desktop reader nav exposes a brightness control that dims and undims the reader', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// The brightness slider used to live only in the mobile settings sheet,
|
||||||
|
// so desktop users couldn't dim — and could be stuck dimmed by a value a
|
||||||
|
// phone had persisted. The desktop nav now carries its own slider.
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
|
|
||||||
|
const slider = page.getByTestId('reader-brightness-desktop');
|
||||||
|
await expect(slider).toBeVisible();
|
||||||
|
|
||||||
|
const dim = () =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
document.documentElement.style.getPropertyValue('--reader-dim')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default brightness is full → no dim overlay.
|
||||||
|
await expect.poll(async () => Number(await dim())).toBe(0);
|
||||||
|
|
||||||
|
// Dimming raises the overlay opacity…
|
||||||
|
await slider.fill('0.3');
|
||||||
|
await expect.poll(async () => Number(await dim())).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// …and restoring it clears the dim, so a desktop user is never stuck.
|
||||||
|
await slider.fill('1');
|
||||||
|
await expect.poll(async () => Number(await dim())).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a brightness value persisted by a phone is recoverable on desktop', async ({
|
||||||
|
page,
|
||||||
|
context
|
||||||
|
}) => {
|
||||||
|
// Seed a dimmed value as if set on mobile, then open the reader on a
|
||||||
|
// desktop viewport: the dim applies and the desktop control reflects it.
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('mangalord-reader-brightness', '0.3');
|
||||||
|
} catch {
|
||||||
|
// about:blank — ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
|
|
||||||
|
const slider = page.getByTestId('reader-brightness-desktop');
|
||||||
|
await expect(slider).toHaveValue('0.3');
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
Number(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.documentElement.style.getPropertyValue('--reader-dim')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('reader-mode preference set on one page is honored when the reader opens', async ({
|
test('reader-mode preference set on one page is honored when the reader opens', async ({
|
||||||
page,
|
page,
|
||||||
context
|
context
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.88.0",
|
"version": "0.89.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1094,6 +1094,28 @@
|
|||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Desktop brightness. On mobile this control lives in the reader
|
||||||
|
settings sheet; desktop has no sheet, so it sits inline here.
|
||||||
|
Shares the `brightness` state and the same `--reader-dim`
|
||||||
|
effect, so a value set on either form factor stays in sync. -->
|
||||||
|
<label class="brightness-field desktop-control">
|
||||||
|
<Sun size={16} aria-hidden="true" />
|
||||||
|
<span class="visually-hidden">Brightness</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="brightness-slider"
|
||||||
|
min="0.3"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={brightness}
|
||||||
|
oninput={(e) => {
|
||||||
|
brightness = Number((e.currentTarget as HTMLInputElement).value);
|
||||||
|
}}
|
||||||
|
aria-label="Brightness"
|
||||||
|
data-testid="reader-brightness-desktop"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="reader-settings-btn mobile-control"
|
class="reader-settings-btn mobile-control"
|
||||||
@@ -2181,6 +2203,20 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline desktop brightness control in the reader nav. The slider is
|
||||||
|
full-width inside the mobile settings sheet; here it's capped so it
|
||||||
|
sits neatly beside the mode toggle. */
|
||||||
|
.brightness-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brightness-field .brightness-slider {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
/* Reader-nav sits at the viewport top — the global mobile
|
/* Reader-nav sits at the viewport top — the global mobile
|
||||||
chrome is hidden on this route so there's no app bar above
|
chrome is hidden on this route so there's no app bar above
|
||||||
|
|||||||
Reference in New Issue
Block a user