Commit Graph
27 Commits
Author SHA1 Message Date
timka123andClaude Opus 5 a8c84c65da android: offline writes go to the outbox that was already written
`:core/Outbox.kt` is 245 lines and 29 tests of careful thinking — what coalesces
with what, which post-condition proves an ambiguous send landed, which intervals
cannot be filed honestly at all — and nothing in `app/` had ever called it. Three
comments pointed at it as though it were wired up. A tap with no signal produced
one banner and was gone, after a ten-second connect timeout taken *under the
command mutex*, so a handful of taps was most of a minute of frozen UI and then a
stack of identical banners.

Now every command has a queued form. `Reachability` (ConnectivityManager, which
the manifest has had permission for since the start and never used) is asked
before the network is, so an offline tap is answered instantly; a request that
fails as NetworkError anyway lands in the same place one timeout later. The
optimistic state stays on screen, the local anchor keeps ticking, and the
snapshot is cached — so an interval started with no signal survives the process
being killed. `NET_CAPABILITY_VALIDATED` is deliberately not required: this
backend is routinely a LAN address, and demanding validation would call the one
setup the README recommends unreachable.

Replay runs on the next successful poll and the moment a network appears. In
order, stopping at the first thing that cannot go; at most once, with the state
re-read and probed rather than resent, because none of these endpoints is
idempotent; a 4xx dropped rather than retried eight times into a stuck queue.
An interval that both began and ended offline is quarantined and *said out loud*
— replaying it would stamp `started_at` as the reconnect moment and record a
pomodoro of no duration, quietly corrupting the statistics the app exists to
produce.

The poll finally declares the queue as `PendingOps`, which is what that type was
written for: a poll racing an unsent intent now keeps the dial still instead of
bouncing it.

The offline banner stops saying only that the countdown keeps running — the
sentence that made the dead buttons look intentional.

7 tests: an offline pause that never touches the network, the queue going out on
reconnect, two `+5`s coalescing into ten minutes, pause-then-resume annihilating,
the unfilable interval being reported, and an offline interval surviving a
restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:46:12 +03:00
timka123andClaude Opus 5 120c553537 android: keep what the server said, on disk
Everything the backend owns lived in memory, which made every claim about
working offline true for exactly as long as the process. Android kills processes
constantly, and starts them again for the accessibility service with no Activity
to have fetched anything — so the common case was the unhandled one:

* the focus guard woke with empty matchers, enforced nothing, and reported
  "disabled", which is indistinguishable from the user switching it off;
* the dial read 00:00 until a reply landed, and with no signal, forever;
* Tasks and Today showed an error banner where yesterday's answer would do.

`LocalStore` is one small JSON file per subject under `filesDir` — not Room (no
queries, no relations, no migrations worth the dependency) and not
SharedPreferences (those are for what this *machine* owns; these are documents).
Every read degrades to "no cache" rather than throwing, and a file that fails to
parse is deleted on the way past, because a background service must not be
crash-looped by its own cache.

The timer state is stamped with the wall-clock moment it was received, which is
what lets a cold start subtract the time that passed while nothing was running —
wall clock deliberately, since `nanoTime` does not survive a reboot and the
question is how much of the *day* went by. A paused interval is left where it
was. None of it is marked Online: a cache is not a reply.

The guard keeps the rules and the last policy. The verdict is not adopted from
disk — `active` is a claim about this moment — but the expanded host list and
the masks do not go stale, so the guard can enforce before the first poll.

Read-only screens take a cache slot, so `Loader` paints the last good answer
before it asks. Changing the backend URL clears the lot: one server's report
under another's name is worse than an empty screen.

10 tests, including the two cold-start cases that failed before: a dial that
knows what is running, and a guard that still blocks with the server gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:41:26 +03:00
timka123andClaude Opus 5 71a0389029 android: a command's reply is the answer, not a conflict
Half of "the buttons do nothing" happens with the network working perfectly.

`command()` passed `PendingOps(hasAny = true, …)` into `applyServer`, and
`PendingOps` is the *outbox's* vocabulary — it means "something of ours is
queued and has not landed". For a direct reply that is simply untrue, and
because the reply is for the interval already running, `reconcile` answered
`KeepOptimistic` and `applyServer` returned early, discarding the server's
status, remaining and planned seconds. Pause, resume, +5 min, Interrupted and
attach-task therefore changed nothing until the next poll, up to ten seconds
later. `toggle()` read the stale state, so the second tap fired another pause.

Two changes. `KeepOptimistic` now short-circuits only for *polls* — a poll
racing a queued intent is a real conflict and still keeps the dial still. And
the commands whose effect is knowable without asking apply it on the tap, then
let the server's reply overwrite it: paused, resumed, extendedBy, one more
interruption. Three small extension functions, deliberately a preview of the
server's answer rather than a second implementation of the timer.

`toggle()` returns its Job now, so the Glyph toy's long press, the Live Update's
buttons and the tests can all wait for the reply.

5 tests, on MockWebServer and the committed fixtures. The first of them fails
on the old code in the obvious way: the reply says paused, the UI says running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:35:46 +03:00
timka123andClaude Opus 5 bc6ff3c489 ci: gitea actions for the suites, the image and the APK
Four workflows, none of which existed:

* `tests.yml` — backend, desktop, `:core` and the web app's typecheck+build, in
  parallel. `:core` needs no Android SDK at all, because settings.gradle.kts
  leaves `:app` out when there is none; `:app`'s Robolectric suite therefore
  lives in the APK workflow, where the SDK is installed anyway. Nothing needs a
  running backend — the suites feed on committed fixtures, which is the whole
  reason they were written that way.
* `lint.yml` — ruff, which the repo had been configured for and never run.
* `docker.yml` — builds the image, *starts* it against SQLite and waits for
  /api/health before pushing, then publishes `:latest` and `:<short-sha>` to
  git.timka.su. A tag nobody can run is worse than no tag.
* `android-release.yml` — the release APK on every push to master, signed from
  secrets, with `apksigner verify --print-certs` reporting which key was really
  used and failing if it was the debug one.

Three things the repo needed before any of that could work:

`versionCode` was pinned at 1, so every APK looked like the same version to the
phone and would not install over its predecessor. It now comes from the run
number, with `versionName` from a property, both via one `setting()` helper that
reads a Gradle property or an environment variable.

The release build fell back to the *debug* signing key whenever the keystore was
missing — silently. That is a trap: the APK installs, looks fine, and can never
be upgraded by a properly signed build. `KADENCE_REQUIRE_SIGNING=1` makes that
case fail the build instead, and the release job sets it whenever a keystore
secret is present.

The compose files built an image with no name, so there was nothing to pull.
`image:` now names the registry tag while `up --build` still builds locally.

Also: `android/ci/install-sdk.sh` (idempotent, cacheable, and the one place that
knows `platforms;android-37.0` is spelled with the minor version), the runner as
a compose service with its config and a setup checklist, six make targets, and a
README section. No runner is registered yet — `make ci-runner` is step two of
that checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:33:27 +03:00
timka123andClaude Opus 5 ec7eedbc66 style: say which lint rules this repo keeps, then keep them
`[tool.ruff]` set a line length and a target version and nothing else, so which
rules ran depended on the installed ruff's defaults — fine while nobody ran it,
not fine for a CI job that can turn a push red because a tool changed its mind.

The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that
are each a real decision rather than a shrug: B008, because FastAPI's whole
dependency idiom is a call in a default argument; E501, because wrapping prose
is a formatter's business; and RUF001, because the reports and the bot speak
Russian and "ambiguous character" is the wrong complaint about a string the
user is meant to read.

The 73 findings that remained are fixed — mostly `datetime.timezone.utc` →
`datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order,
and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two
try/except/pass pairs, unpacking instead of list concatenation, one negated
return.

`ruff format` is deliberately *not* adopted: it would reformat 51 files, and
some of the layout here is hand-set (the comment tables, the DNS wire-format
structs) in ways a formatter flattens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:28:57 +03:00
timka123andClaude Opus 5 5facf41f10 blocker: an exception reads like a block, so make it behave like one
`youtube.com` on the block list catches `music.youtube.com` — a plain entry
covers its subtree. `youtube.com` on the *allow* list caught only itself,
because exceptions were glob-matched whatever they looked like and a plain name
has no metacharacters. The same text in two boxes of the same form meant a
subtree in one and a single hostname in the other, with nothing in the UI
saying so.

A plain exception now covers its subdomains; a masked one is glob-matched
exactly as before, which is still how you spare something narrower than a whole
subtree (`docs.*`, `*.docs.google.com`).

Three enforcers decide this and all three had to move together, or an exception
would work in the browser and not at the resolver: `blocker_service.is_allowed`,
`:core`'s `isAllowed`, and the desktop DNS stub's own allow check, which was
reusing the generic glob matcher. Parity tests on both sides, plus the two
sentences in the web UI and the README that promised the old behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:24:17 +03:00
timka123andClaude Opus 5 b9c0762f23 android: the website guard sees what it promised to see
Four defects in the capture-and-match path, each able to look like "the blocker
works incorrectly" on its own.

Masks were unenforced exactly while online. The online matcher was built with
`blockedDomains = dto.allDomains`, and `all_domains` is concrete hostnames only
— a mask is precisely what the backend could *not* expand, and it comes back in
`unexpandable`. Overwriting the raw entries emptied `DomainMatcher.rawEntries`,
so step 3 of `decide()` — the step a hosts file cannot do — was dead code with
the network up. `*gram*` blocked only offline, while the Guard screen and the
README both said it worked on the phone. The rules now go in unchanged, with
`all_domains` as `expandedHosts`, and `unexpandable` + a newly-read `allowed`
folded in so masks and exceptions survive a process that has never read the
settings document.

The event-text path never matched anything. `event.text` is a list and was
joined with a space before being handed over; `normalizeHost` rejects anything
containing a space, which is correct of it and left the as-you-type capture
dead for every event carrying more than one item. Each item is offered on its
own now.

Any editable node counted as an address bar — including every text field the
*page* renders. Typing a blocked hostname into a search box, a form or a chat
ejected you to the launcher, which is worse than a miss: it throws you off a
page you are allowed to be on. An unlabelled editable node is now believed only
near the top of the tree, a node whose view id names a URL field is believed
anywhere and ends the walk, and web-content containers are not descended into
at all — which also stops a content-heavy tab from spending the 250-node budget
before the toolbar is reached.

The guard armed up to 45 seconds late. `active` only changed on the blocker's
own poll, which is 45s apart while idle — the exact gap between tapping "Start
focus" and opening the thing you blocked. The application now subscribes the
guard to the timer, and `onTimerChanged()` applies `:core`'s local verdict at
once while the server is asked in the background. The rules are also read once
per `start()` rather than only from MainActivity and the Guard screen.

9 new tests. The per-node judgement moved out of the traversal to be reachable
from them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:22:12 +03:00
timka123andClaude Opus 5 7b7b50bc8e blockers: the report's cooldown was deciding whether to block
Both clients made the same mistake, in the same shape: the throttle that keeps
the daily report from filling with one row per poll was placed in front of the
enforcement call. So blocking worked exactly once per target, and the second
launch of a banned app — or the second visit to a banned site — simply went
through.

Android, `BlockerEngine`: `claim()` now gates only `report()`/`announce()`.
Enforcement runs on every decision behind a 1.2s debounce whose only job is to
stop the accessibility event and the usage-stats poll both pressing home for one
visit. An app still in front on the next pass is pressed again — one
GLOBAL_ACTION_HOME is a request, not a result — and a visit is no longer
consumed when no accessibility service is attached to consume it with, which
turned a missing grant into a silently unblocked app. `lastForeground` and
`lastBlockedApp` are @Volatile like every other field here; they are written
from the service's main thread and the poll coroutine both. The usage-stats
cadence now follows the server's `app_poll_seconds`, and the first look happens
at once rather than four seconds in.

Linux, `BlockerManager._enforce_apps`: the pid went into the dedupe set *before*
the kill was attempted, so a process whose signal was refused was exempt for the
rest of the interval, and a pid is reused — a relaunch landing on a recycled
number inherited the exemption. Processes are now identified by (pid, start
time) from /proc/<pid>/stat, the kill is attempted on every pass, and only the
report is throttled. The books are pruned of dead processes so an all-day
interval cannot leak.

`processes.terminate()` no longer busy-waits two seconds for the process to die
before escalating — on the desktop that wait ran on the thread drawing the
countdown. SIGTERM now, SIGKILL on a later pass once the grace has gone by,
which is also gentler than the old fixed two seconds. With nothing blocking,
the scan gets its own timer at the cadence the server asks for instead of
riding the five-second policy poll.

Neither enforcement path had any test: the desktop's blocker suite hardcoded an
empty app list and nothing anywhere touched BlockerEngine. 16 new tests, on
fakes and the committed fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:16:56 +03:00
timka123 5c6710ae3d docs: make targets and counts for the guard
desktop-test runs the 39 desktop tests in the backend venv (an absolute
path, so pytest does not warn about sys.prefix); the root README explains
that a '*' in the expansion list means every subdomain, and that the desktop
app answers the zones a hosts file cannot express.
2026-10-19 18:45:50 +03:00
timka123 50e9c68b2f android: the focus guard on the phone itself
The Guard screen shows the shared policy; two Android facilities enforce it.
An accessibility service reads the browser's URL bar (no /etc/hosts exists,
and the VPN slot belongs to the user's always-on VPN), sends blocked pages
home and records the attempt; UsageStatsManager spots blocked apps in front
and the service presses home — 'kill' cannot mean force-stop, and the KDoc
says so.

Masks mean what they mean on every client: a bare '*' in the expansion list
makes '*.site' cover every subdomain, and masks no hosts file can express
are matched as ordinary globs against every URL, so they work here even
where the desktop cannot route them. Offline, the kept blocker settings are
re-evaluated with :core's evaluatePolicy, so losing signal does not lose the
guard. Two limits are documented rather than papered over: in-app WebViews
are invisible to the URL-bar reader, and blocking degrades to reporting
without the service.

Core keeps the policy/matcher semantics testable on a plain JVM: 190 core
and 44 app tests. The screen's copy says junk labels are ignored, a '*' is
kept, and unroutable masks are enforced on this phone only.
2026-10-19 18:45:50 +03:00
timka123 36af1e9e83 desktop: route wildcard zones to a local DNS stub
A hosts file cannot express a wildcard, so '*.youtube.com' was only ever the
base domain plus the guessed labels. The patterns the server now hands over
as unexpandable are answered by a stub on 127.0.0.1, and the helper owns one
systemd-resolved drop-in that points just those zones at it:

  * the stub is listening before the drop-in is written, and the drop-in is
    removed before the stub stops, so a zone's queries never land on a dead
    port;
  * stub and route live as long as the app, not the interval: between
    intervals they forward everything, instead of restarting resolved (and
    dropping its cache) on every pomodoro;
  * a crashed run leaves a world-readable drop-in the next start adopts, and
    the stub comes back before anything else;
  * masks with nothing routable after their last '*' ('*gram*') are reported
    as unenforceable, not silently dropped.

The Blocker page now reports what is really in place — the hosts region, the
routed zones, and the masks this machine cannot do — rather than the whole
expansion. 39 desktop tests, including the ordering invariants.
2026-10-19 18:45:42 +03:00
timka123 a531a62afc web: say what the guard actually applies
The Blocker page showed all_domains even when nothing was in force, and its
copy still called a bare '*' an invalid expansion. Hostnames now comes from
the live list (empty while blocking is off), the routed DNS zones get their
own stat, and the banners distinguish junk labels from masks no hosts file
can express — which is what the server now reports.
2026-10-19 18:45:42 +03:00
timka123 a75a011edb backend: a bare * in subdomain_expansions means every subdomain
An expansion list of labels guesses which subdomains exist; a '*' entry
makes the guess unnecessary. parse_expansions now reports (labels, wildcard,
rejected) instead of silently keeping junk, expand_entry keeps the base
domain plus the configured labels and leaves '*.base' as a pattern when the
wildcard is on, and the policy grows the 'allowed' masks the desktop
resolver needs to serve exceptions.
2026-10-19 18:45:20 +03:00
timka123andClaude Opus 5 632dee1f12 docs: four pieces, not three
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 16:17:38 +03:00
timka123andClaude Opus 5 738734d485 docs: the phone client, and the Glyph traps worth not re-learning
The android README still described the app as unbuilt and pointed `glyph/libs/`
at a path that does not exist. It now covers what the app is made of, how to get
the SDK, and the three Glyph behaviours that cost an afternoon — two of which
fail with no exception and no log at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 16:17:25 +03:00
timka123andClaude Opus 5 535830bacc android: two reasons the Glyph stayed dark
Both silent, and each alone was enough to leave the matrix blank while
everything else in the app worked.

**1. `setAppMatrixFrame` does nothing on this firmware.** The developer kit is
explicit that app-driven control must use the `App` variants, so that is what
the first cut used. On Phone (3) build 2608141733 the call is accepted without
an exception and then dropped: it forwards to `IGlyphService.setAppMatrixColors`
and the system service never reaches the hardware. Measured over 20 seconds of
1 Hz pushes: `setAppMatrixFrame` produced 0 writes to the matrix light,
`setMatrixFrame` produced 232. Both paths now use the plain variant. The kit's
warning that it can fight a selected Glyph Toy is real, but a rear display that
never lights is the worse failure.

**2. `getDeviceMatrixLength()` returns the side, not the dot count.** The name
invites the opposite reading, and the SDK settles it: `DEVICE_23112_MATRIX_LENGTH`
is 25 and `DEVICE_23112_SIZE` is that squared. Taking a square root of 25 gave a
5x5 frame, so the renderer drew a few stray dots in one corner and `GlyphFrame.set`
silently discarded everything outside the grid — 7 lit dots instead of 78.

Also: `clear()` blanks by drawing an empty frame rather than calling
`closeAppMatrix()`, which was closing a channel we no longer open and so left
the last frame lit.

Verified on the device: 78 of 625 dots for a running interval, 234 hardware
writes in 20s, the frame rendering as minutes over seconds inside a draining
ring. 225 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 16:16:30 +03:00
timka123andClaude Opus 5 a7148b69c6 android: the real client — backend, notifications, Live Update, Glyph
The app module was a design gallery: tokens, type and a dial, with no backend,
no notification and nothing driving the rear matrix. This is the rest of it.

**One engine, three views.** `TimerEngine` lives on the Application, not in a
ViewModel, because the Compose screen, the foreground service's notification
and the Glyph must show the *same* second. Two engines polling the same backend
drift apart within seconds and the user sees a notification disagreeing with
the dial. It polls, ticks from a monotonic anchor, and puts every reply through
`:core`'s `reconcile`.

`pomodoro.state()` does not finish an interval whose time is up — it reports
`remaining_seconds = 0` and waits for a client to call `complete`. The tick
loop does, behind the same re-entry guard `desktop/controller.py` uses, since
without one a 1 Hz tick at zero fires `complete` every second until the reply
lands.

**Notifications.** Two channels, because the running interval must be silent
and its *end* must not; one channel cannot be both, and a user who silences a
buzzing countdown has silenced the only part that mattered. The ongoing one is
an Android 16 Live Update: promotion needs `setOngoing` + `setRequestPromotedOngoing`
+ a `ProgressStyle`, and missing any one renders it as an ordinary notification
with no error and no log. Its segments come from `:core`'s `dialTickCount`, so
the status bar, the dial and the Glyph ring agree on what one tick is worth.

**Glyph.** `GlyphMatrixManager.init()` binds `com.nothing.thirdparty/.GlyphService`
and *discards* the boolean `bindService` returns — so on Android 11+ a missing
`<queries>` entry makes the package invisible, the bind silently fail, and the
matrix stay dark with no exception, no log and `onServiceConnected` never
arriving. That was the whole of "the glyph does not work". The manifest now
declares it and `MatrixGlyphDisplay` checks visibility itself rather than
leaving the next person with the same dead end.

The SDK's licence forbids redistribution (clause 2.1(c)), so the .aar stays
git-ignored and `fetch-glyph-sdk.sh` downloads it. Without it a second source
set compiles a no-op display: the app builds, installs and runs with an inert
rear panel, which is also the correct behaviour on any phone that is not a
Nothing.

Verified on the device (Phone (3), Android 16), debug and release: onboarding
against kadence.timka.su, the Live Update with `shortCriticalText` and a 600s
progress track, the matrix accepting 625-dot frames, tasks opening in the Jira
app, and R8 leaving the DTOs decodable. 225 tests, none needing a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 16:05:13 +03:00
timka123andClaude Opus 5 5259e027c6 deploy: re-exec from a copy, so the script cannot rewrite itself mid-run
deploy.sh lives in the repo it updates. `git reset --hard` therefore replaced
the file bash was reading; bash keeps a byte offset into the script and resumed
at that offset in the new file, skipping whatever the diff had shifted.

That is how the previous commit's renewal hook ended up present on the server
and never executed: `grep` found it in deploy.sh, the hook directory was empty.

Stage one now updates the checkout and re-execs a copy under /tmp, which no
later `git reset` can touch; stage two does the work and removes the copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 15:11:01 +03:00
timka123andClaude Opus 5 9270bd70cb deploy: reload nginx after a certificate renewal
`certbot certonly` replaces the files on disk but signals nothing, so nginx
would have gone on serving the old certificate until some unrelated reload
happened — which is to say the cert would have quietly expired in January.
`--nginx` installs this hook itself; keeping the vhost in git means we
install it ourselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 15:04:08 +03:00
timka123andClaude Opus 5 eb49df58d7 deploy: http2 via the listen directive, for nginx 1.24
The server runs nginx 1.24.0; the standalone `http2 on;` directive only
arrived in 1.25.1, so `nginx -t` rejected the vhost as an unknown directive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 15:00:13 +03:00
timka123andClaude Opus 5 f25ccdec38 deploy: production compose overlay, nginx vhost and deploy script
Three pieces so kadence.timka.su can be stood up and updated from the repo
rather than from shell history on the box:

* `docker-compose.prod.yml` — publishes the app on loopback only. The base
  file's `${APP_PORT}:8000` binds every interface, which on this host both
  collides with remnawave (already on 8000) and would serve the API beside
  the proxy without TLS. Compose *appends* merged port lists, so the override
  needs the `!override` tag or the public binding survives. uvicorn also gets
  `--proxy-headers`, without which every request looks like plain HTTP from
  127.0.0.1 and the session cookie loses its `Secure` flag.
* `deploy/nginx/kadence.timka.su.conf` — TLS, HSTS, the forwarded headers the
  cookie logic reads, and a 120s read timeout because one task refresh fans
  out to four third-party APIs.
* `deploy/deploy.sh` — idempotent install-or-update. Generates API_TOKEN and
  the database password once and never rotates them afterwards (that would
  lock out the phone and the desktop app), treats `.env.example`'s
  `change-me` placeholders as unset, and uses `certbot certonly --webroot`
  rather than `--nginx` so the vhost in git stays the truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 14:56:57 +03:00
timka123 1e55144291 some backend fixes and add android app 2026-10-19 14:52:20 +03:00
timka123 772fdbfded add blocker presets 2026-10-19 11:54:15 +03:00
timka123 2be074f191 add auth 2026-10-19 09:41:32 +03:00
timka123 4bdbc8bfa8 various fixes 2026-10-19 09:19:47 +03:00
timka123 77b26757bf refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency
- Refactored TimerPage component to improve layout and user experience.
- Updated theme configuration to utilize design tokens for better maintainability.
- Introduced new tokens for colors and typography to align with design principles.
- Simplified state management and improved timer functionality.
- Enhanced accessibility and responsiveness of UI elements.
2026-10-18 23:57:36 +03:00
timka123 17a0a3c4c9 init 2026-10-18 18:02:05 +03:00