yash3056 commited on
Commit
ecf6723
·
verified ·
1 Parent(s): e8db4c8

Upload folder using huggingface_hub

Browse files
Files changed (9) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +12 -0
  3. app.py +103 -0
  4. model.onnx +3 -0
  5. model.onnx.data +3 -0
  6. requirements.txt +6 -0
  7. static/index.html +66 -0
  8. static/script.js +114 -0
  9. static/style.css +303 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ model.onnx.data filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 8000
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+ from fastapi import FastAPI, HTTPException
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.responses import FileResponse
5
+ from pydantic import BaseModel
6
+ import onnxruntime as ort
7
+ import numpy as np
8
+ from transformers import AutoTokenizer
9
+ import os
10
+ import logging
11
+
12
+ # Configure logging
13
+ logging.basicConfig(level=logging.INFO)
14
+ logger = logging.getLogger(__name__)
15
+
16
+ app = FastAPI()
17
+
18
+ # Configuration
19
+ MODEL_PATH = "model.onnx"
20
+ TOKENIZER_NAME = "jhu-clsp/mmBERT-small"
21
+
22
+ # Global variables
23
+ ort_session = None
24
+ tokenizer = None
25
+
26
+ class PredictRequest(BaseModel):
27
+ text: str
28
+
29
+ class PredictResponse(BaseModel):
30
+ label: str
31
+ score: float
32
+ is_hate: bool
33
+
34
+ def load_model():
35
+ global ort_session, tokenizer
36
+ try:
37
+ if os.path.exists(MODEL_PATH):
38
+ logger.info(f"Loading ONNX model from {MODEL_PATH}...")
39
+ ort_session = ort.InferenceSession(MODEL_PATH)
40
+ logger.info("Model loaded successfully.")
41
+ else:
42
+ logger.warning(f"Model file {MODEL_PATH} not found. Inference will fail.")
43
+
44
+ logger.info(f"Loading tokenizer {TOKENIZER_NAME}...")
45
+ tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_NAME)
46
+ logger.info("Tokenizer loaded successfully.")
47
+ except Exception as e:
48
+ logger.error(f"Error loading model or tokenizer: {e}")
49
+
50
+ @app.on_event("startup")
51
+ async def startup_event():
52
+ load_model()
53
+
54
+ @app.post("/predict", response_model=PredictResponse)
55
+ async def predict(request: PredictRequest):
56
+ if ort_session is None or tokenizer is None:
57
+ # Mock response if model is not loaded (for testing UI)
58
+ logger.warning("Model not loaded, returning mock response.")
59
+ return PredictResponse(label="Non-Hate (Mock)", score=0.0, is_hate=False)
60
+ # Alternatively, raise error:
61
+ # raise HTTPException(status_code=503, detail="Model not loaded")
62
+
63
+ try:
64
+ inputs = tokenizer(
65
+ request.text,
66
+ max_length=128,
67
+ padding="max_length",
68
+ truncation=True,
69
+ return_tensors="np"
70
+ )
71
+
72
+ ort_inputs = {
73
+ "input_ids": inputs["input_ids"].astype(np.int64),
74
+ "attention_mask": inputs["attention_mask"].astype(np.int64)
75
+ }
76
+
77
+ ort_outs = ort_session.run(None, ort_inputs)
78
+ logits = ort_outs[0]
79
+
80
+ # Softmax
81
+ probs = np.exp(logits) / np.sum(np.exp(logits), axis=1, keepdims=True)
82
+ pred_idx = np.argmax(probs, axis=1)[0]
83
+ score = float(probs[0][pred_idx])
84
+
85
+ # 0: Non-Hate, 1: Hate
86
+ is_hate = bool(pred_idx == 1)
87
+ label = "Hate" if is_hate else "Non-Hate"
88
+
89
+ return PredictResponse(label=label, score=score, is_hate=is_hate)
90
+
91
+ except Exception as e:
92
+ logger.error(f"Prediction error: {e}")
93
+ raise HTTPException(status_code=500, detail=str(e))
94
+
95
+ # Serve static files
96
+ app.mount("/static", StaticFiles(directory="static"), name="static")
97
+
98
+ @app.get("/")
99
+ async def read_index():
100
+ return FileResponse("static/index.html")
101
+
102
+ if __name__ == "__main__":
103
+ uvicorn.run(app, host="0.0.0.0", port=7860)
model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f885054f14e2df02c723904fe5bb7c36ada86cad113af8f02d1a4a3cdcfce047
3
+ size 1850778
model.onnx.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a4a68e16c86f4101ef7da05e5bf5f5198113337d2dc17d06b705a35523a5f1ff
3
+ size 566099968
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ onnxruntime
4
+ numpy
5
+ transformers
6
+ pydantic
static/index.html ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="hi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Hindi Hate Speech Detector</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=Rozha+One&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ </head>
12
+ <body>
13
+ <div class="background-shapes">
14
+ <div class="shape shape-1"></div>
15
+ <div class="shape shape-2"></div>
16
+ <div class="shape shape-3"></div>
17
+ </div>
18
+
19
+ <div class="container">
20
+ <header>
21
+ <h1>Hindi Hate Speech Detector</h1>
22
+ <p class="subtitle">Advanced AI to identify toxic content in Hindi text</p>
23
+ </header>
24
+
25
+ <main>
26
+ <div class="card input-card">
27
+ <div class="input-wrapper">
28
+ <textarea id="inputText" placeholder="यहाँ अपना टेक्स्ट लिखें... (Type Hindi text here)"></textarea>
29
+ <div class="char-count">0 / 500</div>
30
+ </div>
31
+ <button id="analyzeBtn" class="btn-primary">
32
+ <span class="btn-text">Analyze Text</span>
33
+ <div class="loader hidden"></div>
34
+ </button>
35
+ </div>
36
+
37
+ <div id="resultCard" class="card result-card hidden">
38
+ <div class="result-header">
39
+ <h2>Analysis Result</h2>
40
+ <span id="confidenceScore" class="confidence"></span>
41
+ </div>
42
+ <div class="result-content">
43
+ <div id="resultIcon" class="icon"></div>
44
+ <div class="result-text">
45
+ <h3 id="resultLabel"></h3>
46
+ <p id="resultMessage"></p>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="examples-section">
52
+ <h3>Try Examples</h3>
53
+ <div class="examples-grid" id="examplesGrid">
54
+ <!-- Examples will be populated by JS -->
55
+ </div>
56
+ </div>
57
+ </main>
58
+
59
+ <footer>
60
+ <p>Powered by BERT-CNN Ensemble Model</p>
61
+ </footer>
62
+ </div>
63
+
64
+ <script src="/static/script.js"></script>
65
+ </body>
66
+ </html>
static/script.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const inputText = document.getElementById('inputText');
3
+ const analyzeBtn = document.getElementById('analyzeBtn');
4
+ const resultCard = document.getElementById('resultCard');
5
+ const resultLabel = document.getElementById('resultLabel');
6
+ const resultMessage = document.getElementById('resultMessage');
7
+ const confidenceScore = document.getElementById('confidenceScore');
8
+ const loader = document.querySelector('.loader');
9
+ const btnText = document.querySelector('.btn-text');
10
+ const charCount = document.querySelector('.char-count');
11
+ const examplesGrid = document.getElementById('examplesGrid');
12
+
13
+ // Hindi Examples
14
+ const examples = [
15
+ "बांग्लादेश की शानदार वापसी, भारत को 314 रन पर रोका #INDvBAN #CWC19",
16
+ "तुम सब गद्दार हो, देश छोड़ दो।",
17
+ "भारत एक महान देश है।",
18
+ "इन लोगों को मार डालना चाहिए।",
19
+ "हमें मिलजुल कर रहना चाहिए।",
20
+ "वह बहुत बुरा इंसान है।"
21
+ ];
22
+
23
+ // Populate Examples
24
+ examples.forEach(text => {
25
+ const chip = document.createElement('div');
26
+ chip.className = 'example-chip';
27
+ chip.textContent = text;
28
+ chip.onclick = () => {
29
+ inputText.value = text;
30
+ updateCharCount();
31
+ analyzeText();
32
+ };
33
+ examplesGrid.appendChild(chip);
34
+ });
35
+
36
+ // Character Count
37
+ inputText.addEventListener('input', updateCharCount);
38
+
39
+ function updateCharCount() {
40
+ const length = inputText.value.length;
41
+ charCount.textContent = `${length} / 500`;
42
+ if (length > 500) {
43
+ charCount.style.color = 'var(--danger)';
44
+ } else {
45
+ charCount.style.color = 'var(--text-dim)';
46
+ }
47
+ }
48
+
49
+ // Analyze Text
50
+ analyzeBtn.addEventListener('click', analyzeText);
51
+
52
+ async function analyzeText() {
53
+ const text = inputText.value.trim();
54
+ if (!text) return;
55
+
56
+ // UI Loading State
57
+ setLoading(true);
58
+ resultCard.classList.add('hidden');
59
+
60
+ try {
61
+ const response = await fetch('/predict', {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json',
65
+ },
66
+ body: JSON.stringify({ text: text }),
67
+ });
68
+
69
+ if (!response.ok) {
70
+ throw new Error('API Error');
71
+ }
72
+
73
+ const data = await response.json();
74
+ showResult(data);
75
+
76
+ } catch (error) {
77
+ console.error('Error:', error);
78
+ alert('An error occurred while analyzing the text. Please try again.');
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ }
83
+
84
+ function setLoading(isLoading) {
85
+ if (isLoading) {
86
+ analyzeBtn.disabled = true;
87
+ loader.classList.remove('hidden');
88
+ btnText.textContent = 'Analyzing...';
89
+ } else {
90
+ analyzeBtn.disabled = false;
91
+ loader.classList.add('hidden');
92
+ btnText.textContent = 'Analyze Text';
93
+ }
94
+ }
95
+
96
+ function showResult(data) {
97
+ resultCard.classList.remove('hidden', 'hate', 'non-hate');
98
+
99
+ // Add class based on result
100
+ if (data.is_hate) {
101
+ resultCard.classList.add('hate');
102
+ resultLabel.textContent = 'Hate Speech Detected';
103
+ resultMessage.textContent = 'This content contains toxic or hateful language.';
104
+ } else {
105
+ resultCard.classList.add('non-hate');
106
+ resultLabel.textContent = 'Non-Hate Speech';
107
+ resultMessage.textContent = 'This content appears to be safe.';
108
+ }
109
+
110
+ // Format confidence score
111
+ const scorePercent = (data.score * 100).toFixed(1);
112
+ confidenceScore.textContent = `Confidence: ${scorePercent}%`;
113
+ }
114
+ });
static/style.css ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary: #6366f1;
3
+ --primary-dark: #4f46e5;
4
+ --secondary: #ec4899;
5
+ --bg-dark: #0f172a;
6
+ --card-bg: rgba(30, 41, 59, 0.7);
7
+ --text-light: #f8fafc;
8
+ --text-dim: #94a3b8;
9
+ --success: #10b981;
10
+ --danger: #ef4444;
11
+ --glass-border: rgba(255, 255, 255, 0.1);
12
+ }
13
+
14
+ * {
15
+ margin: 0;
16
+ padding: 0;
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ body {
21
+ font-family: 'Outfit', sans-serif;
22
+ background-color: var(--bg-dark);
23
+ color: var(--text-light);
24
+ min-height: 100vh;
25
+ display: flex;
26
+ justify-content: center;
27
+ align-items: center;
28
+ overflow-x: hidden;
29
+ position: relative;
30
+ }
31
+
32
+ /* Background Shapes */
33
+ .background-shapes {
34
+ position: fixed;
35
+ top: 0;
36
+ left: 0;
37
+ width: 100%;
38
+ height: 100%;
39
+ z-index: -1;
40
+ overflow: hidden;
41
+ }
42
+
43
+ .shape {
44
+ position: absolute;
45
+ filter: blur(100px);
46
+ opacity: 0.4;
47
+ animation: float 20s infinite ease-in-out;
48
+ }
49
+
50
+ .shape-1 {
51
+ width: 400px;
52
+ height: 400px;
53
+ background: var(--primary);
54
+ top: -100px;
55
+ left: -100px;
56
+ }
57
+
58
+ .shape-2 {
59
+ width: 300px;
60
+ height: 300px;
61
+ background: var(--secondary);
62
+ bottom: -50px;
63
+ right: -50px;
64
+ animation-delay: -5s;
65
+ }
66
+
67
+ .shape-3 {
68
+ width: 200px;
69
+ height: 200px;
70
+ background: #8b5cf6;
71
+ top: 40%;
72
+ left: 60%;
73
+ animation-delay: -10s;
74
+ }
75
+
76
+ @keyframes float {
77
+ 0%, 100% { transform: translate(0, 0); }
78
+ 50% { transform: translate(30px, -30px); }
79
+ }
80
+
81
+ .container {
82
+ width: 100%;
83
+ max-width: 800px;
84
+ padding: 2rem;
85
+ z-index: 1;
86
+ }
87
+
88
+ header {
89
+ text-align: center;
90
+ margin-bottom: 3rem;
91
+ }
92
+
93
+ h1 {
94
+ font-size: 3rem;
95
+ font-weight: 700;
96
+ background: linear-gradient(to right, #fff, #94a3b8);
97
+ -webkit-background-clip: text;
98
+ -webkit-text-fill-color: transparent;
99
+ margin-bottom: 0.5rem;
100
+ }
101
+
102
+ .subtitle {
103
+ color: var(--text-dim);
104
+ font-size: 1.1rem;
105
+ }
106
+
107
+ .card {
108
+ background: var(--card-bg);
109
+ backdrop-filter: blur(16px);
110
+ border: 1px solid var(--glass-border);
111
+ border-radius: 1.5rem;
112
+ padding: 2rem;
113
+ margin-bottom: 2rem;
114
+ box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
115
+ transition: transform 0.3s ease;
116
+ }
117
+
118
+ .input-card:hover {
119
+ transform: translateY(-2px);
120
+ }
121
+
122
+ .input-wrapper {
123
+ position: relative;
124
+ margin-bottom: 1.5rem;
125
+ }
126
+
127
+ textarea {
128
+ width: 100%;
129
+ height: 150px;
130
+ background: rgba(15, 23, 42, 0.6);
131
+ border: 2px solid var(--glass-border);
132
+ border-radius: 1rem;
133
+ padding: 1rem;
134
+ color: var(--text-light);
135
+ font-family: 'Outfit', sans-serif;
136
+ font-size: 1.1rem;
137
+ resize: none;
138
+ transition: border-color 0.3s ease;
139
+ }
140
+
141
+ textarea:focus {
142
+ outline: none;
143
+ border-color: var(--primary);
144
+ }
145
+
146
+ .char-count {
147
+ position: absolute;
148
+ bottom: 1rem;
149
+ right: 1rem;
150
+ font-size: 0.8rem;
151
+ color: var(--text-dim);
152
+ }
153
+
154
+ .btn-primary {
155
+ width: 100%;
156
+ padding: 1rem;
157
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
158
+ border: none;
159
+ border-radius: 1rem;
160
+ color: white;
161
+ font-size: 1.1rem;
162
+ font-weight: 600;
163
+ cursor: pointer;
164
+ transition: opacity 0.3s ease, transform 0.2s ease;
165
+ display: flex;
166
+ justify-content: center;
167
+ align-items: center;
168
+ }
169
+
170
+ .btn-primary:hover {
171
+ opacity: 0.9;
172
+ transform: scale(1.02);
173
+ }
174
+
175
+ .btn-primary:active {
176
+ transform: scale(0.98);
177
+ }
178
+
179
+ .loader {
180
+ width: 20px;
181
+ height: 20px;
182
+ border: 3px solid rgba(255, 255, 255, 0.3);
183
+ border-radius: 50%;
184
+ border-top-color: white;
185
+ animation: spin 1s ease-in-out infinite;
186
+ margin-left: 0.5rem;
187
+ }
188
+
189
+ @keyframes spin {
190
+ to { transform: rotate(360deg); }
191
+ }
192
+
193
+ .hidden {
194
+ display: none;
195
+ }
196
+
197
+ /* Result Card */
198
+ .result-card {
199
+ border-left: 5px solid transparent;
200
+ animation: slideUp 0.5s ease-out;
201
+ }
202
+
203
+ .result-card.hate {
204
+ border-left-color: var(--danger);
205
+ background: linear-gradient(to right, rgba(239, 68, 68, 0.1), transparent);
206
+ }
207
+
208
+ .result-card.non-hate {
209
+ border-left-color: var(--success);
210
+ background: linear-gradient(to right, rgba(16, 185, 129, 0.1), transparent);
211
+ }
212
+
213
+ .result-header {
214
+ display: flex;
215
+ justify-content: space-between;
216
+ align-items: center;
217
+ margin-bottom: 1rem;
218
+ }
219
+
220
+ .confidence {
221
+ font-size: 0.9rem;
222
+ color: var(--text-dim);
223
+ background: rgba(0, 0, 0, 0.2);
224
+ padding: 0.25rem 0.75rem;
225
+ border-radius: 1rem;
226
+ }
227
+
228
+ .result-content {
229
+ display: flex;
230
+ align-items: center;
231
+ gap: 1rem;
232
+ }
233
+
234
+ .icon {
235
+ width: 50px;
236
+ height: 50px;
237
+ border-radius: 50%;
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ font-size: 1.5rem;
242
+ }
243
+
244
+ .hate .icon::before {
245
+ content: '⚠️';
246
+ }
247
+
248
+ .non-hate .icon::before {
249
+ content: '✅';
250
+ }
251
+
252
+ #resultLabel {
253
+ font-size: 1.5rem;
254
+ margin-bottom: 0.25rem;
255
+ }
256
+
257
+ .hate #resultLabel { color: var(--danger); }
258
+ .non-hate #resultLabel { color: var(--success); }
259
+
260
+ @keyframes slideUp {
261
+ from { opacity: 0; transform: translateY(20px); }
262
+ to { opacity: 1; transform: translateY(0); }
263
+ }
264
+
265
+ /* Examples */
266
+ .examples-section {
267
+ margin-top: 3rem;
268
+ }
269
+
270
+ .examples-section h3 {
271
+ margin-bottom: 1rem;
272
+ color: var(--text-dim);
273
+ font-weight: 400;
274
+ }
275
+
276
+ .examples-grid {
277
+ display: grid;
278
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
279
+ gap: 1rem;
280
+ }
281
+
282
+ .example-chip {
283
+ background: rgba(255, 255, 255, 0.05);
284
+ border: 1px solid var(--glass-border);
285
+ padding: 1rem;
286
+ border-radius: 0.75rem;
287
+ cursor: pointer;
288
+ transition: all 0.3s ease;
289
+ font-size: 0.95rem;
290
+ color: var(--text-light);
291
+ }
292
+
293
+ .example-chip:hover {
294
+ background: rgba(255, 255, 255, 0.1);
295
+ border-color: var(--primary);
296
+ }
297
+
298
+ footer {
299
+ text-align: center;
300
+ margin-top: 3rem;
301
+ color: var(--text-dim);
302
+ font-size: 0.9rem;
303
+ }