Show each site's own icon; restore the native title bar
Both previous attempts asked a question about a domain, and a domain does not know which product it is serving. Google's favicon service returned a marketing site's icon for anything behind a login and nothing for a private host; Simple Icons returned one flat brand mark where the real one is multicoloured and, for Gmail, carries the unread count. The page already holds the answer - fetched, authenticated, current. The injected script now reads link[rel~="icon"] and reports the best one: largest declared sizes wins, an Apple touch icon counts as 180, and an .ico is penalised as usually the 16px tab icon. It rechecks on the same tick as the unread count, which is when a site like Gmail redraws its icon with a badge. The URL is stored, so the nav is right at launch rather than blank until every page has loaded. The custom frame is gone with it: ordinary macOS title bar, traffic lights where every other window puts them, and the bar following the app's Light/Dark choice through set_theme. A window that behaves like a window beats one that looks bespoke.
This commit is contained in:
@@ -2,6 +2,3 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
src-tauri/target
|
src-tauri/target
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Generated from simple-icons by `npm run icons` on every build.
|
|
||||||
public/brand-icons.json
|
|
||||||
|
|||||||
@@ -252,47 +252,34 @@ a number that survived would be a claim the app can no longer support.
|
|||||||
|
|
||||||
## App marks
|
## App marks
|
||||||
|
|
||||||
Each app is drawn as its brand glyph in white on a round tile of its own colour, from
|
Each app shows **the icon its own page declares**, exactly as a browser would.
|
||||||
**Simple Icons** (CC0, ~3,400 marks). A host with no glyph gets its initial in the same
|
|
||||||
tile, so a private tool sits in the row looking like it belongs.
|
|
||||||
|
|
||||||
This replaced Google's favicon service, which was wrong as often as it was right: it
|
Two earlier attempts were wrong in the same way. Google's favicon service and Simple
|
||||||
returned a sign-in page's icon for anything behind a login, nothing at all for a private
|
Icons both answer a question about a *domain*, and a domain does not know which product
|
||||||
host, and cached both answers past any way of asking again. A "refresh icons" button could
|
it is serving: the service returned a marketing site's icon for anything behind a login
|
||||||
not fix that, because the staleness was not local.
|
and nothing for a private host, and Simple Icons returned a single flat brand mark where
|
||||||
|
the real one is multicoloured and, in Gmail's case, carries the unread count.
|
||||||
|
|
||||||
Each tile carries the **brand's own colour**, which Simple Icons publishes alongside the
|
The page has the answer already — fetched, authenticated, and current. So the injected
|
||||||
glyph — Gmail red, Drive blue, Chat green, Gemini violet. The glyph is drawn black or
|
script reads `link[rel~="icon"]` and reports the best one: largest declared `sizes` wins,
|
||||||
white depending on the tile's perceived brightness, because brand colours are chosen to
|
an Apple touch icon counts as 180, and an `.ico` is penalised because it is usually the
|
||||||
look right rather than to carry a white mark: GitHub and Notion are near-black, Snapchat
|
16px one drawn for a browser tab in 2005. It rechecks on the same tick as the unread
|
||||||
is pure yellow, and a fixed white glyph loses one end of that range.
|
count, because that is exactly when a site like Gmail redraws its icon with a badge on it.
|
||||||
|
|
||||||
A host with no mark falls back to a Tailwind 500 chosen by hashing the host, so it is
|
The URL is stored in `apps.json`, so the nav is right the moment it opens rather than
|
||||||
still stable — you find things by their colour, and one that moved every launch would be
|
blank until every page has loaded. An app with nothing yet — or an icon that will not
|
||||||
worse than none.
|
load — falls back to its initial in a tile of the same size, so the row never reflows.
|
||||||
|
|
||||||
The build turns Simple Icons' 15MB of SVG files into one 4.5MB map of slug to path and
|
|
||||||
hex,
|
|
||||||
written to `public/` so it is fetched once at runtime rather than parsed into the JS
|
|
||||||
bundle at every start. The bundle stays at 233KB. The map is generated by `npm run icons`,
|
|
||||||
which `npm run build` runs, so it is never committed and never stale.
|
|
||||||
|
|
||||||
Matching a host to a glyph tries the registrable name first — `example.odoo.com` is Odoo,
|
|
||||||
not Aputure — because a self-hosted tool is nearly always on a subdomain of its vendor. A
|
|
||||||
short table handles the ones a domain cannot answer, which is most of Google: `google.com`
|
|
||||||
says only that it is Google, not which of a dozen products.
|
|
||||||
|
|
||||||
## The window
|
## The window
|
||||||
|
|
||||||
There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin
|
The window keeps its ordinary macOS title bar, with the traffic lights where every other
|
||||||
around itself, and the traffic lights are pushed in to sit within it rather than crowding
|
window puts them. It follows the app's own Light or Dark choice through `set_theme`;
|
||||||
the corner — which also sets the depth of the nav's drag strip.
|
"System" hands it back to the OS, which is what System means.
|
||||||
|
|
||||||
An app's corners are rounded on its own layer, not by the container around it: an app is a
|
An earlier version drew its own frame instead — a hidden title bar, a margin to grab, and
|
||||||
native view sitting on top rather than something the shell lays out, so a `rounded-xl` on
|
a corner radius applied to each app's layer because a native view sitting on top of the
|
||||||
its parent does nothing and it overhangs the curve. Only the right pair is rounded; the
|
shell cannot be clipped by the CSS around it. It is gone. A window that behaves like a
|
||||||
left edge butts against the nav, and rounding it would cut a notch out of the middle of
|
window is worth more than one that looks bespoke.
|
||||||
the window.
|
|
||||||
|
|
||||||
The margin That margin is the only part of the window that is not a web page, and
|
The margin That margin is the only part of the window that is not a web page, and
|
||||||
therefore the only place left to grab it by. Full screen has no use for it and gets the
|
therefore the only place left to grab it by. Full screen has no use for it and gets the
|
||||||
|
|||||||
Generated
+1
-21
@@ -11,8 +11,7 @@
|
|||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0"
|
||||||
"simple-icons": "^16.29.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
@@ -2509,25 +2508,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-icons": {
|
|
||||||
"version": "16.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.29.0.tgz",
|
|
||||||
"integrity": "sha512-4H94f5ZgcCcgJroc902TFlFdgPu2IU2eD7+WebN2Z14xKYrKHeJ4UQcZwzgSuH4Rgzw+jp7sbHl40NefuIEYsg==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/simple-icons"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/simple-icons"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "CC0-1.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
+3
-5
@@ -5,18 +5,16 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npm run icons && tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"ship": "./scripts/ship.sh",
|
"ship": "./scripts/ship.sh"
|
||||||
"icons": "node scripts/build-brand-icons.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0"
|
||||||
"simple-icons": "^16.29.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Turns Simple Icons into one map of slug → [path, brand colour].
|
|
||||||
*
|
|
||||||
* The package ships a file per icon and 15MB of SVG wrapper around what is
|
|
||||||
* really a single `d` attribute each, plus a separate metadata file carrying
|
|
||||||
* the brand's own hex. This joins the two and throws the rest away.
|
|
||||||
*
|
|
||||||
* The output goes to public/ rather than src/: it is fetched once at runtime
|
|
||||||
* instead of being parsed into the JS bundle at every startup.
|
|
||||||
*
|
|
||||||
* `npm run build` runs this, so the result is never committed and never stale.
|
|
||||||
*/
|
|
||||||
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const ICONS = "node_modules/simple-icons/icons";
|
|
||||||
const META = "node_modules/simple-icons/data/simple-icons.json";
|
|
||||||
const OUT = "public/brand-icons.json";
|
|
||||||
|
|
||||||
const hexes = new Map();
|
|
||||||
for (const icon of JSON.parse(readFileSync(META, "utf8"))) {
|
|
||||||
hexes.set(icon.slug, icon.hex);
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons = {};
|
|
||||||
for (const file of readdirSync(ICONS)) {
|
|
||||||
if (!file.endsWith(".svg")) continue;
|
|
||||||
const slug = file.slice(0, -4);
|
|
||||||
const path = /\sd="([^"]+)"/.exec(readFileSync(join(ICONS, file), "utf8"))?.[1];
|
|
||||||
if (!path) continue;
|
|
||||||
icons[slug] = [path, hexes.get(slug) ?? "64748B"];
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(OUT, JSON.stringify(icons));
|
|
||||||
const kb = Math.round(readFileSync(OUT).length / 1024);
|
|
||||||
console.log(`${Object.keys(icons).length} icons → ${OUT} (${kb} KB)`);
|
|
||||||
@@ -41,6 +41,11 @@ impl AppState {
|
|||||||
config::save(&self.dir, &self.config.lock().unwrap())
|
config::save(&self.dir, &self.config.lock().unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same, for the sentinel handlers that live outside this module.
|
||||||
|
pub fn save(&self) -> Result<(), String> {
|
||||||
|
self.persist()
|
||||||
|
}
|
||||||
|
|
||||||
/// Records a selector chosen by right-clicking it in the page.
|
/// Records a selector chosen by right-clicking it in the page.
|
||||||
pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> {
|
pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> {
|
||||||
{
|
{
|
||||||
@@ -298,6 +303,7 @@ pub fn add_app(
|
|||||||
group_id,
|
group_id,
|
||||||
user_agent: None,
|
user_agent: None,
|
||||||
hidden: Vec::new(),
|
hidden: Vec::new(),
|
||||||
|
icon: None,
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
@@ -448,11 +454,27 @@ pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn set_theme(theme: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
state.config.lock().unwrap().settings.theme = theme;
|
state.config.lock().unwrap().settings.theme = theme.clone();
|
||||||
|
apply_window_theme(&app, &theme);
|
||||||
state.persist()
|
state.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Puts the window's own title bar in the same light or dark as the app.
|
||||||
|
///
|
||||||
|
/// `None` hands it back to the system, which is what "System" means — the bar
|
||||||
|
/// then follows the OS the way every other window does.
|
||||||
|
pub fn apply_window_theme(app: &AppHandle, theme: &str) {
|
||||||
|
let wanted = match theme {
|
||||||
|
"light" => Some(tauri::Theme::Light),
|
||||||
|
"dark" => Some(tauri::Theme::Dark),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(w) = app.get_window("main") {
|
||||||
|
let _ = w.set_theme(wanted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------- hidden elements
|
// ------------------------------------------------------- hidden elements
|
||||||
|
|
||||||
/// Replaces an app's hidden selectors and re-applies them without a reload.
|
/// Replaces an app's hidden selectors and re-applies them without a reload.
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ pub struct App {
|
|||||||
/// the thing you never want to see again.
|
/// the thing you never want to see again.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hidden: Vec<String>,
|
pub hidden: Vec<String>,
|
||||||
|
/// The icon this app's own page last reported, kept so the nav is right
|
||||||
|
/// from the moment it opens rather than once every page has loaded.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<String>,
|
||||||
/// Page zoom, remembered per app: a dense ERP and a mail client do not
|
/// Page zoom, remembered per app: a dense ERP and a mail client do not
|
||||||
/// want the same size.
|
/// want the same size.
|
||||||
#[serde(default = "default_zoom")]
|
#[serde(default = "default_zoom")]
|
||||||
@@ -187,6 +191,7 @@ pub fn seed() -> Config {
|
|||||||
group_id: Some(group.into()),
|
group_id: Some(group.into()),
|
||||||
user_agent: None,
|
user_agent: None,
|
||||||
hidden: Vec::new(),
|
hidden: Vec::new(),
|
||||||
|
icon: None,
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -445,6 +445,57 @@
|
|||||||
});
|
});
|
||||||
} catch (e) { window.Notification = WorkNotification; }
|
} catch (e) { window.Notification = WorkNotification; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- the icon */
|
||||||
|
|
||||||
|
/* The site's own icon, read off the page it is on.
|
||||||
|
|
||||||
|
An icon service asked about a domain can only guess, and it guesses badly:
|
||||||
|
it gets a marketing site's icon for a tool that lives behind a login, and
|
||||||
|
nothing at all for a private host. The page knows — it is carrying the
|
||||||
|
answer in its head, already fetched, already authenticated. Gmail even
|
||||||
|
redraws it with the unread count on it.
|
||||||
|
|
||||||
|
Rechecked on the same tick as the count, because that is exactly when a
|
||||||
|
site like Gmail swaps it. */
|
||||||
|
var lastIcon = null;
|
||||||
|
|
||||||
|
function bestIcon() {
|
||||||
|
var links = document.querySelectorAll(
|
||||||
|
'link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
|
||||||
|
);
|
||||||
|
var best = null;
|
||||||
|
var bestScore = -1;
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
var href = links[i].getAttribute('href');
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
|
/* Biggest declared size wins; an .ico is a last resort because it is
|
||||||
|
usually the 16px one drawn for a browser tab in 2005. */
|
||||||
|
var sizes = links[i].getAttribute('sizes') || '';
|
||||||
|
var size = parseInt((/(\d+)/.exec(sizes) || [])[1] || '0', 10);
|
||||||
|
var rel = (links[i].getAttribute('rel') || '').toLowerCase();
|
||||||
|
var score = size || (rel.indexOf('apple') !== -1 ? 180 : 32);
|
||||||
|
if (/\.ico(\?|$)/i.test(href)) score -= 24;
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return best ? new URL(best, document.baseURI).href : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIcon() {
|
||||||
|
var icon = bestIcon();
|
||||||
|
if (!icon || icon === lastIcon) return;
|
||||||
|
lastIcon = icon;
|
||||||
|
send('icon', { u: icon });
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------- unread counting */
|
/* --------------------------------------------------- unread counting */
|
||||||
|
|
||||||
/* Sites put their unread count in the title — "Inbox (12)", "(3) Chat".
|
/* Sites put their unread count in the title — "Inbox (12)", "(3) Chat".
|
||||||
@@ -491,6 +542,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInterval(checkUnread, 4000);
|
setInterval(checkUnread, 4000);
|
||||||
|
setInterval(checkIcon, 4000);
|
||||||
|
checkIcon();
|
||||||
setInterval(poke, 45000);
|
setInterval(poke, 45000);
|
||||||
|
|
||||||
/* ------------------------------------------------------------- start */
|
/* ------------------------------------------------------------- start */
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ pub fn run() {
|
|||||||
// Asked for at startup rather than at the first notification, so
|
// Asked for at startup rather than at the first notification, so
|
||||||
// the prompt does not arrive attached to someone else's message.
|
// the prompt does not arrive attached to someone else's message.
|
||||||
commands::ensure_notification_permission(&app.handle().clone());
|
commands::ensure_notification_permission(&app.handle().clone());
|
||||||
|
|
||||||
|
let theme = app.state::<commands::AppState>().cfg().settings.theme;
|
||||||
|
commands::apply_window_theme(&app.handle().clone(), &theme);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ pub struct NotificationClick {
|
|||||||
pub notification_id: String,
|
pub notification_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IconEvent {
|
||||||
|
pub app_id: String,
|
||||||
|
pub icon: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct HiddenEvent {
|
pub struct HiddenEvent {
|
||||||
@@ -183,6 +190,31 @@ fn handle_sentinel(
|
|||||||
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The site told us its own icon. Stored rather than merely shown,
|
||||||
|
// so the nav is right at launch instead of blank until every page
|
||||||
|
// has finished loading.
|
||||||
|
"icon" => {
|
||||||
|
let Some(url) = params.get("u") else { return };
|
||||||
|
let state = handle.state::<crate::commands::AppState>();
|
||||||
|
let changed = {
|
||||||
|
let mut cfg = state.config.lock().unwrap();
|
||||||
|
match cfg.apps.iter_mut().find(|a| a.id == from) {
|
||||||
|
Some(a) if a.icon.as_deref() != Some(url.as_str()) => {
|
||||||
|
a.icon = Some(url.clone());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
let _ = state.save();
|
||||||
|
let _ = handle.emit(
|
||||||
|
"icon-changed",
|
||||||
|
IconEvent { app_id: from.clone(), icon: url.clone() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"manage" => {
|
"manage" => {
|
||||||
let _ = handle.emit("manage-hidden", from.clone());
|
let _ = handle.emit("manage-hidden", from.clone());
|
||||||
}
|
}
|
||||||
@@ -722,6 +754,7 @@ mod tests {
|
|||||||
group_id: None,
|
group_id: None,
|
||||||
user_agent: None,
|
user_agent: None,
|
||||||
hidden: vec![".ad".into()],
|
hidden: vec![".ad".into()],
|
||||||
|
icon: None,
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
order: 0,
|
order: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,10 +18,7 @@
|
|||||||
"height": 900,
|
"height": 900,
|
||||||
"minWidth": 900,
|
"minWidth": 900,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"center": true,
|
"center": true
|
||||||
"titleBarStyle": "Overlay",
|
|
||||||
"hiddenTitle": true,
|
|
||||||
"trafficLightPosition": { "x": 26, "y": 24 }
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": { "csp": null }
|
"security": { "csp": null }
|
||||||
|
|||||||
+2
-41
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
|
|
||||||
import * as api from "./api";
|
import * as api from "./api";
|
||||||
import Nav from "./components/Nav";
|
import Nav from "./components/Nav";
|
||||||
@@ -16,8 +15,6 @@ export default function App() {
|
|||||||
const [focusHidden, setFocusHidden] = useState<string | null>(null);
|
const [focusHidden, setFocusHidden] = useState<string | null>(null);
|
||||||
const [theme, setTheme] = useAppearance("system");
|
const [theme, setTheme] = useAppearance("system");
|
||||||
const [unread, setUnread] = useState<Record<string, number>>({});
|
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
|
||||||
const fullscreenRef = useRef(false);
|
|
||||||
const [backdrop, setBackdrop] = useState<string | null>(null);
|
const [backdrop, setBackdrop] = useState<string | null>(null);
|
||||||
|
|
||||||
const stageRef = useRef<HTMLDivElement>(null);
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -29,23 +26,6 @@ export default function App() {
|
|||||||
|
|
||||||
const collapsed = config?.settings.navCollapsed ?? false;
|
const collapsed = config?.settings.navCollapsed ?? false;
|
||||||
|
|
||||||
/* The window has no frame of its own, so in a normal window the shell keeps
|
|
||||||
a margin around itself: somewhere to grab that is not a web page, and the
|
|
||||||
only way to move or place the window by hand. Full screen has no use for
|
|
||||||
it and gives the room back. */
|
|
||||||
useEffect(() => {
|
|
||||||
const w = getCurrentWindow();
|
|
||||||
const check = () =>
|
|
||||||
void w.isFullscreen().then((f) => {
|
|
||||||
fullscreenRef.current = f;
|
|
||||||
setFullscreen(f);
|
|
||||||
});
|
|
||||||
check();
|
|
||||||
const un = w.onResized(check);
|
|
||||||
return () => {
|
|
||||||
void un.then((f) => f());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getConfig().then((c) => {
|
api.getConfig().then((c) => {
|
||||||
@@ -67,9 +47,7 @@ export default function App() {
|
|||||||
const el = stageRef.current;
|
const el = stageRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
// The radius goes with it: an app is a native view, so the container's
|
void api.setStage(r.x, r.y, r.width, r.height, 0);
|
||||||
// rounded corners cannot clip it and its own layer has to be told.
|
|
||||||
void api.setStage(r.x, r.y, r.width, r.height, fullscreenRef.current ? 0 : 12);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -94,9 +72,6 @@ export default function App() {
|
|||||||
void api.bootstrap();
|
void api.bootstrap();
|
||||||
}, [config, report]);
|
}, [config, report]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
report();
|
|
||||||
}, [fullscreen, report]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeId) void api.setActive(activeId);
|
if (activeId) void api.setActive(activeId);
|
||||||
@@ -186,20 +161,8 @@ export default function App() {
|
|||||||
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
const frame = fullscreen ? 0 : 6;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex h-screen">
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-screen bg-slate-200 dark:bg-black"
|
|
||||||
style={{ padding: frame }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"flex min-w-0 flex-1 overflow-hidden " +
|
|
||||||
(fullscreen ? "" : "rounded-xl border border-slate-300 dark:border-slate-800")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Nav
|
<Nav
|
||||||
config={config}
|
config={config}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
@@ -241,8 +204,6 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<Settings
|
<Settings
|
||||||
config={config}
|
config={config}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* Brand glyphs for the nav, from Simple Icons (CC0).
|
|
||||||
*
|
|
||||||
* The favicon service this replaced was wrong as often as it was right: it
|
|
||||||
* cached a sign-in page's icon for anything behind a login, served nothing at
|
|
||||||
* all for private hosts, and could not be made to forget either. These are
|
|
||||||
* local, so they are the same every time.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** A glyph's path, and the brand's own colour. */
|
|
||||||
export type BrandIcon = [path: string, hex: string];
|
|
||||||
|
|
||||||
/** Fetched once, lazily — it is four megabytes and nothing needs it at boot. */
|
|
||||||
let pending: Promise<Record<string, BrandIcon>> | null = null;
|
|
||||||
|
|
||||||
export function loadBrandIcons(): Promise<Record<string, BrandIcon>> {
|
|
||||||
pending ??= fetch("/brand-icons.json")
|
|
||||||
.then((r) => (r.ok ? r.json() : {}))
|
|
||||||
.catch(() => ({}));
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hosts whose icon is not simply their domain name.
|
|
||||||
*
|
|
||||||
* Everything Google serves off `google.com` needs this, since the domain says
|
|
||||||
* only that it is Google and not which of a dozen products you are looking at.
|
|
||||||
*/
|
|
||||||
const KNOWN: Record<string, string> = {
|
|
||||||
"gemini.google.com": "googlegemini",
|
|
||||||
"aistudio.google.com": "googlegemini",
|
|
||||||
"notebooklm.google.com": "googlegemini",
|
|
||||||
"mail.google.com": "gmail",
|
|
||||||
"drive.google.com": "googledrive",
|
|
||||||
"chat.google.com": "googlechat",
|
|
||||||
"calendar.google.com": "googlecalendar",
|
|
||||||
"docs.google.com": "googledocs",
|
|
||||||
"sheets.google.com": "googlesheets",
|
|
||||||
"slides.google.com": "googleslides",
|
|
||||||
"meet.google.com": "googlemeet",
|
|
||||||
"keep.google.com": "googlekeep",
|
|
||||||
"photos.google.com": "googlephotos",
|
|
||||||
"analytics.google.com": "googleanalytics",
|
|
||||||
"ads.google.com": "googleads",
|
|
||||||
"console.cloud.google.com": "googlecloud",
|
|
||||||
"outlook.office.com": "microsoftoutlook",
|
|
||||||
"outlook.office365.com": "microsoftoutlook",
|
|
||||||
"teams.microsoft.com": "microsoftteams",
|
|
||||||
"onedrive.live.com": "microsoftonedrive",
|
|
||||||
"sharepoint.com": "microsoftsharepoint",
|
|
||||||
"web.whatsapp.com": "whatsapp",
|
|
||||||
"news.ycombinator.com": "ycombinator",
|
|
||||||
"x.com": "x",
|
|
||||||
"app.asana.com": "asana",
|
|
||||||
"app.slack.com": "slack",
|
|
||||||
"claude.ai": "claude",
|
|
||||||
"chatgpt.com": "openai",
|
|
||||||
"chat.openai.com": "openai",
|
|
||||||
"linear.app": "linear",
|
|
||||||
"app.clickup.com": "clickup",
|
|
||||||
"app.hubspot.com": "hubspot",
|
|
||||||
"admin.shopify.com": "shopify",
|
|
||||||
"dash.cloudflare.com": "cloudflare",
|
|
||||||
"mail.proton.me": "protonmail",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Simple Icons slug for a host, or null to fall back to a monogram.
|
|
||||||
*
|
|
||||||
* Tries the registrable name first — `example.odoo.com` is Odoo, not Aputure —
|
|
||||||
* because a self-hosted tool is nearly always on a subdomain of the vendor.
|
|
||||||
*/
|
|
||||||
export function slugForHost(host: string, icons: Record<string, BrandIcon>): string | null {
|
|
||||||
const h = host.toLowerCase().replace(/^www\./, "");
|
|
||||||
if (KNOWN[h]) return KNOWN[h] in icons ? KNOWN[h] : null;
|
|
||||||
|
|
||||||
const parts = h.split(".");
|
|
||||||
const candidates = [
|
|
||||||
parts.length >= 2 ? parts[parts.length - 2] : null, // odoo.com → odoo
|
|
||||||
h.replace(/\./g, ""), // x.com → xcom
|
|
||||||
parts[0], // github.com → github
|
|
||||||
parts.length >= 3 ? `${parts[0]}${parts[parts.length - 2]}` : null,
|
|
||||||
].filter((c): c is string => !!c);
|
|
||||||
|
|
||||||
return candidates.find((c) => c in icons) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fallback palette, for a host with no brand mark of its own.
|
|
||||||
*
|
|
||||||
* Tailwind's 500s. The choice is a hash rather than a random number so an app
|
|
||||||
* keeps its colour — you learn where things are by their colour, and one that
|
|
||||||
* moved every launch would be worse than none.
|
|
||||||
*/
|
|
||||||
const PALETTE = [
|
|
||||||
"#0ea5e9", // sky
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#d946ef", // fuchsia
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#f43f5e", // rose
|
|
||||||
"#f97316", // orange
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#84cc16", // lime
|
|
||||||
"#22c55e", // green
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#14b8a6", // teal
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#a855f7", // purple
|
|
||||||
"#64748b", // slate, for the ones that want to be quiet
|
|
||||||
];
|
|
||||||
|
|
||||||
export function colourFor(key: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < key.length; i++) {
|
|
||||||
hash = (hash * 31 + key.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return PALETTE[Math.abs(hash) % PALETTE.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hostOf(url: string): string {
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname;
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Black or white for a glyph, whichever the tile can actually be read against.
|
|
||||||
*
|
|
||||||
* Brand colours are chosen to look right, not to carry a white glyph: GitHub
|
|
||||||
* and Notion are near-black, Snapchat is pure yellow. Perceived brightness
|
|
||||||
* decides, so both ends of that range stay legible.
|
|
||||||
*/
|
|
||||||
export function glyphOn(hex: string): string {
|
|
||||||
const n = Number.parseInt(hex.replace("#", ""), 16);
|
|
||||||
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
||||||
// Rec. 601 luma: green reads far brighter than blue at the same value.
|
|
||||||
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#0f172a" : "#ffffff";
|
|
||||||
}
|
|
||||||
+2
-11
@@ -23,13 +23,6 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
const RAIL = 72;
|
const RAIL = 72;
|
||||||
const PANEL = 240;
|
const PANEL = 240;
|
||||||
/**
|
|
||||||
* The strip the traffic lights sit in. Draggable, since there is no title bar.
|
|
||||||
*
|
|
||||||
* Deep enough to give them room: with the window's own frame around it too,
|
|
||||||
* anything shallower leaves them crowding the corner.
|
|
||||||
*/
|
|
||||||
const TITLEBAR = 48;
|
|
||||||
|
|
||||||
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
||||||
|
|
||||||
@@ -110,7 +103,7 @@ export default function Nav({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
||||||
<Favicon url={app.url} name={app.name} size={20} />
|
<Favicon icon={app.icon} name={app.name} size={20} />
|
||||||
{(unread[app.id] ?? 0) > 0 && (
|
{(unread[app.id] ?? 0) > 0 && (
|
||||||
<span
|
<span
|
||||||
title={`${unread[app.id]} new since you last looked`}
|
title={`${unread[app.id]} new since you last looked`}
|
||||||
@@ -126,7 +119,6 @@ export default function Nav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
||||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
|
|
||||||
{/* Only the expander survives the rail's header. Back and forward are
|
{/* Only the expander survives the rail's header. Back and forward are
|
||||||
a two-finger swipe and reload is ⌘R, so a toolbar here would be
|
a two-finger swipe and reload is ⌘R, so a toolbar here would be
|
||||||
clutter standing in for something nobody asked for. */}
|
clutter standing in for something nobody asked for. */}
|
||||||
@@ -172,7 +164,7 @@ export default function Nav({
|
|||||||
title={app.url}
|
title={app.url}
|
||||||
className={`${row} ${app.id === activeId ? active : inactive}`}
|
className={`${row} ${app.id === activeId ? active : inactive}`}
|
||||||
>
|
>
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon icon={app.icon} name={app.name} />
|
||||||
<span className="truncate">{app.name}</span>
|
<span className="truncate">{app.name}</span>
|
||||||
{badge(app.id, app.id === activeId)}
|
{badge(app.id, app.id === activeId)}
|
||||||
</button>
|
</button>
|
||||||
@@ -181,7 +173,6 @@ export default function Nav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={shell} style={{ width: PANEL }}>
|
<aside className={shell} style={{ width: PANEL }}>
|
||||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="shrink-0" />
|
|
||||||
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
|
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
|
||||||
{controls}
|
{controls}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function Settings({
|
|||||||
key={app.id}
|
key={app.id}
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
||||||
>
|
>
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon icon={app.icon} name={app.name} />
|
||||||
<input
|
<input
|
||||||
value={app.name}
|
value={app.name}
|
||||||
onChange={(e) => patch(app, { name: e.target.value })}
|
onChange={(e) => patch(app, { name: e.target.value })}
|
||||||
@@ -183,7 +183,7 @@ export default function Settings({
|
|||||||
{withHidden.map((app) => (
|
{withHidden.map((app) => (
|
||||||
<div key={app.id} className={SUBPANEL}>
|
<div key={app.id} className={SUBPANEL}>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon icon={app.icon} name={app.name} />
|
||||||
<span className="text-[12px] font-medium">{app.name}</span>
|
<span className="text-[12px] font-medium">{app.name}</span>
|
||||||
<Badge tone="accent">{app.hidden.length}</Badge>
|
<Badge tone="accent">{app.hidden.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,7 +231,7 @@ export default function Settings({
|
|||||||
key={app.id}
|
key={app.id}
|
||||||
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
||||||
>
|
>
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon icon={app.icon} name={app.name} />
|
||||||
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|||||||
+29
-52
@@ -5,16 +5,7 @@
|
|||||||
* Ported from FlightTube: slate and sky, a 9–15px type ladder, outline-first
|
* Ported from FlightTube: slate and sky, a 9–15px type ladder, outline-first
|
||||||
* controls, borders for separation and shadows only for elevation.
|
* controls, borders for separation and shadows only for elevation.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import {
|
|
||||||
colourFor,
|
|
||||||
glyphOn,
|
|
||||||
hostOf,
|
|
||||||
loadBrandIcons,
|
|
||||||
slugForHost,
|
|
||||||
type BrandIcon,
|
|
||||||
} from "../brandIcons";
|
|
||||||
|
|
||||||
/** Every control in the app is this tall, so a row of mixed ones lines up. */
|
/** Every control in the app is this tall, so a row of mixed ones lines up. */
|
||||||
export const CONTROL_H = "h-[30px]";
|
export const CONTROL_H = "h-[30px]";
|
||||||
@@ -210,64 +201,50 @@ export function Dialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An app's mark: its brand glyph, white, on a round tile of its own colour.
|
* An app's mark: the icon the site itself declared, as the browser would use.
|
||||||
*
|
*
|
||||||
* Local rather than fetched. The favicon service this replaced returned a
|
* Not an icon service. Asked about a domain, a service can only guess, and it
|
||||||
* sign-in page's icon for anything behind a login, nothing at all for a
|
* guesses badly — a marketing site's icon for a tool behind a login, nothing
|
||||||
* private host, and cached both answers past any way of asking again.
|
* at all for a private host, and no way to make it reconsider. The page is
|
||||||
|
* carrying the real answer already, so it reports it and this draws it.
|
||||||
*/
|
*/
|
||||||
export function Favicon({
|
export function Favicon({
|
||||||
url,
|
icon,
|
||||||
name,
|
name,
|
||||||
size = 16,
|
size = 16,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
icon?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
const [icons, setIcons] = useState<Record<string, BrandIcon> | null>(null);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let live = true;
|
|
||||||
void loadBrandIcons().then((i) => live && setIcons(i));
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const host = hostOf(url);
|
|
||||||
const slug = icons ? slugForHost(host, icons) : null;
|
|
||||||
const icon = slug ? icons?.[slug] : undefined;
|
|
||||||
// The brand's own colour where there is one; a stable stand-in where there
|
|
||||||
// is not, so an unbranded tool still reads as a distinct thing in the list.
|
|
||||||
const colour = icon ? `#${icon[1]}` : colourFor(host || name);
|
|
||||||
const ink = glyphOn(colour);
|
|
||||||
const letter = name.trim().charAt(0).toUpperCase() || "?";
|
const letter = name.trim().charAt(0).toUpperCase() || "?";
|
||||||
|
|
||||||
|
if (!icon || failed) {
|
||||||
|
/* Until a page has loaded and said, or if its icon will not load: the
|
||||||
|
initial, in a tile the same size, so the row does not reflow later. */
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="grid shrink-0 place-items-center rounded-full"
|
|
||||||
style={{ width: size, height: size, background: colour }}
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
className="grid shrink-0 place-items-center rounded bg-slate-200 font-semibold
|
||||||
{icon ? (
|
text-slate-500 dark:bg-slate-700 dark:text-slate-300"
|
||||||
<svg
|
style={{ width: size, height: size, fontSize: Math.max(8, size * 0.5) }}
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill={ink}
|
|
||||||
style={{ width: size * 0.58, height: size * 0.58 }}
|
|
||||||
>
|
|
||||||
<path d={icon[0]} />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
/* No glyph for this host — its initial, in the same round tile, so a
|
|
||||||
private tool sits in the row looking like it belongs. */
|
|
||||||
<span
|
|
||||||
className="font-semibold leading-none"
|
|
||||||
style={{ fontSize: Math.max(8, size * 0.5), color: ink }}
|
|
||||||
>
|
>
|
||||||
{letter}
|
{letter}
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
</span>
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={icon}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
className="shrink-0 rounded-[3px] object-contain"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface WorkApp {
|
|||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
/** CSS selectors this app hides on every page. */
|
/** CSS selectors this app hides on every page. */
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
|
/** The icon the site itself declared, as the browser would use. */
|
||||||
|
icon: string | null;
|
||||||
/** Page zoom, remembered per app. */
|
/** Page zoom, remembered per app. */
|
||||||
zoom: number;
|
zoom: number;
|
||||||
order: number;
|
order: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user