Skip to content

Commit e0c127d

Browse files
committed
feat(ui): add custom select, replace inline styles, and improve popup UX
- Implemented custom brutal select component - Replaced inline styles with class-based styling (progress, visibility, badge states) - Introduced CSS variables for progress handling (--progress) - Added site badge state helper (success, warning, muted) - Updated dashboard language label - Improved FAQ messaging for stronger product positioning - Added ESLint restrictions for unsafe DOM usage (innerHTML, inline styles)
1 parent 9bd3019 commit e0c127d

7 files changed

Lines changed: 269 additions & 21 deletions

File tree

docs/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -642,13 +642,13 @@ <h2 class="text-3xl font-extrabold mb-8 text-[#171717] dark:text-[#FAFAFA]">
642642
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
643643
/>
644644
</svg>
645-
Can I disable the block if I really need to?
645+
Why does Limitra enforce instant blocking?
646646
</h4>
647647
<p
648648
class="text-gray-600 dark:text-[#A3A3A3] text-sm leading-relaxed font-medium pl-8 transition-colors duration-300"
649649
>
650-
You can modify your limits in the extension popup. Limitra is designed to be a
651-
friction point to stop mindless scrolling, not a permanent lock.
650+
Because willpower is unreliable. "Just 5 more minutes" turns into 30. Limitra
651+
removes the decision - and enforces your limits.
652652
</p>
653653
</div>
654654

eslint.config.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,30 @@ export default tseslint.config(
3030
'@typescript-eslint/no-floating-promises': 'error',
3131
'@typescript-eslint/await-thenable': 'error',
3232
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
33+
34+
'no-restricted-properties': [
35+
'error',
36+
37+
// --- Dangerous HTML injection ---
38+
{
39+
property: 'innerHTML',
40+
message: 'Do not use innerHTML. Use textContent or DOM APIs instead.',
41+
},
42+
{
43+
property: 'outerHTML',
44+
message: 'Do not use outerHTML.',
45+
},
46+
{
47+
property: 'insertAdjacentHTML',
48+
message: 'Avoid insertAdjacentHTML. Use DOM creation instead.',
49+
},
50+
51+
// --- Inline styles ---
52+
{
53+
property: 'style',
54+
message: 'Do not use inline styles. Use classes instead.',
55+
},
56+
],
3357
},
3458
},
3559

src/popup.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,14 @@ function updateProgressBar() {
4545
const usageSection = document.getElementById('usage-section') as HTMLElement;
4646

4747
if (!isLimitEnabled && !isTimeEnabled) {
48-
usageSection.style.display = 'none';
48+
usageSection.classList.add('hidden');
4949
return;
5050
} else {
51-
usageSection.style.display = 'block';
51+
usageSection.classList.remove('hidden');
5252
}
5353

5454
if (isLimitEnabled) {
55-
containerVideos.style.display = 'block';
55+
containerVideos.classList.remove('hidden');
5656

5757
let pCount = limit > 0 ? (currentConsumedCount / limit) * 100 : 0;
5858
if (pCount > 100) pCount = 100;
@@ -61,14 +61,14 @@ function updateProgressBar() {
6161
`${currentConsumedCount} / ${limit} ${t.popup.videosUnit}`;
6262

6363
const fillV = document.getElementById('fill-videos') as HTMLElement;
64-
fillV.style.width = `${pCount}%`;
65-
fillV.style.backgroundColor = pCount >= 100 && limit > 0 ? '#7c3aed' : 'var(--accent)';
64+
fillV.setAttribute('style', `--progress: ${pCount}%`);
65+
fillV.classList.toggle('is-complete', pCount >= 100 && limit > 0);
6666
} else {
67-
containerVideos.style.display = 'none';
67+
containerVideos.classList.add('hidden');
6868
}
6969

7070
if (isTimeEnabled) {
71-
containerTime.style.display = 'block';
71+
containerTime.classList.remove('hidden');
7272

7373
let pTime = timeLimit > 0 ? (timeSpentMins / timeLimit) * 100 : 0;
7474
if (pTime > 100) pTime = 100;
@@ -77,13 +77,18 @@ function updateProgressBar() {
7777
`${timeSpentMins} / ${timeLimit} ${t.popup.minsUnit}`;
7878

7979
const fillT = document.getElementById('fill-time') as HTMLElement;
80-
fillT.style.width = `${pTime}%`;
81-
fillT.style.backgroundColor = pTime >= 100 && timeLimit > 0 ? '#7c3aed' : 'var(--accent)';
80+
fillT.setAttribute('style', `--progress: ${pTime}%`);
81+
fillT.classList.toggle('is-complete', pTime >= 100 && timeLimit > 0);
8282
} else {
83-
containerTime.style.display = 'none';
83+
containerTime.classList.add('hidden');
8484
}
8585
}
8686

87+
function setBadgeState(el: HTMLElement, state: 'success' | 'warning' | 'muted') {
88+
el.classList.remove('success', 'warning', 'muted');
89+
el.classList.add(state);
90+
}
91+
8792
async function init() {
8893
const savedTheme = await storage.getTheme();
8994
applyTheme(savedTheme);
@@ -118,18 +123,15 @@ async function init() {
118123
chrome.tabs.sendMessage(currentTab.id!, { action: AppAction.PING }, (response) => {
119124
if (chrome.runtime.lastError || !response || response.status !== 'ALIVE') {
120125
siteBadge.textContent = '○ Refresh Page';
121-
siteBadge.style.color = 'var(--status-warning)';
122-
siteBadge.style.borderColor = 'var(--status-warning)';
126+
setBadgeState(siteBadge, 'warning');
123127
} else {
124128
siteBadge.textContent = '● YouTube Shorts';
125-
siteBadge.style.color = 'var(--status-success)';
126-
siteBadge.style.borderColor = 'var(--status-success)';
129+
setBadgeState(siteBadge, 'success');
127130
}
128131
});
129132
} else {
130133
siteBadge.textContent = '○ Unsupported Site';
131-
siteBadge.style.color = 'var(--text-muted)';
132-
siteBadge.style.borderColor = 'var(--text-muted)';
134+
setBadgeState(siteBadge, 'muted');
133135
}
134136
});
135137

src/settings/dashboard.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ <h5 class="setting-title" id="lbl-dash-language">Language</h5>
7373
<div class="setting-control">
7474
<select id="dash-language" class="brutal-select">
7575
<option value="en">English</option>
76-
<option value="ar">العربية</option>
76+
<option value="ar">Arabic</option>
7777
</select>
7878
</div>
7979
</div>

src/settings/dashboard.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { storage } from '../core/storage';
22
import { i18n } from '../i18n/index';
33
import { LocaleCode } from '../i18n/types';
4+
import { initCustomSelects } from '../ui/components/custom-select';
45

56
function applyTheme(theme: string) {
67
const isPageDark =
@@ -181,6 +182,8 @@ async function init() {
181182
applyTheme('auto');
182183
}
183184
});
185+
186+
initCustomSelects();
184187
}
185188

186189
void init();

src/ui/components/custom-select.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
export function initCustomSelects(): void {
2+
const selects = document.querySelectorAll<HTMLSelectElement>('select.brutal-select');
3+
4+
selects.forEach((select) => {
5+
if (select.classList.contains('brutal-select-hidden')) return;
6+
7+
select.classList.add('brutal-select-hidden');
8+
9+
const wrapper = document.createElement('div');
10+
wrapper.className = 'custom-brutal-select';
11+
12+
const trigger = document.createElement('div');
13+
trigger.className = 'custom-brutal-trigger';
14+
15+
const selectedText = document.createElement('span');
16+
selectedText.className = 'custom-select-value';
17+
18+
const activeOption = select.options[select.selectedIndex];
19+
selectedText.textContent = activeOption ? activeOption.text : '';
20+
21+
const icon = document.createElement('div');
22+
23+
const svgNS = 'http://www.w3.org/2000/svg';
24+
25+
const svg = document.createElementNS(svgNS, 'svg');
26+
svg.setAttribute('viewBox', '0 0 24 24');
27+
svg.setAttribute('fill', 'none');
28+
svg.setAttribute('stroke-width', '2');
29+
30+
const polyline = document.createElementNS(svgNS, 'polyline');
31+
polyline.setAttribute('points', '6 9 12 15 18 9');
32+
33+
svg.appendChild(polyline);
34+
icon.appendChild(svg);
35+
36+
trigger.appendChild(selectedText);
37+
trigger.appendChild(icon);
38+
39+
const optionsList = document.createElement('ul');
40+
optionsList.className = 'custom-brutal-options';
41+
42+
Array.from(select.options).forEach((option) => {
43+
const li = document.createElement('li');
44+
li.className = 'custom-brutal-option';
45+
if (option.selected) li.classList.add('selected');
46+
li.textContent = option.text;
47+
li.dataset.value = option.value;
48+
49+
li.addEventListener('click', (e: MouseEvent) => {
50+
e.stopPropagation();
51+
52+
wrapper.querySelectorAll('.custom-brutal-option').forEach((el) => {
53+
el.classList.remove('selected');
54+
});
55+
56+
li.classList.add('selected');
57+
selectedText.textContent = option.text;
58+
wrapper.classList.remove('open');
59+
60+
if (select.value !== option.value) {
61+
select.value = option.value;
62+
select.dispatchEvent(new Event('change', { bubbles: true }));
63+
}
64+
});
65+
optionsList.appendChild(li);
66+
});
67+
68+
trigger.addEventListener('click', (e: MouseEvent) => {
69+
e.stopPropagation();
70+
document.querySelectorAll('.custom-brutal-select').forEach((el) => {
71+
if (el !== wrapper) el.classList.remove('open');
72+
});
73+
wrapper.classList.toggle('open');
74+
});
75+
76+
wrapper.appendChild(trigger);
77+
wrapper.appendChild(optionsList);
78+
79+
if (select.parentNode) {
80+
select.parentNode.insertBefore(wrapper, select.nextSibling);
81+
}
82+
83+
select.addEventListener('change', (e: Event) => {
84+
if (!e.isTrusted) return;
85+
const newActive = Array.from(select.options).find((opt) => opt.value === select.value);
86+
if (newActive) {
87+
selectedText.textContent = newActive.text;
88+
wrapper.querySelectorAll<HTMLElement>('.custom-brutal-option').forEach((el) => {
89+
el.classList.toggle('selected', el.dataset.value === select.value);
90+
});
91+
}
92+
});
93+
});
94+
95+
document.addEventListener('click', () => {
96+
document.querySelectorAll('.custom-brutal-select').forEach((el) => {
97+
el.classList.remove('open');
98+
});
99+
});
100+
}

styles.css

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ button#save:disabled {
319319
.progress-fill {
320320
height: 100%;
321321
background-color: var(--accent);
322-
width: 0%;
322+
width: var(--progress, 0%);
323323
transition:
324324
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
325325
background-color 0.3s ease;
@@ -349,6 +349,23 @@ button#save:disabled {
349349
color: var(--text-main);
350350
}
351351

352+
/* --- Site Badge States --- */
353+
354+
.site-badge.success {
355+
color: var(--status-success);
356+
border-color: var(--status-success);
357+
}
358+
359+
.site-badge.warning {
360+
color: var(--status-warning);
361+
border-color: var(--status-warning);
362+
}
363+
364+
.site-badge.muted {
365+
color: var(--text-muted);
366+
border-color: var(--text-muted);
367+
}
368+
352369
/* --- Usage --- */
353370

354371
.usage-block {
@@ -560,6 +577,108 @@ button#save:disabled {
560577
box-shadow: none;
561578
}
562579

580+
/* --- Custom Select (Brutal Dropdown Replacement) --- */
581+
582+
select.brutal-select-hidden {
583+
display: none !important;
584+
}
585+
586+
.custom-brutal-select {
587+
position: relative;
588+
width: 100%;
589+
min-width: 140px;
590+
font-family: 'JetBrains Mono', monospace;
591+
font-size: 13px;
592+
user-select: none;
593+
}
594+
595+
.custom-brutal-trigger {
596+
width: 100%;
597+
display: flex;
598+
justify-content: space-between;
599+
align-items: center;
600+
padding: 8px 12px;
601+
border: 2px solid var(--ui-border);
602+
background-color: var(--bg-surface);
603+
color: var(--text-main);
604+
box-shadow: 2px 2px 0 var(--ui-border);
605+
cursor: pointer;
606+
transition: all 0.1s ease-in-out;
607+
box-sizing: border-box;
608+
}
609+
610+
.custom-brutal-trigger:active {
611+
transform: translate(2px, 2px);
612+
box-shadow: none;
613+
}
614+
615+
.custom-brutal-trigger svg {
616+
width: 16px;
617+
height: 16px;
618+
stroke: currentColor;
619+
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
620+
}
621+
622+
.custom-brutal-select.open .custom-brutal-trigger svg {
623+
transform: rotate(180deg);
624+
}
625+
626+
.custom-brutal-options {
627+
position: absolute;
628+
top: calc(100% + 4px);
629+
left: 0;
630+
width: 100%;
631+
background-color: var(--bg-surface);
632+
border: 2px solid var(--ui-border);
633+
box-shadow: 4px 4px 0 var(--ui-border);
634+
z-index: 1000;
635+
display: none;
636+
flex-direction: column;
637+
padding: 0;
638+
margin: 0;
639+
list-style: none;
640+
box-sizing: border-box;
641+
}
642+
643+
.custom-brutal-select.open .custom-brutal-options {
644+
display: flex;
645+
animation: brutalSlideDown 0.15s ease-out forwards;
646+
}
647+
648+
@keyframes brutalSlideDown {
649+
from {
650+
opacity: 0;
651+
transform: translateY(-4px);
652+
}
653+
to {
654+
opacity: 1;
655+
transform: translateY(0);
656+
}
657+
}
658+
659+
.custom-brutal-option {
660+
padding: 10px 12px;
661+
cursor: pointer;
662+
border-bottom: 2px solid var(--ui-border);
663+
transition: background-color 0.1s;
664+
color: var(--text-main);
665+
}
666+
667+
.custom-brutal-option:last-child {
668+
border-bottom: none;
669+
}
670+
671+
.custom-brutal-option:hover {
672+
background-color: var(--grid-color);
673+
color: var(--accent);
674+
}
675+
676+
.custom-brutal-option.selected {
677+
background-color: var(--accent);
678+
color: #ffffff;
679+
font-weight: 700;
680+
}
681+
563682
/* --- Tabs --- */
564683

565684
.tab-content {

0 commit comments

Comments
 (0)