Skip to content

Commit

Merge branch 'main' of github.com:flagon-io/g1t

syntaqxcommitted Parentse39ae9ccb15916Browse files
26 files+878−4550/26 viewed
+0−7
2929 user-select: none;
3030 cursor: default;
3131 }
32−/* Under the app's own title bar (src/frame.rs): the page fills what is below it. */
33−html[data-titlebar] {
34− padding-top: 0;
35−}
36−html[data-titlebar] body {
37− padding-top: 36px;
38−}
3932 main {
4033 width: min(26rem, calc(100vw - 2rem));
4134 text-align: center;
+42−0
2626 user-select: none;
2727 cursor: default;
2828 }
29+/* g1t's own title bar, when the window has no system frame: fixed at the top, 36px tall. */
30+.titlebar {
31+ position: fixed;
32+ inset: 0 0 auto 0;
33+ z-index: 10;
34+ height: 36px;
35+ display: flex;
36+ align-items: center;
37+ justify-content: space-between;
38+ padding-left: 14px;
39+ background: #050507;
40+ color: var(--muted);
41+ font-size: 12px;
42+ font-weight: 500;
43+}
44+.titlebar[hidden] {
45+ display: none;
46+}
47+.mac .titlebar {
48+ padding-left: 78px;
49+}
50+.mac .titlebar .caption {
51+ display: none;
52+}
53+.titlebar .caption {
54+ all: unset;
55+ display: inline-flex;
56+ align-items: center;
57+ justify-content: center;
58+ width: 46px;
59+ height: 36px;
60+}
61+.titlebar .caption:hover {
62+ background: #c42b1c;
63+ color: #fff;
64+}
65+/* Under it, the page scrolls on its own, so its scrollbar starts below the bar. */
66+body.framed .page {
67+ position: fixed;
68+ inset: 36px 0 0 0;
69+ overflow-y: auto;
70+}
2971 main {
3072 max-width: 36rem;
3173 margin: 0 auto;
+98−86
1313 <link rel="stylesheet" href="settings.css" />
1414 </head>
1515 <body>
16− <main>
17− <h1>Settings</h1>
18− <p class="muted lede">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</p>
16+ <!-- Its title bar, when g1t draws its own (src/frame.rs): drag it to move the window. -->
17+ <header class="titlebar" id="titlebar" hidden>
18+ <span>Settings</span>
19+ <button type="button" id="close-window" class="caption" aria-label="Close" title="Close" tabindex="-1">
20+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" aria-hidden="true">
21+ <line x1="4" y1="4" x2="12" y2="12" />
22+ <line x1="12" y1="4" x2="4" y2="12" />
23+ </svg>
24+ </button>
25+ </header>
26+ <div class="page">
27+ <main>
28+ <h1>Settings</h1>
29+ <p class="muted lede">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</p>
1930
20− <section aria-labelledby="general-heading">
21− <h2 id="general-heading">Starting and closing</h2>
22− <label class="setting">
23− <span><strong>Open g1t when you sign in</strong><span class="muted" id="open-at-login-note">g1t starts with your computer.</span></span>
24− <input type="checkbox" role="switch" id="openAtLogin" />
25− </label>
26− <label class="setting">
27− <span><strong>Start in the tray</strong><span class="muted" id="start-hidden-note">Opened at sign-in, g1t waits in the tray without opening its window.</span></span>
28− <input type="checkbox" role="switch" id="startHidden" />
29− </label>
30− <label class="setting">
31− <span><strong>Keep g1t running when the window closes</strong><span class="muted">It stays in the tray for notifications. Turned off, closing the window quits.</span></span>
32− <input type="checkbox" role="switch" id="closeToTray" />
33− </label>
34− </section>
31+ <section aria-labelledby="general-heading">
32+ <h2 id="general-heading">Starting and closing</h2>
33+ <label class="setting">
34+ <span><strong>Open g1t when you sign in</strong><span class="muted" id="open-at-login-note">g1t starts with your computer.</span></span>
35+ <input type="checkbox" role="switch" id="openAtLogin" />
36+ </label>
37+ <label class="setting">
38+ <span><strong>Start in the tray</strong><span class="muted" id="start-hidden-note">Opened at sign-in, g1t waits in the tray without opening its window.</span></span>
39+ <input type="checkbox" role="switch" id="startHidden" />
40+ </label>
41+ <label class="setting">
42+ <span><strong>Keep g1t running when the window closes</strong><span class="muted">It stays in the tray for notifications. Turned off, closing the window quits.</span></span>
43+ <input type="checkbox" role="switch" id="closeToTray" />
44+ </label>
45+ </section>
3546
36− <section aria-labelledby="notifications-heading">
37− <h2 id="notifications-heading">Notifications</h2>
38− <label class="setting">
39− <span><strong>Show notifications</strong><span class="muted">From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t.</span></span>
40− <input type="checkbox" role="switch" id="notifications" />
41− </label>
42− <label class="setting">
43− <span><strong id="flash-label">Flash the taskbar icon</strong><span class="muted">When a notification arrives and the window is not in front.</span></span>
44− <input type="checkbox" role="switch" id="flash" />
45− </label>
46− <label class="setting">
47− <span><strong>Show a badge on the icon</strong><span class="muted">How many things are waiting for you.</span></span>
48− <input type="checkbox" role="switch" id="badge" />
49− </label>
50− </section>
47+ <section aria-labelledby="notifications-heading">
48+ <h2 id="notifications-heading">Notifications</h2>
49+ <label class="setting">
50+ <span><strong>Show notifications</strong><span class="muted">From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t.</span></span>
51+ <input type="checkbox" role="switch" id="notifications" />
52+ </label>
53+ <label class="setting">
54+ <span><strong id="flash-label">Flash the taskbar icon</strong><span class="muted">When a notification arrives and the window is not in front.</span></span>
55+ <input type="checkbox" role="switch" id="flash" />
56+ </label>
57+ <label class="setting">
58+ <span><strong>Show a badge on the icon</strong><span class="muted">How many things are waiting for you.</span></span>
59+ <input type="checkbox" role="switch" id="badge" />
60+ </label>
61+ </section>
5162
52− <section aria-labelledby="window-heading">
53− <h2 id="window-heading">Window</h2>
54− <label class="setting">
55− <span
56− ><strong>Use the system's title bar</strong
57− ><span class="muted" id="system-title-bar-note">In place of g1t's own, with the menu bar under it.</span></span
58− >
59− <input type="checkbox" role="switch" id="systemTitleBar" />
60− </label>
61− <div class="setting" id="restart-row" hidden>
62− <span><strong>Restart to change the title bar</strong><span class="muted">g1t opens again where you were.</span></span>
63− <button type="button" id="restart-app" class="primary">Restart now</button>
64− </div>
65− <label class="setting">
66− <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span>
67− <select id="zoom">
68− <option value="0.67">67%</option>
69− <option value="0.75">75%</option>
70− <option value="0.8">80%</option>
71− <option value="0.9">90%</option>
72− <option value="1">100%</option>
73− <option value="1.1">110%</option>
74− <option value="1.25">125%</option>
75− <option value="1.5">150%</option>
76− <option value="1.75">175%</option>
77− <option value="2">200%</option>
78− </select>
79− </label>
80− </section>
63+ <section aria-labelledby="window-heading">
64+ <h2 id="window-heading">Window</h2>
65+ <label class="setting">
66+ <span
67+ ><strong>Use the system's title bar</strong
68+ ><span class="muted" id="system-title-bar-note">In place of g1t's own, with the menu bar under it.</span></span
69+ >
70+ <input type="checkbox" role="switch" id="systemTitleBar" />
71+ </label>
72+ <div class="setting" id="restart-row" hidden>
73+ <span><strong>Restart to change the title bar</strong><span class="muted">g1t opens again where you were.</span></span>
74+ <button type="button" id="restart-app" class="primary">Restart now</button>
75+ </div>
76+ <label class="setting">
77+ <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span>
78+ <select id="zoom">
79+ <option value="0.67">67%</option>
80+ <option value="0.75">75%</option>
81+ <option value="0.8">80%</option>
82+ <option value="0.9">90%</option>
83+ <option value="1">100%</option>
84+ <option value="1.1">110%</option>
85+ <option value="1.25">125%</option>
86+ <option value="1.5">150%</option>
87+ <option value="1.75">175%</option>
88+ <option value="2">200%</option>
89+ </select>
90+ </label>
91+ </section>
8192
82− <section aria-labelledby="updates-heading">
83− <h2 id="updates-heading">Updates</h2>
84− <label class="setting">
85− <span><strong>Download updates by themselves</strong><span class="muted">Each one installs when you restart or quit g1t, never in the middle of something.</span></span>
86− <input type="checkbox" role="switch" id="autoUpdate" />
87− </label>
88− <div class="setting" id="updates-row">
89− <span><strong id="version">g1t</strong><span class="muted" id="update-status" aria-live="polite">&nbsp;</span></span>
90− <span class="buttons">
91− <button type="button" id="restart" class="primary" hidden>Restart now</button>
92− <button type="button" id="check">Check for updates</button>
93− </span>
94− </div>
95− </section>
93+ <section aria-labelledby="updates-heading">
94+ <h2 id="updates-heading">Updates</h2>
95+ <label class="setting">
96+ <span><strong>Download updates by themselves</strong><span class="muted">Each one installs when you restart or quit g1t, never in the middle of something.</span></span>
97+ <input type="checkbox" role="switch" id="autoUpdate" />
98+ </label>
99+ <div class="setting" id="updates-row">
100+ <span><strong id="version">g1t</strong><span class="muted" id="update-status" aria-live="polite">&nbsp;</span></span>
101+ <span class="buttons">
102+ <button type="button" id="restart" class="primary" hidden>Restart now</button>
103+ <button type="button" id="check">Check for updates</button>
104+ </span>
105+ </div>
106+ </section>
96107
97− <section aria-labelledby="server-heading">
98− <h2 id="server-heading">Server</h2>
99− <div class="setting">
100− <span><strong id="server">g1t.sh</strong><span class="muted">The g1t this app opens. Change it if you run g1t yourself.</span></span>
101− <button type="button" id="change-server">Change…</button>
102− </div>
103− </section>
108+ <section aria-labelledby="server-heading">
109+ <h2 id="server-heading">Server</h2>
110+ <div class="setting">
111+ <span><strong id="server">g1t.sh</strong><span class="muted">The g1t this app opens. Change it if you run g1t yourself.</span></span>
112+ <button type="button" id="change-server">Change…</button>
113+ </div>
114+ </section>
104115
105− <p class="error" id="error" role="alert" hidden></p>
106− <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p>
107− </main>
116+ <p class="error" id="error" role="alert" hidden></p>
117+ <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p>
118+ </main>
119+ </div>
108120 <script src="settings.js"></script>
109121 </body>
110122 </html>
+17−0
178178 invoke("open_url", { url: anchor.href }).catch(function () {});
179179 });
180180
181+ // g1t's own title bar, when the window has no system frame.
182+ function frame(view) {
183+ if (!view.customFrame) return;
184+ if (view.platform === "macos") document.documentElement.classList.add("mac");
185+ var bar = byId("titlebar");
186+ bar.hidden = false;
187+ document.body.classList.add("framed");
188+ bar.addEventListener("mousedown", function (event) {
189+ if (event.button !== 0 || event.target.closest("button")) return;
190+ invoke("window_control", { action: "drag" }).catch(function () {});
191+ });
192+ byId("close-window").addEventListener("click", function () {
193+ invoke("window_control", { action: "close" }).catch(function () {});
194+ });
195+ }
196+
181197 invoke("get_settings")
182198 .then(function (view) {
199+ frame(view);
183200 platform(view.platform);
184201 render(view);
185202 // Opened by "Check for updates…" in a menu.
+114−0
1+/* The title bar, in the colours of the page under it (titlebar.js sets
2+ them): g1t's rail on g1t's pages, light or dark. 36px tall. */
3+:root {
4+ --bg: #050507;
5+ --fg: #ededef;
6+ --muted: #a0a0a8;
7+ --accent: #b6a8ff;
8+ --hover: rgba(237, 237, 239, 0.1);
9+ --pill: rgba(237, 237, 239, 0.09);
10+ --pill-hover: rgba(237, 237, 239, 0.14);
11+ --pill-edge: rgba(237, 237, 239, 0.08);
12+}
13+* {
14+ box-sizing: border-box;
15+}
16+html,
17+body {
18+ height: 100%;
19+ margin: 0;
20+ overflow: hidden;
21+ background: var(--bg);
22+}
23+.bar {
24+ height: 100%;
25+ display: flex;
26+ align-items: center;
27+ gap: 2px;
28+ padding-left: 6px;
29+ color: var(--muted);
30+ font: 500 12px/1 system-ui, "Segoe UI", -apple-system, Roboto, sans-serif;
31+ user-select: none;
32+ -webkit-user-select: none;
33+ cursor: default;
34+}
35+.mac .bar {
36+ padding-left: 78px;
37+}
38+.side {
39+ display: flex;
40+ align-items: center;
41+ gap: 2px;
42+ flex: 1 1 0;
43+ min-width: 0;
44+}
45+.end {
46+ justify-content: flex-end;
47+}
48+.middle {
49+ flex: 0 1 560px;
50+ min-width: 0;
51+ display: flex;
52+ justify-content: center;
53+}
54+button {
55+ all: unset;
56+ box-sizing: border-box;
57+ display: inline-flex;
58+ align-items: center;
59+ justify-content: center;
60+ color: inherit;
61+}
62+.icon {
63+ width: 28px;
64+ height: 26px;
65+ border-radius: 6px;
66+}
67+.icon:hover {
68+ background: var(--hover);
69+ color: var(--fg);
70+}
71+.icon:disabled {
72+ opacity: 0.35;
73+ background: none;
74+ color: inherit;
75+}
76+.search {
77+ width: 100%;
78+ height: 24px;
79+ gap: 8px;
80+ padding: 0 10px;
81+ justify-content: flex-start;
82+ border-radius: 6px;
83+ background: var(--pill);
84+ box-shadow: inset 0 0 0 1px var(--pill-edge);
85+}
86+.search:hover {
87+ background: var(--pill-hover);
88+ color: var(--fg);
89+}
90+.search span,
91+.title {
92+ overflow: hidden;
93+ text-overflow: ellipsis;
94+ white-space: nowrap;
95+}
96+.title {
97+ padding: 0 8px;
98+}
99+.caption {
100+ width: 46px;
101+ height: 36px;
102+}
103+.caption:hover {
104+ background: var(--hover);
105+ color: var(--fg);
106+}
107+.caption.close:hover {
108+ background: #c42b1c;
109+ color: #fff;
110+}
111+button:focus-visible {
112+ outline: 2px solid var(--accent);
113+ outline-offset: -2px;
114+}
+23−0
1+<!doctype html>
2+<!--
3+ The title bar: a page of its own, across the top of the main window,
4+ above the site's (src/frame.rs). It shows what the site's page reports
5+ (its title, its search, whether there is a way back, its colours), and
6+ its buttons act on the window and on that page through the app.
7+-->
8+<html lang="en">
9+ <head>
10+ <meta charset="utf-8" />
11+ <meta name="viewport" content="width=device-width, initial-scale=1" />
12+ <title>g1t</title>
13+ <link rel="stylesheet" href="titlebar.css" />
14+ </head>
15+ <body>
16+ <div class="bar" id="bar" role="toolbar" aria-label="Window">
17+ <div class="side" id="left"></div>
18+ <div class="middle" id="middle"></div>
19+ <div class="side end" id="right"></div>
20+ </div>
21+ <script src="titlebar.js"></script>
22+ </body>
23+</html>
+174−0
1+// The title bar's work: draw what the page under it reports, and send its
2+// buttons to the app (src/bridge.rs: title_bar_current, title_bar_act,
3+// window_control, window_menu). A click here hands the keyboard back to
4+// the page, so typing goes on where it was.
5+(function () {
6+ var core = window.__TAURI__ && window.__TAURI__.core;
7+ var events = window.__TAURI__ && window.__TAURI__.event;
8+ function invoke(command, args) {
9+ if (core && typeof core.invoke === "function") return core.invoke(command, args || {});
10+ return Promise.reject(new Error("the title bar cannot reach the app"));
11+ }
12+ function quietly(promise) {
13+ promise.catch(function () {});
14+ }
15+ function act(action) {
16+ quietly(invoke("title_bar_act", { action: action }));
17+ }
18+ function control(action) {
19+ return invoke("window_control", { action: action });
20+ }
21+
22+ var ua = navigator.userAgent;
23+ var MAC = /Macintosh|Mac OS X/.test(ua) && !/Windows/.test(ua);
24+ if (MAC) document.documentElement.classList.add("mac");
25+
26+ function svg(body) {
27+ return (
28+ '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
29+ body +
30+ "</svg>"
31+ );
32+ }
33+ // Drawn here, from lines and boxes, as each system draws its own.
34+ var ICONS = {
35+ menu: svg('<line x1="3" y1="4.5" x2="13" y2="4.5"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="11.5" x2="13" y2="11.5"/>'),
36+ back: svg('<polyline points="9.5,3.5 5,8 9.5,12.5"/><line x1="5" y1="8" x2="13" y2="8"/>'),
37+ forward: svg('<polyline points="6.5,3.5 11,8 6.5,12.5"/><line x1="3" y1="8" x2="11" y2="8"/>'),
38+ search: svg('<circle cx="7" cy="7" r="4"/><line x1="10" y1="10" x2="13" y2="13"/>'),
39+ minimize: svg('<line x1="3.5" y1="8" x2="12.5" y2="8"/>'),
40+ maximize: svg('<rect x="3.5" y="3.5" width="9" height="9" rx="1"/>'),
41+ restore: svg('<rect x="3.5" y="5.5" width="7" height="7" rx="1"/><path d="M5.5 5.5V4.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-1"/>'),
42+ close: svg('<line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/>'),
43+ };
44+
45+ function button(kind, label, icon, onClick) {
46+ var b = document.createElement("button");
47+ b.type = "button";
48+ b.tabIndex = -1;
49+ b.className = kind;
50+ b.setAttribute("aria-label", label);
51+ b.title = label;
52+ if (icon) b.innerHTML = ICONS[icon];
53+ b.addEventListener("click", function (event) {
54+ event.stopPropagation();
55+ onClick(b);
56+ });
57+ return b;
58+ }
59+
60+ var bar = document.getElementById("bar");
61+ var left = document.getElementById("left");
62+ var middle = document.getElementById("middle");
63+ var right = document.getElementById("right");
64+
65+ if (!MAC) {
66+ left.append(
67+ button("icon", "Menu", "menu", function (b) {
68+ var box = b.getBoundingClientRect();
69+ quietly(invoke("window_menu", { x: box.left, y: box.bottom }));
70+ }),
71+ );
72+ }
73+ var back = button("icon", "Back", "back", function () {
74+ act("back");
75+ });
76+ var forward = button("icon", "Forward", "forward", function () {
77+ act("forward");
78+ });
79+ left.append(back, forward);
80+
81+ var maximize = null;
82+ function drawMaximized(maximized) {
83+ if (!maximize) return;
84+ maximize.innerHTML = maximized ? ICONS.restore : ICONS.maximize;
85+ maximize.setAttribute("aria-label", maximized ? "Restore" : "Maximize");
86+ maximize.title = maximized ? "Restore" : "Maximize";
87+ }
88+ // The window's buttons, at the right as Windows and Linux put them; macOS has its own.
89+ if (!MAC) {
90+ maximize = button("caption", "Maximize", "maximize", function () {
91+ control("maximize").then(drawMaximized, function () {});
92+ });
93+ right.append(
94+ button("caption", "Minimize", "minimize", function () {
95+ quietly(control("minimize"));
96+ }),
97+ maximize,
98+ button("caption close", "Close", "close", function () {
99+ quietly(control("close"));
100+ }),
101+ );
102+ var resizing = 0;
103+ window.addEventListener("resize", function () {
104+ clearTimeout(resizing);
105+ resizing = setTimeout(function () {
106+ control("maximized").then(drawMaximized, function () {});
107+ }, 120);
108+ });
109+ control("maximized").then(drawMaximized, function () {});
110+ }
111+
112+ // Dragging the bar moves the window; a double click maximizes it, as a system's title bar does.
113+ bar.addEventListener("mousedown", function (event) {
114+ if (event.button !== 0 || event.target.closest("button")) return;
115+ if (event.detail === 2) {
116+ control("maximize").then(drawMaximized, function () {});
117+ return;
118+ }
119+ quietly(control("drag"));
120+ });
121+ // The keyboard belongs to the page: a click here gives it back.
122+ window.addEventListener("mouseup", function () {
123+ act("focus");
124+ });
125+
126+ var title = document.createElement("span");
127+ title.className = "title";
128+ var pill = button("search", "Search", "search", function () {
129+ act("search");
130+ });
131+ var pillText = document.createElement("span");
132+ pill.append(pillText);
133+
134+ function setColors(colors) {
135+ var root = document.documentElement.style;
136+ if (!colors) return;
137+ var shade = colors.dark ? "237, 237, 239" : "15, 15, 17";
138+ root.setProperty("--bg", colors.background);
139+ root.setProperty("--fg", colors.foreground);
140+ root.setProperty("--muted", colors.muted);
141+ root.setProperty("--accent", colors.accent);
142+ root.setProperty("--hover", "rgba(" + shade + ", " + (colors.dark ? 0.1 : 0.07) + ")");
143+ root.setProperty("--pill", "rgba(" + shade + ", " + (colors.dark ? 0.09 : 0.05) + ")");
144+ root.setProperty("--pill-hover", "rgba(" + shade + ", " + (colors.dark ? 0.14 : 0.09) + ")");
145+ root.setProperty("--pill-edge", "rgba(" + shade + ", " + (colors.dark ? 0.08 : 0.1) + ")");
146+ document.documentElement.style.colorScheme = colors.dark ? "dark" : "light";
147+ }
148+
149+ function draw(page) {
150+ if (!page) return;
151+ middle.textContent = "";
152+ if (page.search) {
153+ pillText.textContent = page.search;
154+ pill.setAttribute("aria-label", page.search);
155+ pill.removeAttribute("title");
156+ middle.append(pill);
157+ } else {
158+ title.textContent = page.title || "g1t";
159+ middle.append(title);
160+ }
161+ back.disabled = page.canGoBack === false;
162+ forward.disabled = page.canGoForward === false;
163+ setColors(page.colors);
164+ }
165+
166+ if (events && typeof events.listen === "function") {
167+ events
168+ .listen("g1t-title-bar", function (event) {
169+ draw(event.payload);
170+ })
171+ .catch(function () {});
172+ }
173+ invoke("title_bar_current").then(draw, function () {});
174+})();
+3−1
1616 tauri-build = { version = "2", features = [] }
1717
1818 [dependencies]
19−tauri = { version = "2", features = ["tray-icon", "image-png"] }
19+# `unstable`: a window holding more than one web view, so the title bar
20+# and the site sit one above the other (src/frame.rs).
21+tauri = { version = "2", features = ["tray-icon", "image-png", "unstable"] }
2022 # Updates: a signed manifest at g1t.sh/downloads/desktop/latest.json.
2123 tauri-plugin-updater = "2"
2224 # Native notifications, from the site's live feed.
+3−0
2121 "restart",
2222 "window_control",
2323 "window_menu",
24+ "title_bar_page",
25+ "title_bar_current",
26+ "title_bar_act",
2427 ])))
2528 .expect("the app's build could not be prepared");
2629 }
+5−2
11 {
22 "$schema": "../gen/schemas/desktop-schema.json",
33 "identifier": "launcher",
4− "description": "The launcher page bundled with the app, which finds the server and goes there. The site's own pages get their capability at run time, for the server that is configured (src/config.rs).",
4+ "description": "The pages bundled with the app in the main window: the launcher, which finds the server and goes there, and the title bar across the top (src/frame.rs). The site's own pages get their capability at run time, for the server that is configured (src/config.rs).",
55 "windows": ["main"],
66 "permissions": [
77 "core:default",
1212 "allow-check-for-updates",
1313 "allow-open-settings",
1414 "allow-window-control",
15− "allow-window-menu"
15+ "allow-window-menu",
16+ "allow-title-bar-page",
17+ "allow-title-bar-current",
18+ "allow-title-bar-act"
1619 ]
1720 }
+2−2
66
77 use tauri::{AppHandle, Manager};
88
9−use crate::{WINDOW, config, tray};
9+use crate::{config, tray};
1010
1111 pub fn set(app: &AppHandle, count: i64) {
1212 *app.state::<config::State>().waiting.lock().unwrap() = count;
1313 let shown = if config::settings(app).badge { count } else { 0 };
14− if let Some(window) = app.get_webview_window(WINDOW) {
14+ if let Some(window) = crate::frame::window(app) {
1515 #[cfg(target_os = "windows")]
1616 let _ = window.set_overlay_icon(if shown > 0 { Some(dot()) } else { None });
1717 #[cfg(not(target_os = "windows"))]
+89−249
99 if (window.g1tDesktop) return;
1010 var VERSION = "__VERSION__";
1111 var PLATFORM = "__PLATFORM__";
12− // Which window this is ("main" or "settings"), whether it draws g1t's
13− // own title bar ("custom") or has the system's, and the bar's height.
14− var WINDOW = "__WINDOW__";
12+ // Whether g1t's own title bar is above this page ("custom"), or the
13+ // system's ("system"). The bar is a page of its own (src/frame.rs); this
14+ // page only tells it what to show (`report`), and is never under it.
1515 var FRAME = "__FRAME__";
16− var HEIGHT = "__HEIGHT__";
1716 // The site's pages for reading about g1t, not using it, which open in
1817 // the browser (navigation.rs); one ending in "/" is every page under it.
1918 var OUTSIDE = "__OUTSIDE__";
9190 setSearch: function (label, open) {
9291 var mine = typeof open === "function" ? { label: String(label || "Search"), open: open } : null;
9392 search = mine;
94− drawSearch();
93+ report();
9594 return function () {
9695 if (search === mine) {
9796 search = null;
98− drawSearch();
97+ report();
9998 }
10099 };
101100 },
102101 // Whether g1t draws the title bar, and how tall it is: 0 under the system's.
103− titleBarHeight: FRAME === "custom" ? HEIGHT : 0,
102+ // The page is below it, never under it: this says only that the bar is there.
103+ titleBarHeight: FRAME === "custom" ? 36 : 0,
104104 };
105105 Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false });
106106
154154 };
155155
156156 // ── The title bar ─────────────────────────────────────────────────────
157− // g1t's own, in place of the system's (src/frame.rs), on every page the
158− // window shows: the menu button, back and forward, search in the middle,
159− // and the window's buttons; on macOS the system's traffic lights sit at
160− // its left. It is drawn in a closed shadow root, so no page's styles
161− // reach it, in the colours of the site's rail where the page has them.
162− // `data-titlebar` on <html> tells the page to make room: the site does
163− // (`--frame-top`, apps/web/app/app.css); any other page is pushed down.
157+ // What the bar above the page shows of it: the title, the search the
158+ // site handed over (`setSearch`), whether there is a page to go back or
159+ // forward to, and the page's colours, so the bar is the colour of g1t's
160+ // rail, light or dark, or of whatever page is showing. Sent whenever any
161+ // of it changes; the bar's search comes back as an event.
164162 var search = null;
165− var drawSearch = function () {};
163+ var report = function () {};
166164 if (FRAME !== "custom") return;
167− var MAC = PLATFORM === "macos";
168− var MAIN = WINDOW === "main";
169165
170− function whenThere(test, then) {
171− if (test()) return then();
172− var watch = new MutationObserver(function () {
173− if (!test()) return;
174− watch.disconnect();
175− then();
176− });
177− watch.observe(document, { childList: true, subtree: true });
178− }
179−
180− // Room for the bar, before the page paints. With no specificity of its
181− // own, any rule of the page's for the same thing wins.
182− var ROOM = ":where(html[data-titlebar]) { padding-top: " + HEIGHT + "px; }";
183− try {
184− var sheet = new CSSStyleSheet();
185− sheet.replaceSync(ROOM);
186− document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);
187− } catch (error) {
188− whenThere(
189− function () {
190− return document.head;
191− },
192− function () {
193− var style = document.createElement("style");
194− style.textContent = ROOM;
195− document.head.appendChild(style);
196− },
197− );
198− }
199− whenThere(
200− function () {
201− return document.documentElement;
202− },
203− function () {
204− document.documentElement.setAttribute("data-titlebar", PLATFORM);
205− },
206− );
207−
208− function svg(body) {
209− return '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
210− }
211− // Drawn here, from lines and boxes, as each system draws its own.
212− var ICONS = {
213− menu: svg('<line x1="3" y1="4.5" x2="13" y2="4.5"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="11.5" x2="13" y2="11.5"/>'),
214− back: svg('<polyline points="9.5,3.5 5,8 9.5,12.5"/><line x1="5" y1="8" x2="13" y2="8"/>'),
215− forward: svg('<polyline points="6.5,3.5 11,8 6.5,12.5"/><line x1="3" y1="8" x2="11" y2="8"/>'),
216− search: svg('<circle cx="7" cy="7" r="4"/><line x1="10" y1="10" x2="13" y2="13"/>'),
217− minimize: svg('<line x1="3.5" y1="8" x2="12.5" y2="8"/>'),
218− maximize: svg('<rect x="3.5" y="3.5" width="9" height="9" rx="1"/>'),
219− restore: svg('<rect x="3.5" y="5.5" width="7" height="7" rx="1"/><path d="M5.5 5.5V4.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-1"/>'),
220− close: svg('<line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/>'),
221− };
222−
223− var STYLE =
224− ":host{position:fixed;top:0;left:0;right:0;height:" + HEIGHT + "px;z-index:2147483646;display:block;}" +
225− ".bar{box-sizing:border-box;height:100%;display:flex;align-items:center;gap:2px;padding:0 0 0 6px;" +
226− "background:var(--color-rail,#050507);color:var(--color-muted,#a0a0a8);" +
227− "font:500 12px/1 system-ui,'Segoe UI',-apple-system,Roboto,sans-serif;user-select:none;-webkit-user-select:none;cursor:default;}" +
228− ".mac .bar{padding-left:78px;}" +
229− ".side{display:flex;align-items:center;gap:2px;flex:1 1 0;min-width:0;}" +
230− ".end{justify-content:flex-end;}" +
231− ".middle{flex:0 1 560px;min-width:0;display:flex;justify-content:center;}" +
232− "button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;color:inherit;}" +
233− ".icon{width:28px;height:26px;border-radius:6px;}" +
234− ".icon:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 10%,transparent);color:var(--color-fg,#ededef);}" +
235− ".icon:disabled{opacity:.35;background:none;color:inherit;}" +
236− ".search{width:100%;height:24px;gap:8px;padding:0 10px;justify-content:flex-start;border-radius:6px;" +
237− "background:color-mix(in srgb,var(--color-fg,#ededef) 9%,transparent);" +
238− "box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--color-fg,#ededef) 8%,transparent);}" +
239− ".search:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 14%,transparent);color:var(--color-fg,#ededef);}" +
240− ".search span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}" +
241− ".title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 8px;}" +
242− ".caption{width:46px;height:" + HEIGHT + "px;}" +
243− ".caption:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 10%,transparent);color:var(--color-fg,#ededef);}" +
244− ".caption.close:hover{background:#c42b1c;color:#fff;}" +
245− "button:focus-visible{outline:2px solid var(--color-accent,#b6a8ff);outline-offset:-2px;}";
246−
247− function button(kind, label, onClick, extra) {
248− var b = document.createElement("button");
249− b.type = "button";
250− b.tabIndex = -1;
251− b.className = kind + (extra ? " " + extra : "");
252− b.setAttribute("aria-label", label);
253− b.title = label;
254− b.addEventListener("click", function (event) {
255− event.stopPropagation();
256− onClick(event, b);
257− });
258− return b;
259− }
260−
261− function control(action) {
262− return invoke("window_control", { action: action });
263− }
264−
265− var host = document.createElement("g1t-titlebar");
266− var shadow = host.attachShadow({ mode: "closed" });
267− var frame = document.createElement("div");
268− frame.className = MAC ? "mac" : "";
269− frame.style.height = "100%";
270− var styleEl = document.createElement("style");
271− styleEl.textContent = STYLE;
272− var bar = document.createElement("div");
273− bar.className = "bar";
274− bar.setAttribute("role", "toolbar");
275− bar.setAttribute("aria-label", "Window");
276− var left = document.createElement("div");
277− left.className = "side";
278− var middle = document.createElement("div");
279− middle.className = "middle";
280− var right = document.createElement("div");
281− right.className = "side end";
282− bar.append(left, middle, right);
283− frame.append(styleEl, bar);
284− shadow.append(frame);
285−
286− var back = null;
287− var forward = null;
288− if (MAIN) {
289− if (!MAC) {
290− left.append(
291− button("icon", "Menu", function (event, b) {
292− var box = b.getBoundingClientRect();
293− quietly(invoke("window_menu", { x: box.left, y: box.bottom }));
294− }),
295− );
166+ // A colour the page names, resolved: `var(--color-rail)` is light-dark(…) until it is drawn.
167+ var probe = null;
168+ function resolve(value) {
169+ if (!document.body) return "";
170+ if (!probe) {
171+ probe = document.createElement("span");
172+ probe.setAttribute("aria-hidden", "true");
173+ probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;";
296174 }
297− back = button("icon", "Back", function () {
298− history.back();
299− });
300− forward = button("icon", "Forward", function () {
301− history.forward();
302− });
303− back.innerHTML = ICONS.back;
304− forward.innerHTML = ICONS.forward;
305− left.append(back, forward);
306− if (!MAC) left.firstChild.innerHTML = ICONS.menu;
175+ if (probe.parentNode !== document.body) document.body.appendChild(probe);
176+ probe.style.color = "";
177+ probe.style.color = value;
178+ var color = getComputedStyle(probe).color;
179+ probe.remove();
180+ return color;
307181 }
308−
309− // Back and forward, greyed when there is nowhere to go, where the web view can tell.
310− function drawHistory() {
311− if (!back) return;
312− var nav = window.navigation;
313− if (!nav || typeof nav.canGoBack !== "boolean") return;
314− back.disabled = !nav.canGoBack;
315− forward.disabled = !nav.canGoForward;
182+ function opaque(color) {
183+ return !!color && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
316184 }
317− if (window.navigation && typeof window.navigation.addEventListener === "function") {
318− window.navigation.addEventListener("currententrychange", drawHistory);
185+ function luminance(color) {
186+ var parts = (color.match(/[\d.]+/g) || []).map(Number);
187+ return parts.length < 3 ? 0 : (0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]) / 255;
319188 }
320− drawHistory();
321−
322− // The middle: the site's search when it has handed one over, otherwise the page's title.
323− var title = document.createElement("span");
324− title.className = "title";
325− drawSearch = function () {
326− middle.textContent = "";
327− if (search) {
328− var pill = button("search", search.label, function () {
329− search.open();
330− });
331− pill.innerHTML = ICONS.search;
332− var text = document.createElement("span");
333− text.textContent = search.label;
334− pill.append(text);
335− middle.append(pill);
336− } else {
337− title.textContent = document.title || "g1t";
338− middle.append(title);
339− }
340− };
341− drawSearch();
342− {
343− var retitle = function () {
344− if (!search) title.textContent = document.title || "g1t";
189+ // g1t's pages name their colours (apps/web/app/app.css); any other page has its own background and text.
190+ function colors() {
191+ if (!document.body) return null;
192+ var root = getComputedStyle(document.documentElement);
193+ var ours = root.getPropertyValue("--color-rail").trim() !== "";
194+ var body = getComputedStyle(document.body);
195+ var background = ours ? resolve("var(--color-rail)") : body.backgroundColor;
196+ if (!opaque(background)) background = root.backgroundColor;
197+ if (!opaque(background)) return null;
198+ var foreground = ours ? resolve("var(--color-fg)") : body.color;
199+ return {
200+ background: background,
201+ foreground: foreground,
202+ muted: ours ? resolve("var(--color-muted)") : foreground,
203+ accent: ours ? resolve("var(--color-accent)") : foreground,
204+ dark: luminance(background) < 0.5,
345205 };
346− document.addEventListener("DOMContentLoaded", retitle);
347− whenThere(
348− function () {
349− return document.head;
350− },
351− function () {
352− retitle();
353− new MutationObserver(retitle).observe(document.head, { subtree: true, childList: true, characterData: true });
354− },
355− );
356206 }
357207
358− // The window's buttons, at the right as Windows and Linux put them; macOS has its own.
359− if (!MAC) {
360− if (MAIN) {
361− var minimize = button("caption", "Minimize", function () {
362− quietly(control("minimize"));
363− });
364− minimize.innerHTML = ICONS.minimize;
365− var maximize = button("caption", "Maximize", function () {
366− control("maximize").then(drawMaximized, function () {});
367− });
368− maximize.innerHTML = ICONS.maximize;
369− right.append(minimize, maximize);
370− var drawMaximized = function (maximized) {
371− maximize.innerHTML = maximized ? ICONS.restore : ICONS.maximize;
372− maximize.setAttribute("aria-label", maximized ? "Restore" : "Maximize");
373− maximize.title = maximized ? "Restore" : "Maximize";
208+ var sent = "";
209+ var waiting = 0;
210+ report = function () {
211+ clearTimeout(waiting);
212+ waiting = setTimeout(function () {
213+ var nav = window.navigation;
214+ var page = {
215+ title: document.title || "g1t",
216+ search: search ? search.label : null,
217+ canGoBack: nav && typeof nav.canGoBack === "boolean" ? nav.canGoBack : null,
218+ canGoForward: nav && typeof nav.canGoForward === "boolean" ? nav.canGoForward : null,
219+ colors: colors(),
374220 };
375− var resizing = 0;
376− window.addEventListener("resize", function () {
377− clearTimeout(resizing);
378− resizing = setTimeout(function () {
379− control("maximized").then(drawMaximized, function () {});
380− }, 120);
381− });
382− control("maximized").then(drawMaximized, function () {});
383− }
384− var close = button("caption", "Close", function () {
385− quietly(control("close"));
386− }, "close");
387− close.innerHTML = ICONS.close;
388− right.append(close);
389− }
221+ var text = JSON.stringify(page);
222+ if (text === sent) return;
223+ sent = text;
224+ invoke("title_bar_page", { page: page }).catch(function () {});
225+ }, 30);
226+ };
390227
391− // Dragging the bar moves the window; a double click maximizes it, as a system's title bar does.
392− bar.addEventListener("mousedown", function (event) {
393− if (event.button !== 0 || event.target.closest("button")) return;
394− if (event.detail === 2) {
395− if (MAIN) control("maximize").then(drawMaximized || function () {}, function () {});
396− return;
397− }
398− quietly(control("drag"));
228+ document.addEventListener("DOMContentLoaded", function () {
229+ report();
230+ if (document.head) new MutationObserver(report).observe(document.head, { subtree: true, childList: true, characterData: true });
231+ // The site's Light, Dark or Auto (data-theme on <html>), and the system's own.
232+ new MutationObserver(report).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class", "style"] });
399233 });
234+ window.addEventListener("load", report);
235+ window.addEventListener("pageshow", report);
236+ window.addEventListener("popstate", report);
237+ if (window.matchMedia) {
238+ var scheme = window.matchMedia("(prefers-color-scheme: dark)");
239+ if (typeof scheme.addEventListener === "function") scheme.addEventListener("change", report);
240+ }
241+ if (window.navigation && typeof window.navigation.addEventListener === "function") {
242+ window.navigation.addEventListener("currententrychange", report);
243+ }
400244
401− // In the page from as early as there is a page, and back if a page's script takes it out.
402− function attach() {
403− if (host.parentNode !== document.documentElement) document.documentElement.appendChild(host);
245+ // The bar's search was clicked: the site's search opens.
246+ var events = window.__TAURI__ && window.__TAURI__.event;
247+ if (events && typeof events.listen === "function") {
248+ events
249+ .listen("g1t-search", function () {
250+ if (search) search.open();
251+ })
252+ .catch(function () {});
404253 }
405− whenThere(
406− function () {
407− return document.body;
408− },
409− function () {
410− attach();
411− new MutationObserver(attach).observe(document.documentElement, { childList: true });
412− },
413− );
414254 })();
+53−26
1313 use url::Url;
1414
1515 use crate::settings::{self, Settings};
16−use crate::{WINDOW, badge, config, updates};
16+use crate::{badge, config, frame, updates};
1717
1818 const SCRIPT: &str = include_str!("bridge.js");
1919
20−/// The bridge, run at the start of every page a window loads: `window` is
21−/// its label, and `custom` whether it draws g1t's title bar (frame.rs).
22−pub fn script(window: &str, custom: bool) -> String {
20+/// The bridge, run at the start of every page the site's web view loads:
21+/// `custom` is whether g1t's title bar is above it (frame.rs).
22+pub fn script(custom: bool) -> String {
2323 SCRIPT
24− .replace("__WINDOW__", window)
2524 .replace("__FRAME__", if custom { "custom" } else { "system" })
26− .replace("\"__HEIGHT__\"", &crate::frame::HEIGHT.to_string())
2725 .replace("__VERSION__", env!("CARGO_PKG_VERSION"))
2826 .replace("__PLATFORM__", std::env::consts::OS)
2927 .replace("\"__OUTSIDE__\"", &serde_json::to_string(crate::navigation::OUTSIDE).expect("a list of paths is JSON"))
6563 from_site(app, webview).or_else(|_| from_launcher(webview))
6664 }
6765
68−/// The page asking is one the title bar is drawn on: the site's, the
69−/// app's own, or GitHub's while signing in.
70−fn from_frame(app: &AppHandle, webview: &Webview) -> Result<(), String> {
66+/// The page asking is the one under the title bar, whichever it is: the
67+/// site's, the launcher, or GitHub's while signing in.
68+fn from_page(app: &AppHandle, webview: &Webview) -> Result<(), String> {
7169 let url = caller(webview)?;
72− if config::is_server(app, &url) || config::is_launcher(&url) || crate::navigation::signing_in(&url) {
73− Ok(())
74− } else {
75− Err("only g1t's own pages may ask that".into())
76− }
70+ let shown = config::is_server(app, &url) || config::is_launcher(&url) || crate::navigation::signing_in(&url);
71+ if webview.label() == frame::SITE && shown { Ok(()) } else { Err("only the page in the window may ask that".into()) }
72+}
73+
74+/// The page asking is the title bar's, bundled with the app.
75+fn from_bar(webview: &Webview) -> Result<(), String> {
76+ from_launcher(webview)?;
77+ if webview.label() == frame::BAR { Ok(()) } else { Err("only the title bar may ask that".into()) }
78+}
79+
80+/// The page asking has window buttons: the title bar, or the settings window's own.
81+fn from_frame(webview: &Webview) -> Result<(), String> {
82+ from_bar(webview).or_else(|_| from_settings(webview))
7783 }
7884
7985 #[tauri::command]
9399 pub fn notify(app: AppHandle, webview: Webview, title: String, body: String) -> Result<(), String> {
94100 from_site(&app, &webview)?;
95101 let settings = config::settings(&app);
96− if let Some(window) = app.get_webview_window(WINDOW).filter(|window| settings.flash && !window.is_focused().unwrap_or(false)) {
102+ if let Some(window) = crate::frame::window(&app).filter(|window| settings.flash && !window.is_focused().unwrap_or(false)) {
97103 let _ = window.request_user_attention(Some(UserAttentionType::Informational));
98104 }
99105 if !settings.notifications {
158164 /// `drag`, or `maximized` to ask. Answers whether the window is maximized.
159165 #[tauri::command]
160166 pub fn window_control(app: AppHandle, webview: Webview, action: String) -> Result<bool, String> {
161− from_frame(&app, &webview)?;
162− let window = app.get_webview_window(webview.label()).ok_or("no such window")?;
163− crate::frame::control(&app, &window, &action)
167+ from_frame(&webview)?;
168+ crate::frame::control(&app, &webview.window(), &action)
164169 }
165170
166171 /// The title bar's menu button: the app's menu, at that place in the window.
167172 #[tauri::command]
168−pub fn window_menu(app: AppHandle, webview: Webview, x: f64, y: f64) -> Result<(), String> {
169− from_frame(&app, &webview)?;
170− let window = app.get_webview_window(WINDOW).filter(|_| webview.label() == WINDOW).ok_or("only the main window has a menu")?;
171− crate::frame::menu(&window, x, y)
173+pub fn window_menu(webview: Webview, x: f64, y: f64) -> Result<(), String> {
174+ from_bar(&webview)?;
175+ crate::frame::menu(&webview.window(), x, y)
176+}
177+
178+/// The page under the title bar says what it is: its title, its search,
179+/// whether there is a way back, its colours (frame.rs).
180+#[tauri::command]
181+pub fn title_bar_page(app: AppHandle, webview: Webview, page: frame::Page) -> Result<(), String> {
182+ from_page(&app, &webview)?;
183+ frame::page(&app, page);
184+ Ok(())
172185 }
173186
187+/// What the title bar shows now: it asks as it loads.
188+#[tauri::command]
189+pub fn title_bar_current(app: AppHandle, webview: Webview) -> Result<frame::Page, String> {
190+ from_bar(&webview)?;
191+ Ok(frame::current(&app))
192+}
193+
194+/// A title bar button that acts on the page: `back`, `forward`, `search`, `focus`.
195+#[tauri::command]
196+pub fn title_bar_act(app: AppHandle, webview: Webview, action: String) -> Result<(), String> {
197+ from_bar(&webview)?;
198+ frame::act(&app, &action)
199+}
200+
174201 /// Restarts g1t, installing a downloaded update on the way: the settings
175202 /// window offers it after a change that needs one.
176203 #[tauri::command]
236263 mod tests {
237264 #[test]
238265 fn the_bridge_has_nothing_left_to_fill_in() {
239− let script = super::script("main", true);
240− for placeholder in ["__VERSION__", "__PLATFORM__", "__OUTSIDE__", "__WINDOW__", "__FRAME__", "__HEIGHT__"] {
266+ let script = super::script(true);
267+ for placeholder in ["__VERSION__", "__PLATFORM__", "__OUTSIDE__", "__FRAME__"] {
241268 assert!(!script.contains(placeholder), "{placeholder} is left in the bridge");
242269 }
243− assert!(script.contains(r#"var HEIGHT = 36;"#) && script.contains(r#"var FRAME = "custom";"#));
244− assert!(super::script("settings", false).contains(r#"var FRAME = "system";"#));
270+ assert!(script.contains(r#"var FRAME = "custom";"#));
271+ assert!(super::script(false).contains(r#"var FRAME = "system";"#));
245272 assert!(script.contains(r#"var OUTSIDE = ["/pricing""#));
246273 }
247274 }
+4−5
165165 "allow-open-url",
166166 "allow-check-for-updates",
167167 "allow-open-settings",
168− "allow-window-control",
169− "allow-window-menu",
168+ "allow-title-bar-page",
170169 ];
171170
172−/// What GitHub's pages may call while someone signs in: the title bar's buttons.
173−const FRAME: &[&str] = &["allow-window-control", "allow-window-menu"];
171+/// What GitHub's pages may call while someone signs in: telling the title bar their title.
172+const FRAME: &[&str] = &["allow-title-bar-page"];
174173
175174 /// Lets the server's pages call the app: a capability for its origin,
176175 /// added once. Pages from anywhere else get no IPC at all, and bridge.rs
187186 site = site.permission(*permission);
188187 }
189188 app.add_capability(site)?;
190− // Signing in with GitHub: its pages get the title bar's buttons, and nothing else.
189+ // Signing in with GitHub: its pages tell the title bar what they are, and nothing else.
191190 if allowed.is_empty() {
192191 let mut sign_in = CapabilityBuilder::new("sign-in").window(WINDOW).remote("https://github.com/*".to_string());
193192 for permission in FRAME {
+193−15
22 //! do, so the window is all g1t and looks the same on every system: on
33 //! Windows and Linux the system's frame goes and the bar has the menu
44 //! button, back and forward, search, and minimize, maximize and close; on
5−//! macOS the bar sits under the window's own traffic lights. The bar
6−//! itself is bridge.js's, drawn on every page the window shows, the
7−//! site's, the launcher's, and GitHub's while signing in; the site makes
8−//! room for it (`--frame-top`, apps/web/app/app.css). Settings can give
9−//! the window back its system frame, and the menu bar with it, from the
10−//! next start: the bar is a script each window gets as it opens.
5+//! macOS the bar sits under the window's own traffic lights.
6+//!
7+//! The main window holds two web views, one above the other: the bar, a
8+//! page bundled with the app (`launcher/titlebar.html`), across the top,
9+//! and the site under it. The site is never drawn under the bar, so no page
10+//! of it, or of GitHub's while signing in, needs to know the bar is there.
11+//! What the bar shows of the page (its title, its search, whether there is
12+//! a way back, its colours) the page's bridge reports (`page`), and what
13+//! the bar is asked to do comes back to the page through the app (`act`).
14+//!
15+//! Settings can give the window back its system frame, and the menu bar
16+//! with it, from the next start; the site then has the window to itself.
17+
18+use std::sync::Mutex;
1119
12−use tauri::{AppHandle, LogicalPosition, Manager, Runtime, WebviewWindow, WebviewWindowBuilder};
20+use serde::{Deserialize, Serialize};
21+use tauri::webview::WebviewBuilder;
22+use tauri::window::{Color, WindowBuilder};
23+use tauri::{AppHandle, Emitter, EventTarget, LogicalPosition, LogicalSize, Manager, Runtime, Webview, WebviewUrl, WebviewWindowBuilder, Window};
1324
1425 use crate::{WINDOW, config, tray};
1526
16−/// The bar's height, in CSS pixels; bridge.js draws it this tall.
27+/// The bar's height, in CSS pixels.
1728 pub const HEIGHT: f64 = 36.0;
1829
30+/// The site's web view, in the main window.
31+pub const SITE: &str = "site";
32+/// The bar's web view, across the top of the main window.
33+pub const BAR: &str = "titlebar";
34+
35+/// What the bar listens for: what to show (`Page`).
36+pub const PAGE_EVENT: &str = "g1t-title-bar";
37+/// What the site listens for: the bar's search was clicked.
38+pub const SEARCH_EVENT: &str = "g1t-search";
39+
40+/// g1t's rail, dark, which the window is painted while a page loads.
41+const RAIL: Color = Color(5, 5, 7, 255);
42+
1943 /// Whether g1t draws its own title bar, as the settings say now.
2044 pub fn custom(app: &AppHandle) -> bool {
2145 !config::settings(app).system_title_bar
2953 *STARTED_CUSTOM.get_or_init(|| custom(app))
3054 }
3155
32−/// A window with g1t's title bar, or the system's.
33−pub fn style<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> {
56+/// The main window, holding the bar and the site.
57+pub fn window(app: &AppHandle) -> Option<Window> {
58+ app.get_window(WINDOW)
59+}
60+
61+/// The site's web view.
62+pub fn site(app: &AppHandle) -> Option<Webview> {
63+ app.get_webview(SITE)
64+}
65+
66+/// The main window, before its web views: g1t's frame, or the system's.
67+pub fn main_window<'a, R: Runtime, M: Manager<R>>(app: &'a M, custom: bool) -> tauri::Result<WindowBuilder<'a, R, M>> {
68+ #[cfg(target_os = "macos")]
69+ let builder = if custom {
70+ // The traffic lights stay, over the bar and centred in it.
71+ let config = tauri::utils::config::WindowConfig {
72+ label: WINDOW.into(),
73+ traffic_light_position: Some(tauri::utils::config::LogicalPosition { x: 14.0, y: (HEIGHT - 14.0) / 2.0 }),
74+ ..Default::default()
75+ };
76+ WindowBuilder::from_config(app, &config)?.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true)
77+ } else {
78+ WindowBuilder::new(app, WINDOW)
79+ };
80+ #[cfg(not(target_os = "macos"))]
81+ let builder = WindowBuilder::new(app, WINDOW).decorations(!custom);
82+ Ok(builder.title("g1t").inner_size(1280.0, 840.0).min_inner_size(720.0, 480.0).visible(false))
83+}
84+
85+/// The bar's and the site's web views, in the main window, as tall as the window leaves them.
86+pub fn fill(window: &Window, custom: bool, site: WebviewBuilder<tauri::Wry>) -> tauri::Result<()> {
87+ let size = logical_size(window);
88+ let top = if custom { HEIGHT } else { 0.0 };
89+ if custom {
90+ window.add_child(
91+ WebviewBuilder::new(BAR, WebviewUrl::App("titlebar.html".into())).background_color(RAIL).on_navigation(config::is_launcher),
92+ LogicalPosition::new(0.0, 0.0),
93+ LogicalSize::new(size.width, HEIGHT),
94+ )?;
95+ }
96+ window.add_child(site.background_color(RAIL), LogicalPosition::new(0.0, top), LogicalSize::new(size.width, (size.height - top).max(0.0)))?;
97+ #[cfg(target_os = "linux")]
98+ if custom {
99+ pack_bar(window);
100+ }
101+ Ok(())
102+}
103+
104+/// On Linux the web views sit in a GTK box, one under the other, which
105+/// shares the window between them equally whatever size they are given:
106+/// the bar is told to keep its height, and the site takes the rest.
107+#[cfg(target_os = "linux")]
108+fn pack_bar(window: &Window) {
109+ use gtk::prelude::*;
110+ let Ok(vbox) = window.default_vbox() else { return };
111+ // The bar is the first web view added; the menu bar, hidden, is not one.
112+ if let Some(bar) = vbox.children().into_iter().find(|child| child.type_().name() == "WebKitWebView") {
113+ vbox.set_child_packing(&bar, false, true, 0, gtk::PackType::Start);
114+ bar.set_size_request(-1, HEIGHT as i32);
115+ }
116+}
117+
118+fn logical_size(window: &Window) -> LogicalSize<f64> {
119+ let scale = window.scale_factor().unwrap_or(1.0);
120+ window.inner_size().map(|size| size.to_logical(scale)).unwrap_or(LogicalSize::new(1280.0, 840.0))
121+}
122+
123+/// The window changed size: the bar keeps its height, and the site takes the rest.
124+pub fn layout(window: &Window) {
125+ let app = window.app_handle();
126+ let size = logical_size(window);
127+ let bar = app.get_webview(BAR);
128+ let top = if bar.is_some() { HEIGHT } else { 0.0 };
129+ if let Some(bar) = bar {
130+ let _ = bar.set_position(LogicalPosition::new(0.0, 0.0));
131+ let _ = bar.set_size(LogicalSize::new(size.width, HEIGHT));
132+ }
133+ if let Some(site) = site(app) {
134+ let _ = site.set_position(LogicalPosition::new(0.0, top));
135+ let _ = site.set_size(LogicalSize::new(size.width, (size.height - top).max(0.0)));
136+ }
137+}
138+
139+/// The settings window: g1t's frame, or the system's. Its page draws its
140+/// own bar (launcher/settings.html), being g1t's own.
141+pub fn dialog<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> {
34142 if !custom {
35143 return builder;
36144 }
37145 #[cfg(target_os = "macos")]
38146 {
39− // The traffic lights stay, over the bar and centred in it.
40147 builder
41148 .title_bar_style(tauri::TitleBarStyle::Overlay)
42149 .hidden_title(true)
51158 /// The main window's menu bar: shown under the system's frame, and kept
52159 /// hidden under g1t's, whose menu button opens the same menu (`menu`) and
53160 /// whose shortcuts still work.
54−pub fn menu_bar(window: &WebviewWindow, custom: bool) {
161+pub fn menu_bar(window: &Window, custom: bool) {
55162 let _ = if custom { window.hide_menu() } else { window.show_menu() };
56163 // GTK shows everything in a window each time the window is shown, the
57164 // hidden menu bar too, unless the menu bar is told to sit that out.
64171 }
65172 }
66173
67−/// What a button on the bar asks of the window it is in.
68−pub fn control(app: &AppHandle, window: &WebviewWindow, action: &str) -> Result<bool, String> {
174+/// What a window button asks of the window it is in: `minimize`,
175+/// `maximize` (which toggles), `close`, `drag`, or `maximized` to ask.
176+/// Answers whether the window is maximized.
177+pub fn control(app: &AppHandle, window: &Window, action: &str) -> Result<bool, String> {
69178 let main = window.label() == WINDOW;
70179 match action {
71180 "minimize" => window.minimize().map_err(|error| error.to_string())?,
83192 }
84193
85194 /// The menu button: the window's menu, under the button.
86−pub fn menu(window: &WebviewWindow, x: f64, y: f64) -> Result<(), String> {
195+pub fn menu(window: &Window, x: f64, y: f64) -> Result<(), String> {
87196 #[cfg(target_os = "macos")]
88197 let menu = window.app_handle().menu();
89198 #[cfg(not(target_os = "macos"))]
91200 let menu = menu.ok_or("the window has no menu")?;
92201 window.popup_menu_at(&menu, LogicalPosition::new(x, y)).map_err(|error| error.to_string())
93202 }
203+
204+/// The page's colours, as the bar takes them: CSS colours, resolved.
205+#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
206+pub struct Colors {
207+ pub background: String,
208+ pub foreground: String,
209+ pub muted: String,
210+ pub accent: String,
211+ /// Whether the page is dark, for the bar's own hover shades.
212+ pub dark: bool,
213+}
214+
215+/// What the bar shows of the page in the window under it.
216+#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
217+#[serde(rename_all = "camelCase")]
218+pub struct Page {
219+ pub title: String,
220+ /// The site's search, by its label ("Search Flagon"), when it has handed one over.
221+ pub search: Option<String>,
222+ /// Whether there is a page to go back or forward to; absent where the web view cannot tell.
223+ pub can_go_back: Option<bool>,
224+ pub can_go_forward: Option<bool>,
225+ pub colors: Option<Colors>,
226+}
227+
228+/// The page as last reported, kept for a bar that loads after it.
229+#[derive(Default)]
230+pub struct State(Mutex<Page>);
231+
232+/// The site's page reported what it is: the bar is told.
233+pub fn page(app: &AppHandle, mut page: Page) {
234+ page.title.truncate(300);
235+ if let Some(search) = page.search.as_mut() {
236+ search.truncate(120);
237+ }
238+ let state = app.state::<State>();
239+ let mut kept = state.0.lock().unwrap();
240+ // A page that knows no colours of its own keeps the last ones.
241+ if page.colors.is_none() {
242+ page.colors = kept.colors.clone();
243+ }
244+ if *kept == page {
245+ return;
246+ }
247+ *kept = page.clone();
248+ drop(kept);
249+ let _ = app.emit_to(EventTarget::webview(BAR), PAGE_EVENT, page);
250+}
251+
252+/// What the bar shows now: it asks as it loads.
253+pub fn current(app: &AppHandle) -> Page {
254+ app.state::<State>().0.lock().unwrap().clone()
255+}
256+
257+/// A button on the bar that acts on the page: `back`, `forward`, `search`,
258+/// or `focus`, which hands the keyboard back to the page after a click on
259+/// the bar.
260+pub fn act(app: &AppHandle, action: &str) -> Result<(), String> {
261+ let site = site(app).ok_or("there is no page")?;
262+ match action {
263+ "back" => site.eval("history.back()").map_err(|error| error.to_string())?,
264+ "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?,
265+ "search" => app.emit_to(EventTarget::webview(SITE), SEARCH_EVENT, ()).map_err(|error| error.to_string())?,
266+ "focus" => {}
267+ other => return Err(format!("the bar cannot {other}")),
268+ }
269+ let _ = site.set_focus();
270+ Ok(())
271+}
+5−7
44 //! started the app is read on the way up; one that arrives while it runs
55 //! comes through the deep-link plugin, from a second launch too.
66
7−use tauri::{AppHandle, Emitter, Manager};
7+use tauri::{AppHandle, Emitter, EventTarget};
88 use tauri_plugin_deep_link::DeepLinkExt;
99 use url::Url;
1010
11−use crate::{WINDOW, config, tray};
11+use crate::{config, frame, tray};
1212
1313 /// What the page listens for (bridge.js).
1414 pub const EVENT: &str = "g1t-navigate";
6060 pub fn go(app: &AppHandle, path: &str) {
6161 tray::show(app);
6262 if config::on_site(app) {
63− let _ = app.emit_to(WINDOW, EVENT, path.to_string());
63+ let _ = app.emit_to(EventTarget::webview(frame::SITE), EVENT, path.to_string());
6464 return;
6565 }
66− if let Some(window) = app.get_webview_window(WINDOW) {
67− if let Ok(target) = config::server(app).join(path.trim_start_matches('/')) {
68− let _ = window.navigate(target);
69− }
66+ if let (Some(site), Ok(target)) = (frame::site(app), config::server(app).join(path.trim_start_matches('/'))) {
67+ let _ = site.navigate(target);
7068 }
7169 }
7270
+25−13
2323 mod tray;
2424 mod updates;
2525
26−use tauri::{RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent};
26+use tauri::webview::WebviewBuilder;
27+use tauri::{RunEvent, WebviewUrl, WindowEvent};
2728 use tauri_plugin_window_state::{StateFlags, WindowExt};
2829
2930 /// What the window keeps between launches: its size, place, and whether it
5859 // Opening at sign-in, which settings turn on; the argument tells that start from someone's.
5960 .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![settings::AT_LOGIN])))
6061 .manage(config::State::default())
62+ .manage(frame::State::default())
6163 .invoke_handler(tauri::generate_handler![
6264 bridge::desktop_info,
6365 bridge::notify,
7577 bridge::restart,
7678 bridge::window_control,
7779 bridge::window_menu,
80+ bridge::title_bar_page,
81+ bridge::title_bar_current,
82+ bridge::title_bar_act,
7883 ])
7984 .on_menu_event(|app, event| menu::handle(app, event.id().as_ref()))
8085 .setup(|app| {
8590 let hidden = settings::starts_hidden(&handle);
8691 let prefs = config::settings(&handle);
8792 let custom = frame::started(&handle);
88− let builder = WebviewWindowBuilder::new(app, WINDOW, WebviewUrl::App("index.html".into()))
89− .title("g1t")
90− .inner_size(1280.0, 840.0)
91− .min_inner_size(720.0, 480.0)
92− .visible(false)
93− .initialization_script(bridge::script(WINDOW, custom))
93+ // The site's web view: the launcher first, which goes on to the server.
94+ let site = WebviewBuilder::new(frame::SITE, WebviewUrl::App("index.html".into()))
95+ .initialization_script(bridge::script(custom))
9496 .on_navigation({
9597 let handle = handle.clone();
9698 move |url| navigation::allowed(&handle, url)
99101 let handle = handle.clone();
100102 move |_webview, payload| config::at(&handle, payload.url().clone())
101103 });
104+ let builder = frame::main_window(app, custom)?;
102105 // macOS has one menu bar, the app's; elsewhere the menu is the window's own.
103106 #[cfg(target_os = "macos")]
104107 app.set_menu(menu::build(&handle)?)?;
105108 #[cfg(not(target_os = "macos"))]
106109 let builder = builder.menu(menu::build(&handle)?);
107− let window = frame::style(builder, custom).build()?;
110+ let window = builder.build()?;
108111 // Opened at sign-in, g1t waits in the tray; otherwise the window comes up where it was.
109112 let _ = window.restore_state(KEPT - StateFlags::VISIBLE);
113+ frame::fill(&window, custom, site)?;
114+ frame::layout(&window);
110115 #[cfg(not(target_os = "macos"))]
111116 frame::menu_bar(&window, custom);
112117 if !hidden {
113118 let _ = window.show();
119+ if let Some(site) = frame::site(&handle) {
120+ let _ = site.set_focus();
121+ }
114122 }
115− if prefs.zoom != 1.0 {
116− let _ = window.set_zoom(prefs.zoom);
123+ if let Some(site) = frame::site(&handle).filter(|_| prefs.zoom != 1.0) {
124+ let _ = site.set_zoom(prefs.zoom);
117125 }
118− // Closing the window keeps g1t running, for notifications, unless settings say it quits.
126+ // Closing the window keeps g1t running, for notifications, unless
127+ // settings say it quits. A new size gives the bar and the site theirs.
119128 window.on_window_event({
120129 let handle = handle.clone();
121− move |event| {
122− if let WindowEvent::CloseRequested { api, .. } = event {
130+ let window = window.clone();
131+ move |event| match event {
132+ WindowEvent::CloseRequested { api, .. } => {
123133 api.prevent_close();
124134 tray::close(&handle);
125135 }
136+ WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => frame::layout(&window),
137+ _ => {}
126138 }
127139 });
128140 tray::install(&handle)?;
+6−6
55 //! every menu event, from either, comes to `handle`.
66
77 use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
8−use tauri::{AppHandle, Manager, Wry};
8+use tauri::{AppHandle, Wry};
99 use tauri_plugin_opener::OpenerExt;
1010
11−use crate::{WINDOW, links, settings, tray, updates};
11+use crate::{links, settings, tray, updates};
1212
1313 const DOCS: &str = "https://docs.g1t.sh/guides/desktop/";
1414 const STATUS: &str = "https://status.g1t.sh/";
130130
131131 /// Runs the script on the main window's page.
132132 fn eval(app: &AppHandle, script: &str) {
133− if let Some(window) = app.get_webview_window(WINDOW) {
134− let _ = window.eval(script);
133+ if let Some(site) = crate::frame::site(app) {
134+ let _ = site.eval(script);
135135 }
136136 }
137137
147147 "quit" => updates::quit(app),
148148 "close" => tray::close(app),
149149 "reload" => {
150− if let Some(window) = app.get_webview_window(WINDOW) {
151− let _ = window.reload();
150+ if let Some(site) = crate::frame::site(app) {
151+ let _ = site.reload();
152152 }
153153 }
154154 "zoom-in" => settings::zoom(app, 1),
+8−6
1010 use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
1111 use tauri_plugin_autostart::ManagerExt;
1212
13−use crate::{WINDOW, config};
13+use crate::config;
1414
1515 /// The settings window's label.
1616 pub const SETTINGS: &str = "settings";
7474 pub checked_at: Option<u64>,
7575 /// The title bar setting differs from the one the windows have: a restart applies it.
7676 pub title_bar_changed: bool,
77+ /// Whether this window draws g1t's own title bar, which the page then draws (settings.html).
78+ pub custom_frame: bool,
7779 }
7880
7981 pub fn view(app: &AppHandle) -> View {
8688 update_ready: crate::updates::ready(app),
8789 checked_at: crate::updates::checked_at(app),
8890 title_bar_changed: crate::frame::started(app) != crate::frame::custom(app),
91+ custom_frame: crate::frame::started(app),
8992 }
9093 }
9194
9497 settings.zoom = Settings::clamp_zoom(settings.zoom);
9598 let before = config::settings(app);
9699 config::set_settings(app, settings.clone())?;
97− if let Some(window) = app.get_webview_window(WINDOW).filter(|_| before.zoom != settings.zoom) {
98− let _ = window.set_zoom(settings.zoom);
100+ if let Some(site) = crate::frame::site(app).filter(|_| before.zoom != settings.zoom) {
101+ let _ = site.set_zoom(settings.zoom);
99102 }
100103 if before.badge != settings.badge {
101104 crate::badge::refresh(app);
157160 let page = if check { "settings.html?check" } else { "settings.html" };
158161 let custom = crate::frame::started(app);
159162 let builder = WebviewWindowBuilder::new(app, SETTINGS, WebviewUrl::App(page.into()))
160− .title("g1t settings")
161− .initialization_script(crate::bridge::script(SETTINGS, custom));
162− let built = crate::frame::style(builder, custom)
163+ .title("g1t settings");
164+ let built = crate::frame::dialog(builder, custom)
163165 .inner_size(560.0, 680.0)
164166 .min_inner_size(440.0, 420.0)
165167 .maximizable(false)
+9−6
88
99 use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
1010 use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
11−use tauri::{AppHandle, Manager, Wry};
11+use tauri::{AppHandle, Wry};
1212
13−use crate::{WINDOW, config, updates};
13+use crate::{config, updates};
1414
1515 pub const TRAY: &str = "g1t";
1616
7171 }
7272
7373 pub fn show(app: &AppHandle) {
74− if let Some(window) = app.get_webview_window(WINDOW) {
74+ if let Some(window) = crate::frame::window(app) {
7575 let _ = window.show();
7676 let _ = window.unminimize();
7777 let _ = window.set_focus();
7878 }
79+ if let Some(site) = crate::frame::site(app) {
80+ let _ = site.set_focus();
81+ }
7982 }
8083
8184 /// The window was closed: it waits in the tray, or g1t quits, as settings say.
8891 }
8992
9093 pub fn hide(app: &AppHandle) {
91− if let Some(window) = app.get_webview_window(WINDOW) {
94+ if let Some(window) = crate::frame::window(app) {
9295 let _ = window.hide();
9396 }
9497 }
106109
107110 /// Takes the window to the launcher: `change` to choose another server.
108111 pub fn launcher(app: &AppHandle, query: &str) {
109− if let Some(window) = app.get_webview_window(WINDOW) {
110− let _ = window.navigate(launcher_url(query));
112+ if let Some(site) = crate::frame::site(app) {
113+ let _ = site.navigate(launcher_url(query));
111114 show(app);
112115 }
113116 }
+1−1
9090
9191 Drag the bar to move the window, and double-click it to maximize or
9292 restore it. The bar is there on every page the window shows, signing in
93−with GitHub included.
93+with GitHub included, and the page sits below it, never under it.
9494
9595 To have the system's title bar instead, turn on **Use the system's title
9696 bar** in [the settings](#settings). On Windows and Linux the menu bar
+0−17
372372 --topbar-h: calc(var(--frame-top) + 3.5rem);
373373 --page-h: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
374374 }
375−/*
376− * The desktop app's own title bar (apps/desktop, src/frame.rs), drawn over
377− * the top of the window on <html data-titlebar>: what is above the page is
378− * the bar, whatever the frame. --titlebar-h is for frames that do not
379− * read --frame-top (the public header, the standalone frame). The bar
380− * pushes any page down by itself; g1t's pages make room their own way.
381− */
382−:root {
383− --titlebar-h: 0px;
384−}
385−html[data-titlebar] {
386− --titlebar-h: 36px;
387− padding-top: 0;
388−}
389−html[data-titlebar] body {
390− --frame-top: var(--titlebar-h);
391−}
392375 @media (max-width: 767px) {
393376 body[data-frame="app"] {
394377 --frame-top: env(safe-area-inset-top);
+1−1
6666 const params = useParams();
6767 const repo = params.owner && params.repo ? `${params.owner}/${params.repo}` : null;
6868 return (
69− <header className="sticky top-(--titlebar-h) z-40 border-b border-line bg-surface/85 backdrop-blur">
69+ <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur">
7070 <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
7171 <Link to="/" aria-label="g1t home" className="mr-2 flex">
7272 <Logo />
+1−1
1919 const terms = POLICIES.find((policy) => policy.slug === "terms");
2020 const privacy = POLICIES.find((policy) => policy.slug === "privacy");
2121 return (
22− <div className="flex min-h-dvh flex-col pt-[max(env(safe-area-inset-top),var(--titlebar-h))] pb-[env(safe-area-inset-bottom)]">
22+ <div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
2323 <Progress />
2424 <div id="content" tabIndex={-1} {...leaving} className={`flex grow flex-col items-center px-4 pt-[max(2.5rem,9vh)] pb-10 outline-none ${leaving.className}`}>
2525 <Link to="/" aria-label="g1t" className="flex rounded-md p-1 outline-none focus-visible:ring-2 focus-visible:ring-accent">
+2−2
7474 * // The search in the middle of the app's own title bar: its label, and
7575 * // what it opens. Returns what takes it away.
7676 * setSearch(label, open) { return () => {} },
77− * // How tall the app's own title bar is, 0 under the system's. The page
78− * // also finds it as <html data-titlebar> and --titlebar-h (app.css).
77+ * // How tall the app's own title bar is, 0 under the system's. The bar
78+ * // is a web view of its own, above the page, never over it.
7979 * titleBarHeight: 36,
8080 * };
8181 *
+0−2
443443 ) : (
444444 <>
445445 <Progress />
446− {/* Under the desktop app's title bar, the header starts below it. */}
447− <div aria-hidden="true" className="h-(--titlebar-h) shrink-0" />
448446 <PublicHeader user={user} />
449447 {banner}
450448 <div id="content" tabIndex={-1} {...leaving} className={`grow outline-none ${leaving.className}`}>