From ced8c5fec25bdd2b2c6633b1e8b25296b1c398b6 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 21:31:00 -0300 Subject: [PATCH 1/6] fix: the documents say what the program does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRIVACY promised that explorer, cmd and powershell are discarded as the origin of a link. They are not, and they should not be: a rule that sends everything coming from the shell to one browser is a choice, and the picker already names the rule that decided. The document now describes what happens instead of what somebody once thought should. The theme started dark while the language already followed the system, so a fresh install on a light desktop opened dark — and the settings window painted a dark first frame before the backend answered. Both follow the system now. The README claimed rule order decides. It cannot: upsert replaces any rule with the same scope and origin, so two never coexist and the most precise one wins. ruleUp and ruleDown translated a button that does not exist, and the English build-tree notice had lost the sentence that says the copy does not register. --- PRIVACY.md | 6 ++++-- README.md | 2 +- app/src/i18n.ts | 6 +----- app/src/theme.test.ts | 11 +++++++++-- app/src/theme.ts | 5 +++-- crates/linkunbound-core/src/prefs.rs | 6 +++--- 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 3f1640d..2cad850 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -64,8 +64,10 @@ macOS does not report which application opened a link, so the foreground app sta What this means in practice: - **Only the application name is read.** Not its windows, not its title, not - its contents. `explorer`, `cmd` and `powershell` are discarded, since they - are the shell itself rather than an app worth writing a rule about. + its contents. The shell counts as an application like any other: a link + opened from a file on the desktop reports `explorer`, and whether that is + worth a rule is yours to decide, not ours. The picker names the rule that + decided every time one does. - **It is read at the moment a link arrives**, not continuously. LinkUnbound does not watch which applications you use. - **It reaches the disk only if you ask it to.** The name is used in memory to diff --git a/README.md b/README.md index 0940709..919324b 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ Since LinkUnbound is an independent open source project, the installer is signed **Settings (tray):** double-click the tray icon or right-click → Settings. Six pages: - **Links** — whether the system sends links here, what is wrong when it does not and the button that fixes it; on Windows, the associations held -- **Rules** — every rule, in the order that decides; change where each opens, add one, remove one +- **Rules** — every rule, the most precise one deciding; change where each opens, add one, remove one - **Browsers** — what was detected, with profiles; hide, duplicate, or add a custom one with its arguments - **Application** — theme, language, picker look, start at sign-in, the global shortcut, background update checks - **Maintenance** — export a diagnostic report, rescan browsers, reset the configuration, remove LinkUnbound from the system's list diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 6b06148..5fd3a21 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -74,8 +74,6 @@ const ES = { rulesFooter: "Gana siempre la regla más precisa: una URL concreta antes que un subdominio, y este antes que el sitio entero. Las marcadas «Desde» ganan sobre todas y responden a los enlaces de una aplicación, sea cual sea la dirección.", rulePrivate: "privada", - ruleUp: "Subir la regla de {}", - ruleDown: "Bajar la regla de {}", ruleRemove: "Eliminar la regla de {}", ruleBrowser: "Navegador para la regla de {}", ruleDeleteTitle: "Eliminar la regla de {}", @@ -277,7 +275,7 @@ const EN: Record = { healthStale: "The registration points at another copy of LinkUnbound. Links will go there, not here, until you repair it.", healthBuildTree: - "You are running a freshly built copy. Windows cannot trust links to a path that disappears when the project is cleaned.", + "You are running a freshly built copy. Windows cannot trust links to a path that disappears when the project is cleaned, so this copy does not register.", healthWrongBinary: "The registration points at the settings window, which cannot open a link. Repair it so it points at the process that can.", healthNoResident: @@ -334,8 +332,6 @@ const EN: Record = { rulesFooter: "The most precise rule always wins: one URL before a subdomain, and that before the whole site. The ones marked «From» win over all of them, and answer the links of one application whatever the address.", rulePrivate: "private", - ruleUp: "Move the rule for {} up", - ruleDown: "Move the rule for {} down", ruleRemove: "Delete the rule for {}", ruleBrowser: "Browser for the rule for {}", ruleDeleteTitle: "Delete the rule for {}", diff --git a/app/src/theme.test.ts b/app/src/theme.test.ts index 2d34c7f..c65d8d2 100644 --- a/app/src/theme.test.ts +++ b/app/src/theme.test.ts @@ -40,10 +40,17 @@ describe("theme", () => { invoke.mockReset(); }); - it("is dark before the backend has said anything", () => { + it("follows the system before the backend has said anything", () => { systemPrefers(false); invoke.mockReturnValue(new Promise(() => {})); follow(); + expect(isDark()).toBe(false); + }); + + it("follows a dark system too, with nothing remembered", () => { + systemPrefers(true); + invoke.mockReturnValue(new Promise(() => {})); + follow(); expect(isDark()).toBe(true); }); @@ -117,6 +124,6 @@ describe("theme", () => { invoke.mockRejectedValue(new Error("no bridge")); follow(); await settled(); - expect(isDark()).toBe(true); + expect(isDark()).toBe(false); }); }); diff --git a/app/src/theme.ts b/app/src/theme.ts index f96e95c..30875f5 100644 --- a/app/src/theme.ts +++ b/app/src/theme.ts @@ -15,15 +15,16 @@ function paint(theme: Theme, systemIsDark: boolean) { } /// What was painted last time, so the first frame is not the default flashing before the -/// choice arrives. The default is dark, as a fresh install's is. +/// choice arrives. With nothing remembered it follows the system, as a fresh install does. function remembered(): Theme { try { const was = localStorage.getItem(REMEMBERED); if (was === "0") return "light"; + if (was === "1") return "dark"; } catch { // As above. } - return "dark"; + return "system"; } let reread: (() => void) | null = null; diff --git a/crates/linkunbound-core/src/prefs.rs b/crates/linkunbound-core/src/prefs.rs index c983555..3fc5461 100644 --- a/crates/linkunbound-core/src/prefs.rs +++ b/crates/linkunbound-core/src/prefs.rs @@ -3,9 +3,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Theme { + #[default] System, Light, - #[default] Dark, } @@ -144,9 +144,9 @@ mod tests { use super::{Locale, PickerStyle, Preferences, Theme}; #[test] - fn a_fresh_install_is_dark_and_claims_the_default_shortcut() { + fn a_fresh_install_follows_the_system_and_claims_the_default_shortcut() { let p = Preferences::default(); - assert_eq!(p.theme, Theme::Dark); + assert_eq!(p.theme, Theme::System); assert_eq!(p.locale, Locale::System); assert_eq!(p.shortcut.as_deref(), Some("Alt+Shift+L")); assert!(p.notify_on_rule); From 72560c14e228a9eccb5ea44c3e6936fadd15511b Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 21:53:18 -0300 Subject: [PATCH 2/6] docs: the readme leads with the product, and names the alternatives --- CONTRIBUTING.md | 15 ++++++++- README.md | 85 ++++++++++++++++++++++++------------------------- SECURITY.md | 4 +-- 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3742708..134db75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ We believe in: 1. **Fork** the repository. 2. **Choose the base branch:** - - `main` — active development. Version 2.0 lands here. + - `main` — active development. The 2.x line lands here. - `v1-stable` — the frozen 1.4.x line. Security and crash fixes only, no new features. 3. **Create a branch** from it (`git checkout -b feature/my-improvement`). 4. **Make your changes** following the style guide below. @@ -67,6 +67,19 @@ Found something confusing? Missing information? PRs welcome. --- +## Architecture + +Two binaries, one package: + +- `linkunbound-shell` → the resident: tray or menu bar, the picker, the notice; Windows and macOS run it for every link, and a second copy hands its link to the one already running and exits +- `linkunbound-settings` → the settings window, opened on demand, and the errands the resident sends it on with no window (`--look` asks the feed, `--update` installs) + +**Windows.** A named pipe of the user's own (`linkunbound-.sock`) links second instances to the resident, and holding its name is what keeps a second resident from starting. The installer writes the app's own ProgId, `RegisteredApplications` and `StartMenuInternet` keys; the settings window re-points them when the install moves and never recreates what the person took away; Windows itself owns the final choice through `UserChoice`, which no application may write. + +**macOS.** The bundle's `CFBundleExecutable` is the resident, so Launch Services starts it — or talks to the running copy — for every link. Nothing arrives on the command line: links, documents, launches and reopens come in as Apple Events (`GURL`, `odoc`, `oapp`, `rapp`), and the launch event says whether the session started the app as a login item, which is what keeps the settings window closed at sign-in. A Unix socket under `~/Library/Application Support/LinkUnbound/` carries links handed over from a terminal. Default-browser registration goes through `NSWorkspace.setDefaultApplication` for `http`, `https` and the web document types, which the system confirms with its own prompt; the browser that held the links before is remembered and gets them back on unregistering. Login items use `SMAppService`. A browser is started through `open` either way — plainly for a bare link, as an instance of its own when a private window or a profile rides along — so Launch Services starts it and it answers for its own permissions rather than for LinkUnbound's. The web document types are declared as an alternate opener: double-clicking an `.html` keeps opening wherever it did until the person chooses. The app runs as `LSUIElement`, so it lives in the menu bar instead of the Dock, and the picker floats above every Space, full-screen apps included. + +--- + ## Project Structure LinkUnbound is a Cargo workspace. Two binaries ship in one package: the diff --git a/README.md b/README.md index 919324b..fdd311f 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,6 @@ **Open source. Local-first. Privacy-first. Zero telemetry.**

- - Build Status - - - Quality Gate - - - Mutation score - - - Window mutation score - - - Coverage - Latest Release @@ -76,7 +61,7 @@ **LinkUnbound** is a free, open source **browser picker** for Windows and macOS — a small tool that makes itself the default browser and then asks *you* which real browser should open each link. Click a link in Teams, Outlook, Slack, Discord, a PDF, a terminal, anywhere: if you have a rule for it, the right browser opens at once; if not, a little **browser chooser** pops up next to your cursor and you pick with a click or a key. -It is the missing piece for anyone who lives with more than one browser: work in one, personal life in another, a client's tools in a third profile. Instead of changing the system default every week, you set a **rule per site, per subdomain, per exact address or per app** — "everything from Slack in Brave", "docs.google.com in the work profile of Chrome" — and the rest goes through the picker. A **default browser manager**, a **link router**, a **browser switcher**: call it what you like, it just routes links where you want them. +It is the missing piece for anyone who lives with more than one browser: work in one, personal life in another, a client's tools in a third profile. Instead of changing the system default every week, you set a **rule per site, per subdomain, per exact address or per app** — "everything from Slack in Brave", "docs.google.com in the work profile of Chrome" — and the rest goes through the picker. There is no company behind this. I am one developer who got tired of the operating system deciding which browser opens a link, built this for myself, and put it out for whoever has the same itch. That shapes everything about it: **no ads, no telemetry, no analytics, no accounts, no subscriptions, no cloud, no data collection** — the only thing it ever asks the network is whether a new version exists, and you can turn that off. Your rules live in a small JSON file on your own disk, and you can read every line of the code that touches them. @@ -85,7 +70,6 @@ There is no company behind this. I am one developer who got tired of the operati ## Table of Contents - [What It Does](#what-it-does) -- [What It Is / What It Isn't](#what-it-is--what-it-isnt) - [Privacy and Security](#privacy-and-security) - [Getting Started](#getting-started) - [How It Works](#how-it-works) @@ -96,6 +80,7 @@ There is no company behind this. I am one developer who got tired of the operati - [Want to Help?](#want-to-help) - [Support the Project](#support-the-project) - [Other Tools](#other-tools) +- [Alternatives](#alternatives) - [License](#license) --- @@ -118,23 +103,6 @@ There is no company behind this. I am one developer who got tired of the operati --- -## What It Is / What It Isn't - -**LinkUnbound is:** - -- A **local-first browser picker** and **default browser manager** for Windows and macOS -- A lightweight **browser switcher** and **browser selector** that works offline -- An **open source** tool you can trust — GPL v3, inspect every line, fork it, contribute - -**LinkUnbound is not:** - -- A browser, toolbar, or extension -- A telemetry or analytics tool -- A "platform" with accounts, subscriptions, or ads -- A corporate product — it's a personal project shared with the community - ---- - ## Privacy and Security **Everything stays local.** LinkUnbound is built on a single, non-negotiable principle: your data never leaves your computer. @@ -239,14 +207,7 @@ Rules are written from the picker — pick the reach, then the browser — or fr ## Architecture -Two binaries, one package: - -- `linkunbound-shell` → the resident: tray or menu bar, the picker, the notice; Windows and macOS run it for every link, and a second copy hands its link to the one already running and exits -- `linkunbound-settings` → the settings window, opened on demand, and the errands the resident sends it on with no window (`--look` asks the feed, `--update` installs) - -**Windows.** A named pipe of the user's own (`linkunbound-.sock`) links second instances to the resident, and holding its name is what keeps a second resident from starting. The installer writes the app's own ProgId, `RegisteredApplications` and `StartMenuInternet` keys; the settings window re-points them when the install moves and never recreates what the person took away; Windows itself owns the final choice through `UserChoice`, which no application may write. - -**macOS.** The bundle's `CFBundleExecutable` is the resident, so Launch Services starts it — or talks to the running copy — for every link. Nothing arrives on the command line: links, documents, launches and reopens come in as Apple Events (`GURL`, `odoc`, `oapp`, `rapp`), and the launch event says whether the session started the app as a login item, which is what keeps the settings window closed at sign-in. A Unix socket under `~/Library/Application Support/LinkUnbound/` carries links handed over from a terminal. Default-browser registration goes through `NSWorkspace.setDefaultApplication` for `http`, `https` and the web document types, which the system confirms with its own prompt; the browser that held the links before is remembered and gets them back on unregistering. Login items use `SMAppService`. A browser is started through `open` either way — plainly for a bare link, as an instance of its own when a private window or a profile rides along — so Launch Services starts it and it answers for its own permissions rather than for LinkUnbound's. The web document types are declared as an alternate opener: double-clicking an `.html` keeps opening wherever it did until the person chooses. The app runs as `LSUIElement`, so it lives in the menu bar instead of the Dock, and the picker floats above every Space, full-screen apps included. +Two binaries, one package: a resident that receives the links and draws the picker, and a settings window opened on demand. How they talk to each other on each system is in [CONTRIBUTING.md](CONTRIBUTING.md#architecture). **Coming from 1.x.** 2.0 is not compatible with the settings of the 1.x line, and does not promise to carry them over: it starts from what it can read and keeps the rest out of the way. On Windows it reads the rules and browsers of a standalone 1.x install where they are, and keeps a copy of the originals as `rules.1x.json` and `browsers.1x.json` before writing its own format; a copy installed from the Microsoft Store kept its files inside its package and they are not read. Going back to 1.x means restoring those copies over `rules.json` and `browsers.json`. Remove 1.x before installing 2.0 — the installer offers to — or the two fight over the same registration; a Store copy of 1.x has to be uninstalled by hand. On macOS 2.0 ships under the bundle identifier `dev.rgdevment.linkunbound`; 1.x was `com.rgdevment.linkunbound`, kept its files under that name, and nothing of it is read. macOS treats them as two applications: quit 1.x, remove it, then open Settings and set LinkUnbound as the default once more, and remove the old entry under System Settings → General → Login Items if one is left behind. @@ -333,6 +294,24 @@ I build free, open source tools focused on privacy and productivity. If you like --- +## Alternatives + +Other browser pickers, so you can pick the one that fits. Platform and licence checked against each project in September 2026; everything else changes, so go and look. + +| Project | Platform | Licence | +| :-- | :-- | :-- | +| [Finicky](https://github.com/johnste/finicky) | macOS | MIT | +| [Browserosaurus](https://github.com/will-stone/browserosaurus) | macOS | GPL-3.0, archived by its author | +| [BrowserPicker](https://github.com/mortenn/BrowserPicker) | Windows | MIT | +| [Browser Tamer](https://github.com/aloneguid/bt) | Windows, Linux | Apache-2.0 | +| [Junction](https://github.com/sonnyp/Junction) | Linux | GPL-3.0 | +| [Choosy](https://www.choosyosx.com/) | macOS, Windows | Paid | +| [Velja](https://sindresorhus.com/velja) | macOS | Free, closed source | + +What LinkUnbound does that most of these do not: **one build for Windows and macOS**, rules that match **the app a link came from** and not only its address, **browser profiles** detected on their own, and signed automatic updates. Finicky is configured in a file and is macOS only; Browserosaurus is no longer maintained; Junction is Linux only. + +--- + ## License **LinkUnbound** — A free, open source browser picker for Windows and macOS. @@ -358,4 +337,24 @@ from the lockfiles. I built LinkUnbound because I was tired of my OS not letting me choose which browser opens a link. This is a personal tool, built from a real need, shared because others might need it too. Free to use, free to inspect, free forever. -**Keywords:** browser picker, browser chooser, default browser manager, browser switcher, link router, URL router, link handler, multi-browser workflow, open source browser picker Windows, browser picker macOS, Microsoft Store browser picker, privacy-first link handler, local-first browser routing, no telemetry, work and personal browser separation, Teams links browser, Outlook links browser, Slack links browser, SafeLinks resolver, Browserosaurus alternative, Choosy alternative, BrowserPick alternative. +--- + +## Project health + +

+ + Build Status + + + Quality Gate + + + Mutation score + + + Window mutation score + + + Coverage + +

diff --git a/SECURITY.md b/SECURITY.md index 4392c53..badada5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,11 +35,11 @@ LinkUnbound makes exactly **one type of network request** on its own: a read-onl | Version | Supported | | :--------------- | :------------------------------------: | -| 2.0.x | Yes | +| 2.x | Yes | | 1.4.x | Security and crash fixes only | | Older than 1.4.x | No | -The 2.0 line is `main`. The 1.4.x line lives on the `v1-stable` branch and receives security and crash fixes only — no new features. Always use the [latest version](https://github.com/rgdevment/LinkUnbound/releases/latest). +The 2.x line is `main`. The 1.4.x line lives on the `v1-stable` branch and receives security and crash fixes only — no new features. Always use the [latest version](https://github.com/rgdevment/LinkUnbound/releases/latest). --- From af8e4c9f5dca72c274d5a446be9d2870d5a5b4a1 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 22:03:00 -0300 Subject: [PATCH 3/6] feat: the settings window gets a palette, and About is built from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were no design tokens: every component wrote its own hex, so the same five colours appeared 26, 21, 21, 20 and 20 times across six files and changing the blue meant forty-two edits. index.css now carries the palette in :root with a full dark counterpart, and the pieces About is made of — the brow, the what-is block, badges, the support rows, the tool rows, the update card, the progress bar and the alarms — are classes that read from it. About is also rebuilt with the anatomy the sibling project already uses: a badge in a gradient of the accent rather than a flat square, an eyebrow over the block that says what the program is, and icons in the four claims. Not one system colour is left hardcoded on that page; the six that remain are brand colours and have to be. The shared parts.tsx is untouched, so the other five pages keep working as they did. They are what moves next. --- app/src/index.css | 404 +++++++++++++++++++++++++++++++++++++ app/src/settings/About.tsx | 185 ++++++----------- app/src/settings/Icons.tsx | 49 +++++ 3 files changed, 518 insertions(+), 120 deletions(-) create mode 100644 app/src/settings/Icons.tsx diff --git a/app/src/index.css b/app/src/index.css index e9a4ae3..18cfe76 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -50,3 +50,407 @@ body { .dark .scroller:hover { scrollbar-color: rgb(255 255 255 / 0.2) transparent; } + +:root { + --panel: #ffffff; + --card: #fbfbfc; + --tile: #f1f2f6; + --hair: rgb(20 24 48 / 0.08); + --ink: #16181d; + --muted: #6b6d7a; + --accent: #2f62d8; + --accent-dim: #4f7ce4; + --accent-soft: rgb(47 98 216 / 0.09); + --danger: #c0362f; + --danger-soft: rgb(192 54 47 / 0.1); + --good: #1e7a52; +} + +.dark { + --panel: #191a1f; + --card: #1e2027; + --tile: #262a33; + --hair: rgb(255 255 255 / 0.08); + --ink: #f0f1f4; + --muted: #8b92a1; + --accent: #6e9bff; + --accent-dim: #4f7ce4; + --accent-soft: rgb(110 155 255 / 0.12); + --danger: #ff8a85; + --danger-soft: rgb(255 138 133 / 0.12); + --good: #4cc38a; +} + +.brow { + display: flex; + align-items: center; + gap: 13px; +} + +.brow .mark { + width: 50px; + height: 50px; + flex: none; + display: grid; + place-items: center; + border-radius: 12px; + background: linear-gradient(150deg, var(--accent), var(--accent-dim)); + color: var(--panel); + font-size: 24px; + font-weight: 700; +} + +.brow h2 { + margin: 0; + font-size: 20px; + font-weight: 640; + letter-spacing: -0.015em; +} + +.brow .line2 { + margin-top: 1px; + display: flex; + align-items: center; + gap: 8px; + font-size: 11.5px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.brow .line2 i { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--muted); +} + +.what-is { + margin-top: 15px; + border: 1px solid var(--hair); + border-radius: 11px; + background: var(--card); + padding: 12px 14px; +} + +.what-is p { + margin: 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--ink); +} + +.what-is p + p { + margin-top: 4px; + color: var(--muted); +} + +.eyebrow { + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 8px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--muted); +} + +.eyebrow svg { + width: 13px; + height: 13px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + border: 1px solid var(--hair); + border-radius: 7px; + padding: 3px 9px; + font-size: 11px; + color: var(--muted); +} + +.badge svg { + width: 11px; + height: 11px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.rule { + display: flex; + align-items: center; + gap: 10px; + margin: 22px 0 9px; + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); +} + +.rule::after { + content: ""; + flex: 1; + height: 1px; + background: var(--hair); +} + +.quiet { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: var(--muted); +} + +.gives { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; + margin-top: 10px; +} + +.give { + display: flex; + align-items: center; + gap: 10px; + text-align: left; + border: 1px solid var(--hair); + border-radius: 11px; + background: var(--card); + padding: 10px 12px; + cursor: pointer; + font: inherit; + color: var(--ink); +} + +.give.wide { + grid-column: span 2; +} + +.give:hover { + background: var(--tile); +} + +.give svg { + width: 17px; + height: 17px; + flex: none; +} + +.give b { + display: block; + font-size: 12.5px; + font-weight: 560; +} + +.give span span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11.5px; + color: var(--muted); +} + +.tool { + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + text-align: left; + border: 1px solid var(--hair); + border-radius: 11px; + background: transparent; + padding: 10px 12px; + margin-bottom: 7px; + cursor: pointer; + font: inherit; + color: var(--ink); +} + +.tool:hover { + background: var(--tile); +} + +.tool .ico { + width: 24px; + height: 24px; + flex: none; + display: grid; + place-items: center; + border-radius: 7px; + color: #ffffff; + font-size: 12px; + font-weight: 700; +} + +.tool b { + display: block; + font-size: 13px; + font-weight: 600; +} + +.tool span span { + display: block; + margin-top: 2px; + font-size: 11.5px; + line-height: 1.5; + color: var(--muted); +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 16px; +} + +.links button { + border: 0; + background: transparent; + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + font: inherit; + font-size: 11.5px; + color: var(--accent); +} + +.links button:hover { + background: var(--accent-soft); +} + +.newer { + margin-top: 17px; + display: flex; + align-items: center; + gap: 11px; + border: 1px solid var(--hair); + border-radius: 11px; + background: var(--card); + padding: 11px 13px; +} + +.newer.there { + border-color: var(--accent-soft); + background: var(--accent-soft); +} + +.newer .grow { + flex: 1; + min-width: 0; +} + +.newer b { + display: block; + font-size: 13px; + font-weight: 600; +} + +.newer .grow > span { + display: block; + margin-top: 1px; + font-size: 12.5px; + color: var(--muted); +} + +.pip { + width: 8px; + height: 8px; + flex: none; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.pip.ok { + background: var(--good); + box-shadow: 0 0 0 3px rgb(30 122 82 / 0.12); +} + +.dark .pip.ok { + box-shadow: 0 0 0 3px rgb(76 195 138 / 0.14); +} + +.mild { + flex: none; + border: 1px solid var(--hair); + border-radius: 7px; + background: transparent; + padding: 6px 12px; + cursor: pointer; + font: inherit; + font-size: 11.5px; + color: var(--ink); +} + +.mild:hover:not(:disabled) { + background: var(--tile); +} + +.mild:disabled { + opacity: 0.4; +} + +.strong { + flex: none; + border: 0; + border-radius: 7px; + background: var(--accent); + padding: 6px 12px; + cursor: pointer; + font: inherit; + font-size: 11.5px; + font-weight: 560; + color: var(--panel); +} + +.strong:disabled { + opacity: 0.5; +} + +.bar { + margin-top: 6px; + display: block; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: var(--hair); +} + +.bar span { + display: block; + height: 100%; + border-radius: 999px; + background: var(--accent); + transition: width 0.2s; +} + +.alarm { + margin: 0; + border-radius: 8px; + background: var(--danger-soft); + padding: 8px 12px; + font-size: 11.5px; + color: var(--danger); +} diff --git a/app/src/settings/About.tsx b/app/src/settings/About.tsx index d778a4c..29500a2 100644 --- a/app/src/settings/About.tsx +++ b/app/src/settings/About.tsx @@ -3,6 +3,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { useCallback, useEffect, useState } from "react"; import { type Key, useSpoken, useWords } from "../i18n"; import { offerMoved, saidPlainly } from "../refusal"; +import { CloudOff, Code, Gift, Info, Key as KeyIcon } from "./Icons"; import { Card, Line, Switch } from "./parts"; import Star from "./Star"; import type { Ready, Underway } from "./update"; @@ -33,28 +34,20 @@ function howItInstalls(newer: Ready): Key { function External({ href, children }: { href: string; children: string }) { return ( - ); } function Rule({ said }: { said: string }) { - return ( -
- {said} - -
- ); + return
{said}
; } -function Badge({ said }: { said: string }) { +function Badge({ said, children }: { said: string; children: React.ReactNode }) { return ( - + + {children} {said} ); @@ -74,21 +67,13 @@ function Gives({ children: React.ReactNode; }) { return ( - ); @@ -98,51 +83,32 @@ function Gives({ /// project, and a missing file here would leave a broken image in a row that is only a pointer. function Tool({ mark, - tone, + hue, said, note, onPick, }: { mark: string; - tone: string; + hue: string; said: string; note: string; onPick: () => void; }) { return ( - ); } function Pip({ ok }: { ok?: boolean }) { - return ( -