KAN-51: add isolated Tauri macOS app scaffold

This commit is contained in:
2026-09-26 18:41:04 +00:00
parent 7e19364757
commit 2a56d03dbf
21 changed files with 5267 additions and 1 deletions
+4
View File
@@ -43,3 +43,7 @@ storage/framework/sessions/*
storage/framework/testing/* storage/framework/testing/*
storage/framework/views/* storage/framework/views/*
storage/logs/* storage/logs/*
# Native build artifacts are independent of the Laravel image.
native/src-tauri/target
native/src-tauri/gen
+4
View File
@@ -28,3 +28,7 @@ yarn-error.log
/.vscode /.vscode
/.zed /.zed
.codex/config.toml .codex/config.toml
/native/node_modules
/native/src-tauri/target
/native/src-tauri/gen
+3 -1
View File
@@ -27,7 +27,9 @@ const eslintConfig = [
ignores: [ ignores: [
'bootstrap/ssr/**', 'bootstrap/ssr/**',
'**/dist/**', '**/dist/**',
'node_modules/**', '**/node_modules/**',
'native/src-tauri/target/**',
'native/src-tauri/gen/**',
'.github/**', '.github/**',
'**/*.yml', '**/*.yml',
'**/*.yaml', '**/*.yaml',
+17
View File
@@ -0,0 +1,17 @@
{
"name": "kanbangit-native",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"build:macos": "tauri build --debug --bundles app",
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke"
},
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
}
}
+140
View File
@@ -0,0 +1,140 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@tauri-apps/cli':
specifier: 2.11.4
version: 2.11.4
packages:
'@tauri-apps/cli-darwin-arm64@2.11.4':
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.11.4':
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.11.4':
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.11.4':
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
engines: {node: '>= 10'}
hasBin: true
snapshots:
'@tauri-apps/cli-darwin-arm64@2.11.4':
optional: true
'@tauri-apps/cli-darwin-x64@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.11.4':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
optional: true
'@tauri-apps/cli@2.11.4':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.11.4
'@tauri-apps/cli-darwin-x64': 2.11.4
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-musl': 2.11.4
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
+1
View File
@@ -0,0 +1 @@
minimumReleaseAge: 10080
+4509
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "kanbangit-native"
version = "0.1.0"
description = "KanbanGit native application"
edition = "2021"
publish = false
[lib]
name = "kanbangit_native"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
serde_json = "1"
[features]
smoke-test = []
custom-protocol = ["tauri/custom-protocol"]
[[test]]
name = "macos_smoke"
harness = false
required-features = ["smoke-test"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+214
View File
@@ -0,0 +1,214 @@
use tauri::{
webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
const SERVER_URL: &str = "https://kanbangit.de/";
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
destination.scheme() == "https"
&& destination.origin() == server.origin()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn allows_launcher_navigation(destination: &Url) -> bool {
destination.scheme() == "tauri"
&& destination.host_str() == Some("localhost")
&& destination.port().is_none()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn show_launcher(app: &AppHandle) {
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.show();
let _ = window.set_focus();
}
}
fn open_workspace(app: &AppHandle) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window("workspace") {
window.show()?;
window.set_focus()?;
return Ok(());
}
let server = Url::parse(SERVER_URL).expect("The built-in server URL must be valid");
let window = WebviewWindowBuilder::new(app, "workspace", WebviewUrl::External(server.clone()))
.title("KanbanGit")
.inner_size(1280.0, 850.0)
.min_inner_size(375.0, 600.0)
.center()
.on_navigation(move |destination| allows_server_navigation(&server, destination))
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_page_load(|window, payload| {
if payload.event() == PageLoadEvent::Finished {
if let Some(launcher) = window.app_handle().get_webview_window("launcher") {
let _ = launcher.hide();
}
#[cfg(debug_assertions)]
eprintln!(
"KanbanGit workspace page loaded: {}",
payload.url().origin().ascii_serialization()
);
}
})
.build()?;
let handle = app.clone();
window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) {
show_launcher(&handle);
}
});
Ok(())
}
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
let handle = app.handle().clone();
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
.title("KanbanGit")
.inner_size(560.0, 520.0)
.min_inner_size(375.0, 480.0)
.center()
.on_navigation(move |destination| {
if allows_launcher_navigation(destination) {
return true;
}
if destination.as_str() == SERVER_URL {
let handle = handle.clone();
// Defer window creation until the navigation callback has returned.
let _ = handle.clone().run_on_main_thread(move || {
if let Err(error) = open_workspace(&handle) {
eprintln!("Could not open the workspace: {error}");
show_launcher(&handle);
}
});
}
false
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.build()?;
Ok(())
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
builder().run(context()).expect("Could not start KanbanGit");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_allows_application_routes_on_the_same_https_origin() {
let server = Url::parse(SERVER_URL).unwrap();
for path in [
"/",
"/login",
"/two-factor-challenge",
"/projects/1?ticket=51#details",
] {
assert!(allows_server_navigation(
&server,
&server.join(path).unwrap()
));
}
}
#[test]
fn server_rejects_origin_changes_downgrades_credentials_and_local_content() {
let server = Url::parse(SERVER_URL).unwrap();
for destination in [
"http://kanbangit.de/",
"https://kanbangit.de:444/",
"https://kanbangit.de.example.com/",
"https://example.com/",
"https://user:password@kanbangit.de/",
"https://user@kanbangit.de/",
"tauri://localhost/index.html",
"file:///etc/passwd",
"data:text/html,hello",
"javascript:alert(1)",
] {
assert!(
!allows_server_navigation(&server, &Url::parse(destination).unwrap()),
"{destination}"
);
}
}
#[test]
fn launcher_only_loads_bundled_pages() {
assert!(allows_launcher_navigation(
&Url::parse("tauri://localhost/index.html").unwrap()
));
for destination in [
SERVER_URL,
"https://localhost/",
"http://localhost:1420/",
"tauri://other/index.html",
"tauri://user@localhost/index.html",
] {
assert!(!allows_launcher_navigation(
&Url::parse(destination).unwrap()
));
}
}
#[test]
fn remote_pages_cannot_invoke_native_window_webview_or_path_commands() {
let app = tauri::test::mock_builder().build(context()).unwrap();
let window = WebviewWindowBuilder::new(
&app,
"workspace",
WebviewUrl::External(Url::parse(SERVER_URL).unwrap()),
)
.build()
.unwrap();
for command in [
"plugin:window|close",
"plugin:webview|create_webview_window",
"plugin:path|resolve_directory",
] {
let response = tauri::test::get_ipc_response(
&window,
tauri::webview::InvokeRequest {
cmd: command.into(),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
url: Url::parse(SERVER_URL).unwrap(),
body: tauri::ipc::InvokeBody::default(),
headers: Default::default(),
invoke_key: tauri::test::INVOKE_KEY.into(),
},
);
let error = response.expect_err("Remote native commands must be rejected");
assert!(error.as_str().unwrap().contains("not allowed"), "{error}");
}
}
#[test]
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(config["app"]["withGlobalTauri"], false);
assert_eq!(
config["app"]["security"]["capabilities"],
serde_json::json!([])
);
let capabilities = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("capabilities");
assert!(
!capabilities.exists() || std::fs::read_dir(capabilities).unwrap().next().is_none()
);
}
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
kanbangit_native::run();
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.1.0",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
},
"app": {
"windows": [],
"withGlobalTauri": false,
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'",
"capabilities": []
}
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": ["icons/icon.png", "icons/icon.icns"],
"macOS": {
"minimumSystemVersion": "11.0"
}
}
}
+107
View File
@@ -0,0 +1,107 @@
#[cfg(target_os = "macos")]
fn main() {
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tauri::{webview::PageLoadEvent, Manager};
let complete = Arc::new(AtomicBool::new(false));
let passed = Arc::new(AtomicBool::new(false));
let navigation_complete = complete.clone();
let navigation_passed = passed.clone();
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
.js_init_script("window.__nativeSmokeErrors = []; window.addEventListener('error', event => window.__nativeSmokeErrors.push(event.message || event.target.src), true);")
.on_page_load(|webview, payload| {
if payload.event() != PageLoadEvent::Finished {
return;
}
if webview.label() == "launcher" {
webview.eval(r#"
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (document.querySelector('.open-workspace')?.textContent === 'Open workspace') break;
await new Promise(resolve => setTimeout(resolve, 100));
}
const link = document.querySelector('.open-workspace');
const appearance = document.querySelector('#appearance');
const translated = link?.textContent === 'Open workspace';
appearance.value = 'dark';
appearance.dispatchEvent(new Event('change'));
const dark = getComputedStyle(document.documentElement).backgroundColor === 'rgb(15, 23, 42)';
appearance.value = 'light';
appearance.dispatchEvent(new Event('change'));
const light = getComputedStyle(document.documentElement).backgroundColor === 'rgb(247, 249, 251)';
appearance.value = 'system';
appearance.dispatchEvent(new Event('change'));
if (translated && dark && light) {
link.click();
} else {
location.href = 'tauri://localhost/__native_smoke_result?passed=false&stage=launcher&translated=' + translated + '&dark=' + dark + '&light=' + light + '&background=' + encodeURIComponent(getComputedStyle(document.documentElement).backgroundColor) + '&label=' + encodeURIComponent(link?.textContent) + '&errors=' + encodeURIComponent(JSON.stringify(window.__nativeSmokeErrors));
}
})();
"#).unwrap();
} else if webview.label() == "workspace" {
webview.eval(r#"
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (document.querySelector('#app')?.innerText.trim().length > 100) break;
await new Promise(resolve => setTimeout(resolve, 100));
}
const root = document.querySelector('#app');
const rendered = root?.innerText.trim().length > 100 && root.getBoundingClientRect().height > 0;
let denied = true;
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
try {
await window.__TAURI_INTERNALS__.invoke(cmd);
denied = false;
} catch (error) {
denied = denied && String(error).includes('not allowed');
}
}
const noGlobalApi = typeof window.__TAURI__ === 'undefined';
const report = new URL('/__native_smoke_result', location.origin);
report.searchParams.set('passed', String(rendered && denied && noGlobalApi));
report.searchParams.set('rendered', String(rendered));
report.searchParams.set('nativeDenied', String(denied));
report.searchParams.set('globalAbsent', String(noGlobalApi));
location.href = report.href;
})();
"#).unwrap();
}
})
.on_navigation(move |webview, url| {
if url.path() != "/__native_smoke_result" {
return true;
}
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
println!("macOS WebView smoke test: {}", url.query().unwrap_or_default());
navigation_passed.store(success, Ordering::SeqCst);
navigation_complete.store(true, Ordering::SeqCst);
webview.app_handle().exit(if success { 0 } else { 1 });
false
})
.build();
let app = kanbangit_native::builder()
.plugin(plugin)
.build(kanbangit_native::context())
.expect("The macOS application must start");
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
if !complete.load(Ordering::SeqCst) {
eprintln!("macOS smoke test timed out before the server UI rendered");
handle.exit(1);
}
});
app.run_return(|_, _| {});
assert!(
passed.load(Ordering::SeqCst),
"The native smoke test must complete successfully"
);
}
#[cfg(not(target_os = "macos"))]
fn main() {
panic!("Run this smoke test on macOS with an active graphical session");
}
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { en } from '../ui/locales/en.js';
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
test('every launcher text resolves to a nonempty English translation', () => {
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
assert.equal(typeof en[key], 'string', key);
assert.ok(en[key].trim(), key);
}
});
test('the launcher opens only the configured HTTPS workspace without an IPC bridge', () => {
const links = [...html.matchAll(/<a\s[^>]*href="([^"]+)"/g)];
assert.equal(links.length, 1);
assert.equal(links[0][1], 'https://kanbangit.de/');
const main = readFileSync(
new URL('../ui/main.js', import.meta.url),
'utf8',
);
assert.doesNotMatch(main, /__TAURI__|invoke\(/);
});
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>KanbanGit</title>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./main.js"></script>
</head>
<body>
<main>
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1>
<p class="description" data-i18n="description"></p>
<a class="open-workspace" href="https://kanbangit.de/" data-i18n="openWorkspace"></a>
<p class="server">kanbangit.de</p>
<label class="appearance">
<span data-i18n="appearance"></span>
<select id="appearance">
<option value="system" data-i18n="system"></option>
<option value="light" data-i18n="light"></option>
<option value="dark" data-i18n="dark"></option>
</select>
</label>
</main>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
export const en = {
appName: 'KanbanGit',
title: 'Your workspace, one click away.',
description: 'Open your projects and keep work moving.',
openWorkspace: 'Open workspace',
appearance: 'Appearance',
system: 'System',
light: 'Light',
dark: 'Dark',
};
+9
View File
@@ -0,0 +1,9 @@
import { en } from './locales/en.js';
for (const element of document.querySelectorAll('[data-i18n]')) {
element.textContent = en[element.dataset.i18n];
}
document.querySelector('#appearance').addEventListener('change', (event) => {
document.documentElement.dataset.appearance = event.target.value;
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#22c55e" />
<g fill="none" stroke="#004b1e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<rect x="16" y="16" width="32" height="32" rx="3" />
<path d="M27 16v32M37 16v32" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 363 B

+127
View File
@@ -0,0 +1,127 @@
:root {
--surface: #f7f9fb;
--foreground: #191c1e;
--muted: #505f76;
--primary: #006e2f;
--border: #bccbb9;
--control: #ffffff;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color-scheme: light dark;
color: var(--foreground);
background: var(--surface);
font-synthesis: none;
}
:root[data-appearance='light'] {
color-scheme: light;
}
:root[data-appearance='dark'] {
color-scheme: dark;
--surface: #0f172a;
--foreground: #f8fafc;
--muted: #b7c8e1;
--primary: #4ae176;
--border: #334155;
--control: #1e293b;
}
@media (prefers-color-scheme: dark) {
:root:not([data-appearance='light']) {
--surface: #0f172a;
--foreground: #f8fafc;
--muted: #b7c8e1;
--primary: #4ae176;
--border: #334155;
--control: #1e293b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
main {
width: min(100%, 380px);
}
.brand {
display: block;
margin-bottom: 24px;
}
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary);
}
h1 {
margin: 12px 0;
font-size: 28px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.description,
.server,
.appearance {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.open-workspace {
display: block;
margin-top: 28px;
padding: 12px 20px;
border-radius: 4px;
color: #004b1e;
background: #22c55e;
text-align: center;
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.open-workspace:hover {
background: #4ae176;
}
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 4px;
}
.server {
text-align: center;
font-size: 12px;
}
.appearance {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 32px;
}
select {
min-height: 36px;
padding: 4px 10px;
font: inherit;
border: 1px solid var(--border);
border-radius: 4px;
color: inherit;
background: var(--control);
}