[verified] feat: add immersive themes and focus controls
This commit is contained in:
@@ -16,11 +16,13 @@ A browser-based Pomodoro timer and clock built with React, TypeScript, and Vite.
|
|||||||
|
|
||||||
- Custom focus, short-break, and long-break durations
|
- Custom focus, short-break, and long-break durations
|
||||||
- Automatic focus-to-break sequencing, enabled by default with an opt-out setting
|
- Automatic focus-to-break sequencing, enabled by default with an opt-out setting
|
||||||
- Six built-in backgrounds: three gradients and three local illustrated scenes
|
- Ten built-in themes, including minimalist black/white and animated lava-style backgrounds
|
||||||
- Adjustable background overlay and text color
|
- Adjustable background overlay and a separate persistent clock-color override
|
||||||
- Four clock and timer font styles
|
- Four clock and timer font styles
|
||||||
- Live customization preview with one-click reset
|
- Live customization preview with one-click reset
|
||||||
- Automatic settings persistence in IndexedDB
|
- Automatic settings persistence in IndexedDB
|
||||||
|
- Slowly pulsing indicator for the active focus session
|
||||||
|
- Browser fullscreen control and a distraction-free focus mode
|
||||||
|
|
||||||
All preferences stay in the browser. No account or backend is required.
|
All preferences stay in the browser. No account or backend is required.
|
||||||
|
|
||||||
|
|||||||
+327
-5
@@ -2,6 +2,7 @@
|
|||||||
--accent: #8d6bff;
|
--accent: #8d6bff;
|
||||||
--accent-strong: #7250ed;
|
--accent-strong: #7250ed;
|
||||||
--glow: rgba(141, 107, 255, 0.38);
|
--glow: rgba(141, 107, 255, 0.38);
|
||||||
|
--primary-text: #fff;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
@@ -40,12 +41,93 @@
|
|||||||
--accent: #42d6ad;
|
--accent: #42d6ad;
|
||||||
--accent-strong: #20ad87;
|
--accent-strong: #20ad87;
|
||||||
--glow: rgba(66, 214, 173, 0.32);
|
--glow: rgba(66, 214, 173, 0.32);
|
||||||
|
--primary-text: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.phase-longBreak {
|
.app.phase-longBreak {
|
||||||
--accent: #ff9d66;
|
--accent: #ff9d66;
|
||||||
--accent-strong: #ef754c;
|
--accent-strong: #ef754c;
|
||||||
--glow: rgba(255, 157, 102, 0.32);
|
--glow: rgba(255, 157, 102, 0.32);
|
||||||
|
--primary-text: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-light {
|
||||||
|
--accent: #6850c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-light.phase-shortBreak {
|
||||||
|
--accent: #176b58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-light.phase-longBreak {
|
||||||
|
--accent: #a84524;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-black .orb,
|
||||||
|
.app.theme-white .orb {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-light::after {
|
||||||
|
background: rgb(0 0 0 / var(--light-overlay-opacity, 0.07));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .timer-card {
|
||||||
|
border-color: rgba(17, 17, 17, 0.12);
|
||||||
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.42));
|
||||||
|
box-shadow: 0 28px 90px rgba(35, 35, 30, 0.14), inset 0 1px rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .brand-mark,
|
||||||
|
.theme-light .customize-button,
|
||||||
|
.theme-light .fullscreen-button,
|
||||||
|
.theme-light .focus-mode-button,
|
||||||
|
.theme-light .focus-mode-exit,
|
||||||
|
.theme-light .clock {
|
||||||
|
border-color: rgba(17, 17, 17, 0.13);
|
||||||
|
background: rgba(255, 255, 255, 0.54);
|
||||||
|
box-shadow: inset 0 1px rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .supporting-copy,
|
||||||
|
.theme-light .session-row,
|
||||||
|
.theme-light .status-line,
|
||||||
|
.theme-light.app > footer,
|
||||||
|
.theme-light .standard-interface > footer {
|
||||||
|
color: rgba(17, 17, 17, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .topbar :is(button, a):focus-visible,
|
||||||
|
.theme-light .timer-card button:focus-visible,
|
||||||
|
.theme-light .focus-mode-button:focus-visible,
|
||||||
|
.theme-light .focus-mode-exit:focus-visible {
|
||||||
|
outline-color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .phase-switcher {
|
||||||
|
border-color: rgba(17, 17, 17, 0.1);
|
||||||
|
background: rgba(17, 17, 17, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .phase-switcher button {
|
||||||
|
color: #5f5f5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .phase-switcher button:hover,
|
||||||
|
.theme-light .phase-switcher button.active {
|
||||||
|
color: #111;
|
||||||
|
background: rgba(17, 17, 17, 0.09);
|
||||||
|
box-shadow: inset 0 1px rgba(255, 255, 255, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .session-dots span {
|
||||||
|
background: rgba(17, 17, 17, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .secondary-action {
|
||||||
|
border-color: rgba(17, 17, 17, 0.13);
|
||||||
|
color: #171717;
|
||||||
|
background: rgba(17, 17, 17, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orb {
|
.orb {
|
||||||
@@ -72,6 +154,26 @@
|
|||||||
transition: background 400ms ease;
|
transition: background 400ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app.theme-motion-lava::before,
|
||||||
|
.app.theme-motion-liquid::before {
|
||||||
|
background-size: 180% 180%;
|
||||||
|
will-change: background-position, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-motion-lava::before {
|
||||||
|
animation: lava-drift 18s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-motion-liquid::before {
|
||||||
|
animation: liquid-float 22s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.theme-motion-lava .orb,
|
||||||
|
.app.theme-motion-liquid .orb {
|
||||||
|
opacity: 0.38;
|
||||||
|
animation: orb-breathe 14s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -85,6 +187,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.customize-button,
|
.customize-button,
|
||||||
|
.fullscreen-button,
|
||||||
|
.focus-mode-button,
|
||||||
|
.focus-mode-exit,
|
||||||
.icon-button,
|
.icon-button,
|
||||||
.reset-settings {
|
.reset-settings {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -109,6 +214,102 @@
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fullscreen-button {
|
||||||
|
display: grid;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(13, 12, 30, 0.25);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-button:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.standard-interface {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-button {
|
||||||
|
position: fixed;
|
||||||
|
right: clamp(18px, 3vw, 34px);
|
||||||
|
bottom: clamp(18px, 3vw, 30px);
|
||||||
|
z-index: 3;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(13, 12, 30, 0.36);
|
||||||
|
box-shadow: 0 12px 32px rgba(6, 5, 22, 0.18);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-button:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-view {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-time {
|
||||||
|
color: var(--clock-color, currentColor);
|
||||||
|
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||||
|
font-size: clamp(3.8rem, 16vw, 11rem);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.075em;
|
||||||
|
line-height: 0.92;
|
||||||
|
text-shadow: 0 18px 60px rgba(7, 5, 25, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-exit {
|
||||||
|
position: fixed;
|
||||||
|
right: clamp(18px, 3vw, 34px);
|
||||||
|
bottom: clamp(18px, 3vw, 30px);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: inherit;
|
||||||
|
background: rgba(13, 12, 30, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(8px);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
transition: opacity 420ms ease, transform 420ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-exit.visible,
|
||||||
|
.focus-mode-exit:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-exit:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -136,7 +337,7 @@
|
|||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: inherit;
|
color: var(--clock-color, currentColor);
|
||||||
background: rgba(13, 12, 30, 0.25);
|
background: rgba(13, 12, 30, 0.25);
|
||||||
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -235,9 +436,16 @@ h1 {
|
|||||||
box-shadow: 0 0 10px var(--glow);
|
box-shadow: 0 0 10px var(--glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-dots span.current {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 7px var(--glow);
|
||||||
|
animation: session-glow 3.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.countdown {
|
.countdown {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 6px 0 18px;
|
margin: 6px 0 18px;
|
||||||
|
color: var(--clock-color, currentColor);
|
||||||
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||||
font-size: clamp(5rem, 15vw, 8.8rem);
|
font-size: clamp(5rem, 15vw, 8.8rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -268,6 +476,7 @@ h1 {
|
|||||||
|
|
||||||
.primary-action {
|
.primary-action {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
color: var(--primary-text);
|
||||||
background: var(--accent-strong);
|
background: var(--accent-strong);
|
||||||
box-shadow: 0 12px 32px var(--glow);
|
box-shadow: 0 12px 32px var(--glow);
|
||||||
}
|
}
|
||||||
@@ -300,7 +509,8 @@ h1 {
|
|||||||
animation: pulse 1.6s ease-in-out infinite;
|
animation: pulse 1.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app > footer {
|
.app > footer,
|
||||||
|
.standard-interface > footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -547,9 +757,53 @@ h1 {
|
|||||||
accent-color: #8d6bff;
|
accent-color: #8d6bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-control {
|
.clock-color-control {
|
||||||
grid-template-columns: 1fr auto;
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-color-button,
|
||||||
|
.theme-color-button {
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-color-button {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 13px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-color-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-color-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-color-options label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-input-wrap {
|
.color-input-wrap {
|
||||||
@@ -573,6 +827,24 @@ h1 {
|
|||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-color-button {
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-color-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-color-button:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-footer {
|
.settings-footer {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
}
|
}
|
||||||
@@ -593,6 +865,28 @@ h1 {
|
|||||||
50% { opacity: 0.35; }
|
50% { opacity: 0.35; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes session-glow {
|
||||||
|
0%, 100% { opacity: 0.42; box-shadow: 0 0 5px transparent; }
|
||||||
|
50% { opacity: 1; box-shadow: 0 0 14px var(--glow); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lava-drift {
|
||||||
|
0% { background-position: 0% 20%; transform: scale(1); }
|
||||||
|
50% { background-position: 85% 70%; transform: scale(1.08); }
|
||||||
|
100% { background-position: 25% 100%; transform: scale(1.03); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes liquid-float {
|
||||||
|
0% { background-position: 10% 90%; transform: scale(1.02) rotate(0deg); }
|
||||||
|
50% { background-position: 90% 15%; transform: scale(1.1) rotate(1deg); }
|
||||||
|
100% { background-position: 35% 55%; transform: scale(1.04) rotate(-1deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orb-breathe {
|
||||||
|
0% { transform: translate3d(-4%, 2%, 0) scale(0.9); }
|
||||||
|
100% { transform: translate3d(8%, -7%, 0) scale(1.18); }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
}
|
}
|
||||||
@@ -631,6 +925,15 @@ h1 {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clock-color-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-color-actions {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.timer-card {
|
.timer-card {
|
||||||
padding: 28px 18px;
|
padding: 28px 18px;
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
@@ -655,8 +958,27 @@ h1 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app > footer {
|
.app > footer,
|
||||||
|
.standard-interface > footer {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
gap: 0.35rem 0.65rem;
|
||||||
|
padding-bottom: 4.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.brand > span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.topbar-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
padding-inline: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+206
-4
@@ -59,17 +59,130 @@ describe('Pomodoro app', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
|
Reflect.deleteProperty(document, 'fullscreenElement')
|
||||||
|
Reflect.deleteProperty(document, 'exitFullscreen')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a live clock and the initial focus timer', () => {
|
it('shows a live clock and the initial focus timer', () => {
|
||||||
renderApp()
|
renderApp()
|
||||||
|
|
||||||
expect(screen.getByLabelText('Current time')).toHaveTextContent('10:08')
|
expect(screen.getByLabelText('Current time')).toHaveTextContent('10:08')
|
||||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00')
|
const countdown = screen.getByRole('timer', { name: 'Time remaining' })
|
||||||
|
expect(countdown).toHaveTextContent('25:00')
|
||||||
|
expect(countdown).toHaveAttribute('aria-live', 'off')
|
||||||
|
expect(countdown.closest('section')).not.toHaveAttribute('aria-live')
|
||||||
|
expect(screen.queryByRole('status', { name: 'Time remaining' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Focus session ready when you are')
|
||||||
expect(screen.getByText('Focus session')).toBeInTheDocument()
|
expect(screen.getByText('Focus session')).toBeInTheDocument()
|
||||||
expect(screen.getByText('0 / 4 sessions')).toBeInTheDocument()
|
expect(screen.getByText('0 / 4 sessions')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('enters and exits fullscreen from the top-right control', async () => {
|
||||||
|
await renderHydratedApp()
|
||||||
|
const app = screen.getByTestId('app')
|
||||||
|
let fullscreenElement: Element | null = null
|
||||||
|
const requestFullscreen = vi.fn(async () => {
|
||||||
|
fullscreenElement = app
|
||||||
|
document.dispatchEvent(new Event('fullscreenchange'))
|
||||||
|
})
|
||||||
|
const exitFullscreen = vi.fn(async () => {
|
||||||
|
fullscreenElement = null
|
||||||
|
document.dispatchEvent(new Event('fullscreenchange'))
|
||||||
|
})
|
||||||
|
Object.defineProperty(app, 'requestFullscreen', { configurable: true, value: requestFullscreen })
|
||||||
|
Object.defineProperty(document, 'fullscreenElement', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => fullscreenElement,
|
||||||
|
})
|
||||||
|
Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Enter fullscreen' }))
|
||||||
|
await act(async () => Promise.resolve())
|
||||||
|
expect(requestFullscreen).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Exit fullscreen' }))
|
||||||
|
await act(async () => Promise.resolve())
|
||||||
|
expect(exitFullscreen).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByRole('button', { name: 'Enter fullscreen' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps fullscreen state synchronized when exiting fullscreen is rejected', async () => {
|
||||||
|
await renderHydratedApp()
|
||||||
|
const app = screen.getByTestId('app')
|
||||||
|
Object.defineProperty(document, 'fullscreenElement', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => app,
|
||||||
|
})
|
||||||
|
Object.defineProperty(document, 'exitFullscreen', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(async () => {
|
||||||
|
throw new Error('Fullscreen exit denied')
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
fireEvent(document, new Event('fullscreenchange'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Exit fullscreen' }))
|
||||||
|
await act(async () => Promise.resolve())
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only a centered clock with seconds in idle focus mode', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-07-23T10:08:09'))
|
||||||
|
await renderHydratedApp()
|
||||||
|
|
||||||
|
const focusModeTrigger = screen.getByRole('button', { name: 'Enter focus mode' })
|
||||||
|
fireEvent.click(focusModeTrigger)
|
||||||
|
expect(screen.getByLabelText('Current time with seconds')).toHaveTextContent('10:08:09')
|
||||||
|
expect(screen.queryByRole('button', { name: 'Customize' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText('Time remaining')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const exitButton = screen.getByRole('button', { name: 'Exit focus mode' })
|
||||||
|
expect(exitButton).toHaveFocus()
|
||||||
|
expect(exitButton.closest('section')).not.toHaveAttribute('aria-live')
|
||||||
|
expect(exitButton).toHaveClass('visible')
|
||||||
|
act(() => vi.advanceTimersByTime(2_100))
|
||||||
|
expect(exitButton).not.toHaveClass('visible')
|
||||||
|
|
||||||
|
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
||||||
|
window.dispatchEvent(tabEvent)
|
||||||
|
expect(tabEvent.defaultPrevented).toBe(true)
|
||||||
|
expect(exitButton).toHaveFocus()
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByTestId('app'))
|
||||||
|
expect(exitButton).toHaveClass('visible')
|
||||||
|
fireEvent.click(exitButton)
|
||||||
|
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Enter focus mode' })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the enlarged countdown in focus mode once the timer has started', async () => {
|
||||||
|
await renderHydratedApp()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
||||||
|
const focusModeTrigger = screen.getByRole('button', { name: 'Enter focus mode' })
|
||||||
|
fireEvent.click(focusModeTrigger)
|
||||||
|
|
||||||
|
const focusCountdown = screen.getByRole('timer', { name: 'Focus time remaining' })
|
||||||
|
expect(focusCountdown).toHaveTextContent('25:00')
|
||||||
|
expect(focusCountdown).toHaveAttribute('aria-live', 'off')
|
||||||
|
expect(screen.queryByRole('status', { name: 'Focus time remaining' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText('Current time with seconds')).not.toBeInTheDocument()
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' })
|
||||||
|
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Enter focus mode' })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the active break phase in the focus-mode countdown', async () => {
|
||||||
|
await renderHydratedApp()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Short break' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Enter focus mode' }))
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Short break time remaining')).toHaveTextContent('05:00')
|
||||||
|
})
|
||||||
|
|
||||||
it('starts, pauses, resumes, and resets the countdown', async () => {
|
it('starts, pauses, resumes, and resets the countdown', async () => {
|
||||||
const sound = await renderHydratedApp()
|
const sound = await renderHydratedApp()
|
||||||
|
|
||||||
@@ -93,6 +206,16 @@ describe('Pomodoro app', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('slowly pulses the indicator for the active focus session', async () => {
|
||||||
|
await renderHydratedApp()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
||||||
|
|
||||||
|
const sessionDots = document.querySelectorAll('.session-dots span')
|
||||||
|
expect(sessionDots[0]).toHaveClass('current')
|
||||||
|
expect(sessionDots[1]).not.toHaveClass('current')
|
||||||
|
expect(appCss).toMatch(/\.session-dots span\.current[\s\S]*animation:\s*session-glow 3\.6s/)
|
||||||
|
})
|
||||||
|
|
||||||
it('announces completion and automatically starts the short break', async () => {
|
it('announces completion and automatically starts the short break', async () => {
|
||||||
const sound = await renderHydratedApp()
|
const sound = await renderHydratedApp()
|
||||||
|
|
||||||
@@ -104,6 +227,7 @@ describe('Pomodoro app', () => {
|
|||||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00')
|
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00')
|
||||||
expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument()
|
expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Short break timer is running')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('plays one completion sound when a delayed update spans multiple segments', async () => {
|
it('plays one completion sound when a delayed update spans multiple segments', async () => {
|
||||||
@@ -185,20 +309,73 @@ describe('Pomodoro app', () => {
|
|||||||
expect(store.settings?.autoStartNext).toBe(false)
|
expect(store.settings?.autoStartNext).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies background, overlay, font, and text color customization', async () => {
|
it('applies background, overlay, and font customization', async () => {
|
||||||
await renderHydratedApp()
|
await renderHydratedApp()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||||
fireEvent.click(screen.getByRole('radio', { name: 'Forest stillness' }))
|
fireEvent.click(screen.getByRole('radio', { name: 'Forest stillness' }))
|
||||||
fireEvent.change(screen.getByLabelText('Clock font'), { target: { value: 'serif' } })
|
fireEvent.change(screen.getByLabelText('Clock font'), { target: { value: 'serif' } })
|
||||||
fireEvent.change(screen.getByLabelText('Overlay darkness'), { target: { value: '0.4' } })
|
fireEvent.change(screen.getByLabelText('Overlay darkness'), { target: { value: '0.4' } })
|
||||||
fireEvent.change(screen.getByLabelText('Text color'), { target: { value: '#fff2d6' } })
|
|
||||||
|
|
||||||
const app = screen.getByTestId('app')
|
const app = screen.getByTestId('app')
|
||||||
expect(app.style.getPropertyValue('--background-image')).toContain('forest.svg')
|
expect(app.style.getPropertyValue('--background-image')).toContain('forest.svg')
|
||||||
expect(app.style.getPropertyValue('--overlay-opacity')).toBe('0.4')
|
expect(app.style.getPropertyValue('--overlay-opacity')).toBe('0.4')
|
||||||
expect(app.style.getPropertyValue('--app-font')).toContain('Georgia')
|
expect(app.style.getPropertyValue('--app-font')).toContain('Georgia')
|
||||||
expect(app).toHaveStyle({ color: '#fff2d6' })
|
})
|
||||||
|
|
||||||
|
it('applies minimalist black and white theme colors to the clocks', async () => {
|
||||||
|
await renderHydratedApp()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Minimal white' }))
|
||||||
|
const app = screen.getByTestId('app')
|
||||||
|
expect(app).toHaveClass('theme-light')
|
||||||
|
expect(app.style.getPropertyValue('--clock-color')).toBe('#111111')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Minimal black' }))
|
||||||
|
expect(app).toHaveClass('theme-dark')
|
||||||
|
expect(app.style.getPropertyValue('--clock-color')).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers smoothly animated lava-style backgrounds', async () => {
|
||||||
|
await renderHydratedApp()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Lava bloom' }))
|
||||||
|
const app = screen.getByTestId('app')
|
||||||
|
expect(app).toHaveClass('theme-motion-lava')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Liquid aurora' }))
|
||||||
|
expect(app).toHaveClass('theme-motion-liquid')
|
||||||
|
expect(appCss).toMatch(/@keyframes lava-drift/)
|
||||||
|
expect(appCss).toMatch(/@keyframes liquid-float/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a custom clock color behind a separate control when themes change', async () => {
|
||||||
|
const store = new MemorySettingsStore()
|
||||||
|
await renderHydratedApp(store)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('Custom clock color')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Clock color' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('Custom clock color'), {
|
||||||
|
target: { value: '#24cc88' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = screen.getByTestId('app')
|
||||||
|
expect(app.style.getPropertyValue('--clock-color')).toBe('#24cc88')
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Minimal white' }))
|
||||||
|
expect(app.style.getPropertyValue('--clock-color')).toBe('#24cc88')
|
||||||
|
expect(store.settings?.useThemeClockColor).toBe(false)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Use theme color' }))
|
||||||
|
expect(app.style.getPropertyValue('--clock-color')).toBe('#111111')
|
||||||
|
expect(store.settings?.textColor).toBe('#24cc88')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Use custom color' }))
|
||||||
|
expect(app.style.getPropertyValue('--clock-color')).toBe('#24cc88')
|
||||||
|
expect(store.settings?.useThemeClockColor).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores and persists customization through the settings store', async () => {
|
it('restores and persists customization through the settings store', async () => {
|
||||||
@@ -335,4 +512,29 @@ describe('Pomodoro app', () => {
|
|||||||
it('shows a keyboard focus indicator around visually hidden background radios', () => {
|
it('shows a keyboard focus indicator around visually hidden background radios', () => {
|
||||||
expect(appCss).toMatch(/\.background-option:focus-within\s*\{/)
|
expect(appCss).toMatch(/\.background-option:focus-within\s*\{/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses a dark keyboard focus indicator on the white theme', () => {
|
||||||
|
expect(appCss).toMatch(/\.theme-light \.topbar :is\(button, a\):focus-visible[\s\S]*outline-color:\s*#111/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps secondary text readable on the white theme', () => {
|
||||||
|
expect(appCss).toMatch(/\.theme-light \.supporting-copy[\s\S]*color:\s*rgba\(17, 17, 17, 0\.68\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps top-bar controls inside a 320px viewport', () => {
|
||||||
|
expect(appCss).toMatch(/@media \(max-width:\s*360px\)[\s\S]*\.brand > span:last-child[\s\S]*display:\s*none/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses high-contrast primary-action text during break phases', () => {
|
||||||
|
expect(appCss).toMatch(/\.app\.phase-shortBreak[\s\S]*--primary-text:\s*#111/)
|
||||||
|
expect(appCss).toMatch(/\.app\.phase-longBreak[\s\S]*--primary-text:\s*#111/)
|
||||||
|
expect(appCss).toMatch(/\.primary-action[\s\S]*color:\s*var\(--primary-text\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses high-contrast accents and inactive controls in the light theme', () => {
|
||||||
|
expect(appCss).toMatch(/\.app\.theme-light\s*{[\s\S]*--accent:\s*#6850c8/)
|
||||||
|
expect(appCss).toMatch(/\.app\.theme-light\.phase-shortBreak[\s\S]*--accent:\s*#176b58/)
|
||||||
|
expect(appCss).toMatch(/\.app\.theme-light\.phase-longBreak[\s\S]*--accent:\s*#a84524/)
|
||||||
|
expect(appCss).toMatch(/\.theme-light \.phase-switcher button\s*{[\s\S]*color:\s*#5f5f5f/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+173
-10
@@ -33,6 +33,12 @@ const PHASE_LABELS: Record<TimerPhase, string> = {
|
|||||||
longBreak: 'Long break',
|
longBreak: 'Long break',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PHASE_TIME_LABELS: Record<TimerPhase, string> = {
|
||||||
|
focus: 'Focus',
|
||||||
|
shortBreak: 'Short break',
|
||||||
|
longBreak: 'Long break',
|
||||||
|
}
|
||||||
|
|
||||||
const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [
|
const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [
|
||||||
{ phase: 'focus', label: 'Focus' },
|
{ phase: 'focus', label: 'Focus' },
|
||||||
{ phase: 'shortBreak', label: 'Short break' },
|
{ phase: 'shortBreak', label: 'Short break' },
|
||||||
@@ -51,10 +57,11 @@ function formatDuration(milliseconds: number): string {
|
|||||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatClock(date: Date): string {
|
function formatClock(date: Date, includeSeconds = false): string {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
second: includeSeconds ? '2-digit' : undefined,
|
||||||
hour12: false,
|
hour12: false,
|
||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
@@ -68,8 +75,16 @@ function App({
|
|||||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
|
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
|
||||||
const [persistenceState, setPersistenceState] = useState<'loading' | 'ready' | 'error'>('loading')
|
const [persistenceState, setPersistenceState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const [focusMode, setFocusMode] = useState(false)
|
||||||
|
const [focusExitVisible, setFocusExitVisible] = useState(false)
|
||||||
const completionPending = useRef(false)
|
const completionPending = useRef(false)
|
||||||
|
const appRef = useRef<HTMLElement>(null)
|
||||||
const customizeButtonRef = useRef<HTMLButtonElement>(null)
|
const customizeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const focusModeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const focusExitButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const focusModeWasActive = useRef(false)
|
||||||
|
const focusExitTimer = useRef<number | null>(null)
|
||||||
const saveInFlight = useRef(false)
|
const saveInFlight = useRef(false)
|
||||||
const pendingSettingsSave = useRef<AppSettings | null>(null)
|
const pendingSettingsSave = useRef<AppSettings | null>(null)
|
||||||
|
|
||||||
@@ -172,6 +187,14 @@ function App({
|
|||||||
}
|
}
|
||||||
}, [updateTimer])
|
}, [updateTimer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncFullscreenState = () => {
|
||||||
|
setIsFullscreen(document.fullscreenElement === appRef.current)
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handlePrimaryAction = () => {
|
const handlePrimaryAction = () => {
|
||||||
if (timer.status === 'running') {
|
if (timer.status === 'running') {
|
||||||
setTimer((current) => pauseTimer(current, Date.now()))
|
setTimer((current) => pauseTimer(current, Date.now()))
|
||||||
@@ -187,6 +210,68 @@ function App({
|
|||||||
customizeButtonRef.current?.focus()
|
customizeButtonRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
await appRef.current?.requestFullscreen()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsFullscreen(document.fullscreenElement === appRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitFocusMode = useCallback(() => {
|
||||||
|
if (focusExitTimer.current !== null) {
|
||||||
|
window.clearTimeout(focusExitTimer.current)
|
||||||
|
focusExitTimer.current = null
|
||||||
|
}
|
||||||
|
setFocusExitVisible(false)
|
||||||
|
setFocusMode(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const revealFocusExit = useCallback(() => {
|
||||||
|
setFocusExitVisible(true)
|
||||||
|
if (focusExitTimer.current !== null) {
|
||||||
|
window.clearTimeout(focusExitTimer.current)
|
||||||
|
}
|
||||||
|
focusExitTimer.current = window.setTimeout(() => {
|
||||||
|
setFocusExitVisible(false)
|
||||||
|
focusExitTimer.current = null
|
||||||
|
}, 2_000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusMode) return
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
exitFocusMode()
|
||||||
|
} else if (event.key === 'Tab') {
|
||||||
|
event.preventDefault()
|
||||||
|
revealFocusExit()
|
||||||
|
focusExitButtonRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
if (focusExitTimer.current !== null) {
|
||||||
|
window.clearTimeout(focusExitTimer.current)
|
||||||
|
focusExitTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [exitFocusMode, focusMode, revealFocusExit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusMode) {
|
||||||
|
focusExitButtonRef.current?.focus()
|
||||||
|
} else if (focusModeWasActive.current) {
|
||||||
|
focusModeButtonRef.current?.focus()
|
||||||
|
}
|
||||||
|
focusModeWasActive.current = focusMode
|
||||||
|
}, [focusMode])
|
||||||
|
|
||||||
const settingsHydrated = persistenceState !== 'loading'
|
const settingsHydrated = persistenceState !== 'loading'
|
||||||
|
|
||||||
const cycleProgress = timer.completedFocusSessions % 4
|
const cycleProgress = timer.completedFocusSessions % 4
|
||||||
@@ -202,15 +287,30 @@ function App({
|
|||||||
const appStyle = {
|
const appStyle = {
|
||||||
'--app-font': selectedFont.css,
|
'--app-font': selectedFont.css,
|
||||||
'--background-image': selectedBackground.css,
|
'--background-image': selectedBackground.css,
|
||||||
|
'--clock-color': settings.useThemeClockColor
|
||||||
|
? selectedBackground.clockColor
|
||||||
|
: settings.textColor,
|
||||||
|
'--light-overlay-opacity': String(settings.overlayOpacity * 0.35),
|
||||||
'--overlay-opacity': String(settings.overlayOpacity),
|
'--overlay-opacity': String(settings.overlayOpacity),
|
||||||
color: settings.textColor,
|
color: selectedBackground.foregroundColor,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`app phase-${timer.phase}`} data-testid="app" style={appStyle}>
|
<main
|
||||||
|
className={`app phase-${timer.phase} theme-${selectedBackground.tone} theme-${selectedBackground.id}${
|
||||||
|
selectedBackground.motion ? ` theme-motion-${selectedBackground.motion}` : ''
|
||||||
|
}`}
|
||||||
|
data-testid="app"
|
||||||
|
onPointerDown={focusMode ? revealFocusExit : undefined}
|
||||||
|
onPointerMove={focusMode ? revealFocusExit : undefined}
|
||||||
|
ref={appRef}
|
||||||
|
style={appStyle}
|
||||||
|
>
|
||||||
<div className="orb orb-one" aria-hidden="true" />
|
<div className="orb orb-one" aria-hidden="true" />
|
||||||
<div className="orb orb-two" aria-hidden="true" />
|
<div className="orb orb-two" aria-hidden="true" />
|
||||||
|
|
||||||
|
{!focusMode && (
|
||||||
|
<div className="standard-interface">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<a className="brand" href="/" aria-label="Pomodoro home">
|
<a className="brand" href="/" aria-label="Pomodoro home">
|
||||||
<span className="brand-mark" aria-hidden="true">P</span>
|
<span className="brand-mark" aria-hidden="true">P</span>
|
||||||
@@ -230,10 +330,18 @@ function App({
|
|||||||
<time className="clock" aria-label="Current time">
|
<time className="clock" aria-label="Current time">
|
||||||
{formatClock(currentTime)}
|
{formatClock(currentTime)}
|
||||||
</time>
|
</time>
|
||||||
|
<button
|
||||||
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
|
className="fullscreen-button"
|
||||||
|
onClick={() => void toggleFullscreen()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⛶</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="timer-card" aria-live="polite">
|
<section className="timer-card">
|
||||||
<p className="eyebrow">Stay present</p>
|
<p className="eyebrow">Stay present</p>
|
||||||
<h1>{PHASE_LABELS[timer.phase]}</h1>
|
<h1>{PHASE_LABELS[timer.phase]}</h1>
|
||||||
<p className="supporting-copy">
|
<p className="supporting-copy">
|
||||||
@@ -259,13 +367,23 @@ function App({
|
|||||||
<div className="session-row">
|
<div className="session-row">
|
||||||
<div className="session-dots" aria-hidden="true">
|
<div className="session-dots" aria-hidden="true">
|
||||||
{[1, 2, 3, 4].map((session) => (
|
{[1, 2, 3, 4].map((session) => (
|
||||||
<span className={session <= visibleProgress ? 'complete' : ''} key={session} />
|
<span
|
||||||
|
className={[
|
||||||
|
session <= visibleProgress ? 'complete' : '',
|
||||||
|
timer.phase === 'focus'
|
||||||
|
&& timer.status === 'running'
|
||||||
|
&& session === visibleProgress + 1
|
||||||
|
? 'current'
|
||||||
|
: '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
key={session}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span>{visibleProgress} / 4 sessions</span>
|
<span>{visibleProgress} / 4 sessions</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<output className="countdown" aria-label="Time remaining">
|
<output aria-label="Time remaining" aria-live="off" className="countdown" role="timer">
|
||||||
{formatDuration(timer.remainingMs)}
|
{formatDuration(timer.remainingMs)}
|
||||||
</output>
|
</output>
|
||||||
|
|
||||||
@@ -292,13 +410,13 @@ function App({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="status-line">
|
<p className="status-line" role="status">
|
||||||
<span className={`status-dot ${timer.status}`} aria-hidden="true" />
|
<span className={`status-dot ${timer.status}`} aria-hidden="true" />
|
||||||
{timer.status === 'running'
|
{timer.status === 'running'
|
||||||
? 'Timer is running'
|
? `${PHASE_LABELS[timer.phase]} timer is running`
|
||||||
: timer.status === 'paused'
|
: timer.status === 'paused'
|
||||||
? 'Paused — your place is saved'
|
? `${PHASE_LABELS[timer.phase]} paused — your place is saved`
|
||||||
: 'Ready when you are'}
|
: `${PHASE_LABELS[timer.phase]} ready when you are`}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -318,6 +436,51 @@ function App({
|
|||||||
persistenceError={persistenceState === 'error'}
|
persistenceError={persistenceState === 'error'}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="Enter focus mode"
|
||||||
|
className="focus-mode-button"
|
||||||
|
onClick={() => {
|
||||||
|
setFocusMode(true)
|
||||||
|
revealFocusExit()
|
||||||
|
}}
|
||||||
|
ref={focusModeButtonRef}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">◉</span>
|
||||||
|
<span>Focus</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{focusMode && (
|
||||||
|
<section className="focus-mode-view">
|
||||||
|
{timer.status === 'idle' ? (
|
||||||
|
<time aria-label="Current time with seconds" className="focus-mode-time">
|
||||||
|
{formatClock(currentTime, true)}
|
||||||
|
</time>
|
||||||
|
) : (
|
||||||
|
<output
|
||||||
|
aria-live="off"
|
||||||
|
aria-label={`${PHASE_TIME_LABELS[timer.phase]} time remaining`}
|
||||||
|
className="focus-mode-time"
|
||||||
|
role="timer"
|
||||||
|
>
|
||||||
|
{formatDuration(timer.remainingMs)}
|
||||||
|
</output>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="Exit focus mode"
|
||||||
|
className={`focus-mode-exit${focusExitVisible ? ' visible' : ''}`}
|
||||||
|
onClick={exitFocusMode}
|
||||||
|
onFocus={revealFocusExit}
|
||||||
|
ref={focusExitButtonRef}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
Exit focus
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from 'react'
|
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'
|
||||||
import { BACKGROUND_OPTIONS, FONT_OPTIONS } from './options'
|
import { BACKGROUND_OPTIONS, FONT_OPTIONS } from './options'
|
||||||
import type { AppSettings } from './settings'
|
import type { AppSettings } from './settings'
|
||||||
|
|
||||||
@@ -21,6 +21,11 @@ const FOCUSABLE_SELECTOR = [
|
|||||||
function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset }: SettingsPanelProps) {
|
function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset }: SettingsPanelProps) {
|
||||||
const panelRef = useRef<HTMLElement>(null)
|
const panelRef = useRef<HTMLElement>(null)
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [clockColorOpen, setClockColorOpen] = useState(false)
|
||||||
|
const themeClockColor = BACKGROUND_OPTIONS.find(
|
||||||
|
(background) => background.id === settings.backgroundId,
|
||||||
|
)?.clockColor ?? settings.textColor
|
||||||
|
const displayedClockColor = settings.useThemeClockColor ? themeClockColor : settings.textColor
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeButtonRef.current?.focus()
|
closeButtonRef.current?.focus()
|
||||||
@@ -173,18 +178,59 @@ function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="color-control">
|
<div className="clock-color-control">
|
||||||
<span>Text color</span>
|
<button
|
||||||
<span className="color-input-wrap">
|
aria-expanded={clockColorOpen}
|
||||||
<input
|
className="clock-color-button"
|
||||||
aria-label="Text color"
|
onClick={() => setClockColorOpen((open) => !open)}
|
||||||
onChange={(event) => update('textColor', event.target.value)}
|
type="button"
|
||||||
type="color"
|
>
|
||||||
value={settings.textColor}
|
<span>Clock color</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="clock-color-swatch"
|
||||||
|
style={{ background: displayedClockColor }}
|
||||||
/>
|
/>
|
||||||
<code>{settings.textColor.toUpperCase()}</code>
|
</button>
|
||||||
</span>
|
{clockColorOpen && (
|
||||||
</label>
|
<div className="clock-color-options">
|
||||||
|
<label>
|
||||||
|
<span>Custom clock color</span>
|
||||||
|
<span className="color-input-wrap">
|
||||||
|
<input
|
||||||
|
aria-label="Custom clock color"
|
||||||
|
onChange={(event) => onChange({
|
||||||
|
...settings,
|
||||||
|
textColor: event.target.value,
|
||||||
|
useThemeClockColor: false,
|
||||||
|
})}
|
||||||
|
type="color"
|
||||||
|
value={settings.textColor}
|
||||||
|
/>
|
||||||
|
<code>{settings.textColor.toUpperCase()}</code>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="clock-color-actions">
|
||||||
|
<button
|
||||||
|
className="theme-color-button"
|
||||||
|
disabled={!settings.useThemeClockColor}
|
||||||
|
onClick={() => update('useThemeClockColor', false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Use custom color
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="theme-color-button"
|
||||||
|
disabled={settings.useThemeClockColor}
|
||||||
|
onClick={() => update('useThemeClockColor', true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Use theme color
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,42 +5,102 @@ export const BACKGROUND_OPTIONS: Array<{
|
|||||||
label: string
|
label: string
|
||||||
css: string
|
css: string
|
||||||
preview: string
|
preview: string
|
||||||
|
clockColor: string
|
||||||
|
foregroundColor: string
|
||||||
|
tone: 'dark' | 'light'
|
||||||
|
motion?: 'lava' | 'liquid'
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
id: 'violet',
|
id: 'violet',
|
||||||
label: 'Violet haze',
|
label: 'Violet haze',
|
||||||
css: 'radial-gradient(circle at 72% 18%, #8a416f 0%, transparent 38%), linear-gradient(135deg, #15142a 0%, #31265b 52%, #17162e 100%)',
|
css: 'radial-gradient(circle at 72% 18%, #8a416f 0%, transparent 38%), linear-gradient(135deg, #15142a 0%, #31265b 52%, #17162e 100%)',
|
||||||
preview: 'linear-gradient(135deg, #19172f, #684167)',
|
preview: 'linear-gradient(135deg, #19172f, #684167)',
|
||||||
|
clockColor: '#f8f7ff',
|
||||||
|
foregroundColor: '#f8f7ff',
|
||||||
|
tone: 'dark',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sunset',
|
id: 'sunset',
|
||||||
label: 'Soft sunset',
|
label: 'Soft sunset',
|
||||||
css: 'radial-gradient(circle at 72% 20%, #f09a78 0%, transparent 32%), linear-gradient(145deg, #242140 0%, #8f4b62 56%, #382743 100%)',
|
css: 'radial-gradient(circle at 72% 20%, #f09a78 0%, transparent 32%), linear-gradient(145deg, #242140 0%, #8f4b62 56%, #382743 100%)',
|
||||||
preview: 'linear-gradient(135deg, #33294e, #ef8a70)',
|
preview: 'linear-gradient(135deg, #33294e, #ef8a70)',
|
||||||
|
clockColor: '#fff7ef',
|
||||||
|
foregroundColor: '#fff7ef',
|
||||||
|
tone: 'dark',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'aurora',
|
id: 'aurora',
|
||||||
label: 'Quiet aurora',
|
label: 'Quiet aurora',
|
||||||
css: 'radial-gradient(circle at 28% 72%, #2e9f83 0%, transparent 38%), radial-gradient(circle at 78% 18%, #5755a8 0%, transparent 42%), #101e2d',
|
css: 'radial-gradient(circle at 28% 72%, #2e9f83 0%, transparent 38%), radial-gradient(circle at 78% 18%, #5755a8 0%, transparent 42%), #101e2d',
|
||||||
preview: 'linear-gradient(135deg, #183244, #2e9f83)',
|
preview: 'linear-gradient(135deg, #183244, #2e9f83)',
|
||||||
|
clockColor: '#effffb',
|
||||||
|
foregroundColor: '#effffb',
|
||||||
|
tone: 'dark',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dusk',
|
id: 'dusk',
|
||||||
label: 'Mountain dusk',
|
label: 'Mountain dusk',
|
||||||
css: 'url("/backgrounds/dusk.svg")',
|
css: 'url("/backgrounds/dusk.svg")',
|
||||||
preview: 'linear-gradient(135deg, #37416f, #d68a79)',
|
preview: 'linear-gradient(135deg, #37416f, #d68a79)',
|
||||||
|
clockColor: '#fff5ed',
|
||||||
|
foregroundColor: '#fff5ed',
|
||||||
|
tone: 'dark',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'forest',
|
id: 'forest',
|
||||||
label: 'Forest stillness',
|
label: 'Forest stillness',
|
||||||
css: 'url("/backgrounds/forest.svg")',
|
css: 'url("/backgrounds/forest.svg")',
|
||||||
preview: 'linear-gradient(135deg, #153c3a, #6f9c79)',
|
preview: 'linear-gradient(135deg, #153c3a, #6f9c79)',
|
||||||
|
clockColor: '#f1fff1',
|
||||||
|
foregroundColor: '#f1fff1',
|
||||||
|
tone: 'dark',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ocean',
|
id: 'ocean',
|
||||||
label: 'Open ocean',
|
label: 'Open ocean',
|
||||||
css: 'url("/backgrounds/ocean.svg")',
|
css: 'url("/backgrounds/ocean.svg")',
|
||||||
preview: 'linear-gradient(135deg, #173f5f, #53b6c3)',
|
preview: 'linear-gradient(135deg, #173f5f, #53b6c3)',
|
||||||
|
clockColor: '#effcff',
|
||||||
|
foregroundColor: '#effcff',
|
||||||
|
tone: 'dark',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'black',
|
||||||
|
label: 'Minimal black',
|
||||||
|
css: 'linear-gradient(135deg, #050505, #111111)',
|
||||||
|
preview: 'linear-gradient(135deg, #020202, #171717)',
|
||||||
|
clockColor: '#ffffff',
|
||||||
|
foregroundColor: '#f7f7f5',
|
||||||
|
tone: 'dark',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'white',
|
||||||
|
label: 'Minimal white',
|
||||||
|
css: 'linear-gradient(135deg, #ffffff, #efefeb)',
|
||||||
|
preview: 'linear-gradient(135deg, #ffffff, #e5e5df)',
|
||||||
|
clockColor: '#111111',
|
||||||
|
foregroundColor: '#171717',
|
||||||
|
tone: 'light',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lava',
|
||||||
|
label: 'Lava bloom',
|
||||||
|
css: 'radial-gradient(circle at 18% 28%, #ff4d6d 0%, transparent 34%), radial-gradient(circle at 78% 70%, #ff9f1c 0%, transparent 38%), radial-gradient(circle at 62% 18%, #8338ec 0%, transparent 32%), #210b2c',
|
||||||
|
preview: 'linear-gradient(135deg, #ff4d6d, #ff9f1c 54%, #6f2dbd)',
|
||||||
|
clockColor: '#fff7ee',
|
||||||
|
foregroundColor: '#fff7ee',
|
||||||
|
tone: 'dark',
|
||||||
|
motion: 'lava',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'liquid',
|
||||||
|
label: 'Liquid aurora',
|
||||||
|
css: 'radial-gradient(circle at 20% 72%, #00d4a6 0%, transparent 38%), radial-gradient(circle at 82% 24%, #6c63ff 0%, transparent 36%), radial-gradient(circle at 46% 42%, #0088ff 0%, transparent 42%), #07182b',
|
||||||
|
preview: 'linear-gradient(135deg, #00c9a7, #0088ff 54%, #6c63ff)',
|
||||||
|
clockColor: '#efffff',
|
||||||
|
foregroundColor: '#efffff',
|
||||||
|
tone: 'dark',
|
||||||
|
motion: 'liquid',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ import {
|
|||||||
import { IndexedDbSettingsStore } from './settings-store'
|
import { IndexedDbSettingsStore } from './settings-store'
|
||||||
|
|
||||||
describe('application settings', () => {
|
describe('application settings', () => {
|
||||||
|
it('uses the selected theme clock color by default', () => {
|
||||||
|
expect(DEFAULT_SETTINGS.useThemeClockColor).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a legacy custom clock color as an override', () => {
|
||||||
|
const legacySettings: Partial<AppSettings> = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
textColor: '#23ab67',
|
||||||
|
}
|
||||||
|
delete legacySettings.useThemeClockColor
|
||||||
|
|
||||||
|
expect(normalizeSettings(legacySettings).useThemeClockColor).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('automatically starts the next timer by default', () => {
|
it('automatically starts the next timer by default', () => {
|
||||||
expect(DEFAULT_SETTINGS.autoStartNext).toBe(true)
|
expect(DEFAULT_SETTINGS.autoStartNext).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const BACKGROUND_IDS = [
|
|||||||
'dusk',
|
'dusk',
|
||||||
'forest',
|
'forest',
|
||||||
'ocean',
|
'ocean',
|
||||||
|
'black',
|
||||||
|
'white',
|
||||||
|
'lava',
|
||||||
|
'liquid',
|
||||||
] as const
|
] as const
|
||||||
export type BackgroundId = (typeof BACKGROUND_IDS)[number]
|
export type BackgroundId = (typeof BACKGROUND_IDS)[number]
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ export interface AppSettings {
|
|||||||
backgroundId: BackgroundId
|
backgroundId: BackgroundId
|
||||||
overlayOpacity: number
|
overlayOpacity: number
|
||||||
textColor: string
|
textColor: string
|
||||||
|
useThemeClockColor: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
@@ -33,6 +38,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
backgroundId: 'violet',
|
backgroundId: 'violet',
|
||||||
overlayOpacity: 0.2,
|
overlayOpacity: 0.2,
|
||||||
textColor: '#f8f7ff',
|
textColor: '#f8f7ff',
|
||||||
|
useThemeClockColor: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||||
@@ -71,6 +77,14 @@ export function normalizeSettings(value: unknown): AppSettings {
|
|||||||
typeof candidate.textColor === 'string' && /^#[0-9a-f]{6}$/i.test(candidate.textColor)
|
typeof candidate.textColor === 'string' && /^#[0-9a-f]{6}$/i.test(candidate.textColor)
|
||||||
? candidate.textColor
|
? candidate.textColor
|
||||||
: DEFAULT_SETTINGS.textColor,
|
: DEFAULT_SETTINGS.textColor,
|
||||||
|
useThemeClockColor:
|
||||||
|
typeof candidate.useThemeClockColor === 'boolean'
|
||||||
|
? candidate.useThemeClockColor
|
||||||
|
: !(
|
||||||
|
typeof candidate.textColor === 'string'
|
||||||
|
&& /^#[0-9a-f]{6}$/i.test(candidate.textColor)
|
||||||
|
&& candidate.textColor.toLowerCase() !== DEFAULT_SETTINGS.textColor
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user