diff --git a/app/api/countdowns/route.ts b/app/api/countdowns/route.ts
new file mode 100644
index 0000000..1d142c4
--- /dev/null
+++ b/app/api/countdowns/route.ts
@@ -0,0 +1,97 @@
+export const dynamic = 'force-dynamic'
+import { NextResponse } from 'next/server'
+import * as fs from 'fs'
+
+const DATA_DIR = fs.existsSync('/data') ? '/data' : '/tmp'
+const COUNTDOWNS_PATH = `${DATA_DIR}/tablet-countdowns.json`
+
+export interface Countdown {
+ id: string
+ label: string
+ date: string // YYYY-MM-DD
+ emoji?: string
+ color?: string // hex or data-* token name (e.g. "data-rose")
+ note?: string
+ createdAt: string
+ updatedAt: string
+}
+
+const DEFAULT_COUNTDOWNS: Countdown[] = [
+ {
+ id: 'tokyo',
+ label: 'Токио',
+ date: '2026-10-15',
+ emoji: '🗼',
+ color: 'data-rose',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+]
+
+function load(): Countdown[] {
+ try {
+ if (fs.existsSync(COUNTDOWNS_PATH)) {
+ return JSON.parse(fs.readFileSync(COUNTDOWNS_PATH, 'utf-8'))
+ }
+ // первая загрузка — создать дефолтный файл
+ save(DEFAULT_COUNTDOWNS)
+ return DEFAULT_COUNTDOWNS
+ } catch {
+ return []
+ }
+}
+
+function save(list: Countdown[]) {
+ try {
+ fs.writeFileSync(COUNTDOWNS_PATH, JSON.stringify(list, null, 2))
+ } catch {}
+}
+
+export async function GET() {
+ const all = load()
+ // сортируем по дате возрастания
+ const sorted = [...all].sort((a, b) => a.date.localeCompare(b.date))
+ return NextResponse.json({ countdowns: sorted })
+}
+
+export async function POST(req: Request) {
+ const body = await req.json()
+ if (!body.label || !body.date) {
+ return NextResponse.json({ error: 'label_and_date_required' }, { status: 400 })
+ }
+ const list = load()
+ const cd: Countdown = {
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
+ label: String(body.label).slice(0, 60),
+ date: String(body.date).slice(0, 10),
+ emoji: body.emoji?.slice(0, 4) || undefined,
+ color: body.color || undefined,
+ note: body.note?.slice(0, 200) || undefined,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ }
+ list.push(cd)
+ save(list)
+ return NextResponse.json({ countdown: cd })
+}
+
+export async function PUT(req: Request) {
+ const body = await req.json()
+ const { id, ...updates } = body
+ if (!id) return NextResponse.json({ error: 'id_required' }, { status: 400 })
+ const list = load()
+ const idx = list.findIndex(c => c.id === id)
+ if (idx < 0) return NextResponse.json({ error: 'not_found' }, { status: 404 })
+ list[idx] = { ...list[idx], ...updates, id, updatedAt: new Date().toISOString() }
+ save(list)
+ return NextResponse.json({ countdown: list[idx] })
+}
+
+export async function DELETE(req: Request) {
+ const url = new URL(req.url)
+ const id = url.searchParams.get('id')
+ if (!id) return NextResponse.json({ error: 'id_required' }, { status: 400 })
+ const list = load().filter(c => c.id !== id)
+ save(list)
+ return NextResponse.json({ ok: true })
+}
diff --git a/app/globals.css b/app/globals.css
index 5c4266c..9ca48ca 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -26,11 +26,32 @@
--text-secondary: rgba(255, 255, 255, 0.6);
--text-tertiary: rgba(255, 255, 255, 0.38);
- /* Accents */
+ /* Accents — UI */
--accent: #818cf8;
+ --accent-strong: #6366f1;
--accent-secondary: #22d3ee;
--accent-glow: rgba(129, 140, 248, 0.22);
+ /* Data palette — semantic, theme-aware */
+ --data-cool: #38bdf8; /* небо, влажность, охлаждение */
+ --data-info: #60a5fa; /* ссылки, second-accent */
+ --data-good: #34d399; /* успех, здоровье, streak */
+ --data-warm: #fbbf24; /* свет, тепло, утро */
+ --data-hot: #fb923c; /* обострение, urgent-minor */
+ --data-danger: #f87171; /* критичное, истёк, ошибка */
+ --data-rose: #f472b6; /* Света, романтика */
+ --data-violet: #a78bfa; /* Cosmo, индиго-вариант */
+ --data-mood: #8b5cf6; /* секреты, события 2-го уровня */
+
+ /* Soft backgrounds for data (color-mix with surface-2) */
+ --data-cool-bg: color-mix(in srgb, var(--data-cool) 12%, var(--surface-2));
+ --data-good-bg: color-mix(in srgb, var(--data-good) 12%, var(--surface-2));
+ --data-warm-bg: color-mix(in srgb, var(--data-warm) 12%, var(--surface-2));
+ --data-hot-bg: color-mix(in srgb, var(--data-hot) 14%, var(--surface-2));
+ --data-danger-bg: color-mix(in srgb, var(--data-danger) 12%, var(--surface-2));
+ --data-rose-bg: color-mix(in srgb, var(--data-rose) 12%, var(--surface-2));
+ --data-violet-bg: color-mix(in srgb, var(--data-violet) 12%, var(--surface-2));
+
/* Brand gradients */
--gradient-primary: linear-gradient(135deg, #6366f1, #8b5cf6);
--gradient-warm: linear-gradient(135deg, #f59e0b, #ef4444);
@@ -54,6 +75,18 @@
--radius-lg: 22px;
--radius-xl: 28px;
+ /* Layout rhythm */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 22px;
+ --space-6: 28px;
+
+ /* Touch */
+ --touch-min: 44px;
+ --touch-comfy: 56px;
+
/* Legacy aliases */
--sidebar-bg: var(--bg);
--card-bg: var(--surface-1);
@@ -84,9 +117,30 @@
--text-tertiary: rgba(15, 20, 40, 0.38);
--accent: #5b63e0;
+ --accent-strong: #4f46e5;
--accent-secondary: #0891b2;
--accent-glow: rgba(91, 99, 224, 0.14);
+ /* Data palette — slightly darker in light theme for contrast */
+ --data-cool: #0284c7;
+ --data-info: #2563eb;
+ --data-good: #059669;
+ --data-warm: #d97706;
+ --data-hot: #ea580c;
+ --data-danger: #dc2626;
+ --data-rose: #db2777;
+ --data-violet: #7c3aed;
+ --data-mood: #6d28d9;
+
+ /* Soft backgrounds — lighter mix for light theme */
+ --data-cool-bg: color-mix(in srgb, var(--data-cool) 9%, var(--surface-1));
+ --data-good-bg: color-mix(in srgb, var(--data-good) 9%, var(--surface-1));
+ --data-warm-bg: color-mix(in srgb, var(--data-warm) 9%, var(--surface-1));
+ --data-hot-bg: color-mix(in srgb, var(--data-hot) 10%, var(--surface-1));
+ --data-danger-bg: color-mix(in srgb, var(--data-danger) 9%, var(--surface-1));
+ --data-rose-bg: color-mix(in srgb, var(--data-rose) 9%, var(--surface-1));
+ --data-violet-bg: color-mix(in srgb, var(--data-violet) 9%, var(--surface-1));
+
--shadow-sm: 0 1px 2px rgba(15, 20, 40, 0.05);
--shadow-md: 0 2px 6px rgba(15, 20, 40, 0.06), 0 12px 28px -8px rgba(15, 20, 40, 0.1);
--shadow-lg: 0 4px 10px rgba(15, 20, 40, 0.06), 0 24px 60px -12px rgba(15, 20, 40, 0.14);
@@ -112,6 +166,41 @@ html, body {
#__next, main { height: 100%; }
+/* Better tabular numbers across the app */
+.num, .num-display {
+ font-variant-numeric: tabular-nums slashed-zero;
+ font-feature-settings: "tnum", "zero";
+}
+.num-display {
+ letter-spacing: -0.02em;
+ font-weight: 800;
+ line-height: 0.95;
+}
+
+/* ——————————————————————————————
+ Grain overlay
+ ——— subtle SVG-noise; cheap, no image asset needed
+ —————————————————————————————— */
+.grain {
+ position: relative;
+}
+.grain::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border-radius: inherit;
+ opacity: 0.035;
+ mix-blend-mode: overlay;
+ background-image: url("data:image/svg+xml;utf8,");
+ background-size: 120px 120px;
+}
+.light .grain::after {
+ opacity: 0.05;
+ mix-blend-mode: multiply;
+ filter: invert(1);
+}
+
/* ——————————————————————————————
Aurora
—————————————————————————————— */
@@ -178,6 +267,23 @@ html, body {
box-shadow: var(--shadow-lg);
}
+/* Focus-card styling — for the context-aware hero */
+.focus-card {
+ position: relative;
+ background: var(--surface-1);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+}
+.focus-card-accent::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(circle at top right, var(--accent-glow), transparent 60%);
+ pointer-events: none;
+}
+
.glass-card {
background: var(--surface-1);
border: 1px solid var(--border-subtle);
@@ -186,6 +292,32 @@ html, body {
}
.glass-card:hover { background: var(--surface-hover); }
+/* Hairline divider helper */
+.divider {
+ height: 1px;
+ width: 100%;
+ background: var(--hairline);
+}
+
+/* Chip / hit-zone — wraps small icons into 44px tap surface */
+.hit-zone {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: var(--touch-min);
+ min-height: var(--touch-min);
+ border-radius: 12px;
+}
+
+/* Small uppercase label */
+.eyebrow {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-tertiary);
+}
+
.gradient-text {
background: var(--gradient-primary);
-webkit-background-clip: text;
@@ -205,7 +337,8 @@ button {
}
button:focus-visible {
outline: 2px solid var(--accent);
- outline-offset: 2px;
+ outline-offset: 3px;
+ border-radius: 14px;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
diff --git a/app/page.tsx b/app/page.tsx
index b02f906..8b3fb24 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -14,9 +14,13 @@ import WeatherAnimation from '@/components/WeatherAnimation'
import VoiceOverlay from '@/components/VoiceOverlay'
import TimerWidget from '@/components/TimerWidget'
import TimerHomeWidget from '@/components/TimerHomeWidget'
+import FocusCard from '@/components/FocusCard'
+import CountdownCard from '@/components/CountdownCard'
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
+const TAB_ORDER: Tab[] = ['home', 'devices', 'calendar', 'notes', 'settings']
+
interface WeatherData {
temp: string
desc: string
@@ -311,7 +315,7 @@ function WeatherDayModal({ day, days, current, onClose, onChange }: {
}
return (
-
+
go(-1)}
+ aria-label="Предыдущий день"
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
- width: 36, height: 36, borderRadius: 12,
+ width: 48, height: 48, borderRadius: 14,
background: 'var(--surface-2)', border: '1px solid var(--border-subtle)',
- color: 'var(--text-secondary)', zIndex: 2,
+ color: 'var(--text-secondary)', zIndex: 2, fontSize: 22,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>‹
@@ -357,11 +362,12 @@ function WeatherDayModal({ day, days, current, onClose, onChange }: {
{canNext && (
@@ -422,7 +428,8 @@ function WeatherDayModal({ day, days, current, onClose, onChange }: {
)}