Initial commit

This commit is contained in:
Joey Kimsey
2026-09-11 18:14:10 -04:00
commit f785af89cd
15 changed files with 4273 additions and 0 deletions

64
src/main/index.js Normal file
View File

@ -0,0 +1,64 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { registerSessionIpc } from './sessionIpc.js'
/**
* Open the main Capsule window.
*
* @return {void}
*/
function createWindow() {
const mainWindow = new BrowserWindow({
width: 920,
height: 640,
minWidth: 760,
minHeight: 520,
show: false,
autoHideMenuBar: true,
title: 'Capsule',
backgroundColor: '#0c1929',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.thetempusproject.capsule')
app.on('browser-window-created', (_event, window) => {
optimizer.watchWindowShortcuts(window)
})
registerSessionIpc()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

126
src/main/sessionIpc.js Normal file
View File

@ -0,0 +1,126 @@
/**
* IPC handlers for login, token connect, logout, and session reads.
*/
import { ipcMain } from 'electron'
import { findUser, isDeadTokenError, loginWithPassword, normalizeSiteUrl } from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
/**
* Build a stored session after a successful auth.
*
* @param {object} fields - connection fields
* @param {string} fields.siteUrl - canonical site base
* @param {string} fields.token - user or app token
* @param {string} [fields.username] - username used to sign in
* @param {string} fields.authMethod - password or token
* @return {Promise<object>} - public session
*/
async function persistConnection(fields) {
const siteUrl = fields.siteUrl
const token = fields.token
const username = String(fields.username || '').trim()
const authMethod = fields.authMethod
let userId = null
let apiReady = false
if (username) {
try {
const found = await findUser(siteUrl, token, username)
if (isDeadTokenError(found.error)) {
const dead = new Error(
found.error === 'token expired' ? 'That API token has expired.' : 'That API token was not accepted.'
)
dead.code = 'DEAD_TOKEN'
throw dead
}
userId = found.userId
apiReady = found.userId !== null
} catch (err) {
if (err?.code === 'DEAD_TOKEN') {
throw err
}
apiReady = false
}
}
const session = {
siteUrl,
lastSiteUrl: siteUrl,
username,
userId,
authMethod,
apiReady,
token
}
writeSession(session)
return publicSession(session)
}
/**
* Register session IPC. Call once after app ready.
*
* @return {void}
*/
export function registerSessionIpc() {
ipcMain.handle('capsule:session', () => {
return publicSession(readSession())
})
ipcMain.handle('capsule:login', async (_event, payload) => {
const siteUrl = normalizeSiteUrl(payload?.siteUrl)
const username = String(payload?.username || '').trim()
const password = String(payload?.password || '')
if (!username || !password) {
throw new Error('Username and password are required.')
}
const token = await loginWithPassword(siteUrl, username, password)
return persistConnection({ siteUrl, token, username, authMethod: 'password' })
})
ipcMain.handle('capsule:connectToken', async (_event, payload) => {
const siteUrl = normalizeSiteUrl(payload?.siteUrl)
const token = String(payload?.token || '').trim()
const username = String(payload?.username || '').trim()
if (!token) {
throw new Error('Paste an API token.')
}
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
})
ipcMain.handle('capsule:verify', async () => {
const session = readSession()
if (!session?.token || !session.siteUrl) {
return publicSession(session)
}
if (!session.username) {
return publicSession(session)
}
try {
const found = await findUser(session.siteUrl, session.token, session.username)
if (isDeadTokenError(found.error)) {
writeLastSite(session.siteUrl)
return publicSession(readSession())
}
session.userId = found.userId
session.apiReady = found.userId !== null
writeSession(session)
return publicSession(session)
} catch {
return publicSession(session)
}
})
ipcMain.handle('capsule:logout', () => {
const session = readSession()
writeLastSite(session?.siteUrl || session?.lastSiteUrl || '')
return publicSession(readSession())
})
}

118
src/main/sessionStore.js Normal file
View File

@ -0,0 +1,118 @@
/**
* Persist the connected TTP site and token under Electron userData.
* The token is encrypted with safeStorage when the OS keychain is available.
*/
import { app, safeStorage } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
/**
* Path to the session file in userData.
*
* @return {string} - absolute path
*/
export function sessionFilePath() {
return join(app.getPath('userData'), 'session.json')
}
/**
* Read the raw session object from disk. Token is decrypted into memory.
*
* @return {object|null} - session, or null when none is stored
*/
export function readSession() {
const file = sessionFilePath()
if (!existsSync(file)) {
return null
}
let parsed
try {
parsed = JSON.parse(readFileSync(file, 'utf8'))
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') {
return null
}
const session = { ...parsed }
if (session.tokenEnc && safeStorage.isEncryptionAvailable()) {
try {
session.token = safeStorage.decryptString(Buffer.from(session.tokenEnc, 'base64'))
} catch {
session.token = ''
}
}
return session
}
/**
* Write a session. Encrypts the token when safeStorage is available.
*
* @param {object} session - fields to persist
* @return {void}
*/
export function writeSession(session) {
const file = sessionFilePath()
mkdirSync(dirname(file), { recursive: true })
const payload = { ...session }
const token = payload.token
delete payload.token
delete payload.tokenEnc
if (token) {
if (safeStorage.isEncryptionAvailable()) {
payload.tokenEnc = safeStorage.encryptString(token).toString('base64')
} else {
payload.token = token
}
}
writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
}
/**
* Keep the last site URL after logout so the login form can refill it.
*
* @param {string} lastSiteUrl - canonical site base
* @return {void}
*/
export function writeLastSite(lastSiteUrl) {
writeSession({ lastSiteUrl })
}
/**
* Session fields the renderer may see. Never includes the token.
*
* @param {object|null} session - stored session
* @return {object} - public connection state
*/
export function publicSession(session) {
if (!session) {
return {
connected: false,
siteUrl: '',
username: '',
userId: null,
authMethod: '',
apiReady: false,
lastSiteUrl: ''
}
}
const token = session.token || ''
return {
connected: Boolean(token),
siteUrl: session.siteUrl || '',
username: session.username || '',
userId: session.userId ?? null,
authMethod: session.authMethod || '',
apiReady: Boolean(session.apiReady),
lastSiteUrl: session.lastSiteUrl || session.siteUrl || ''
}
}

162
src/main/ttpClient.js Normal file
View File

@ -0,0 +1,162 @@
/**
* HTTP calls to a TTP site. Runs in the main process so CORS does not apply.
*/
/**
* Normalize a TTP site URL to scheme + host + optional path, no trailing slash.
*
* @param {string} raw - what the user typed
* @return {string} - canonical site base
*/
export function normalizeSiteUrl(raw) {
const trimmed = String(raw ?? '').trim()
if (!trimmed) {
throw new Error('Enter the site URL.')
}
let parsed
try {
parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`)
} catch {
throw new Error('Enter a valid site URL.')
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Site URL must start with http:// or https://.')
}
const path = parsed.pathname.replace(/\/+$/, '')
return `${parsed.origin}${path === '/' ? '' : path}`
}
/**
* Call a TTP API path and parse JSON.
*
* @param {object} options - request
* @param {string} options.siteUrl - canonical site base
* @param {string} options.path - path starting with /api/
* @param {string} [options.method='GET'] - HTTP method
* @param {string} [options.token] - Bearer token
* @param {Record<string, string>} [options.form] - urlencoded body
* @return {Promise<object>} - parsed JSON
*/
export async function ttpRequest(options) {
const siteUrl = options.siteUrl
const path = options.path
const method = options.method || 'GET'
const token = options.token
const form = options.form
const url = `${siteUrl}${path}`
const headers = { Accept: 'application/json' }
let body
if (token) {
headers.Authorization = `Bearer ${token}`
}
if (form) {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(form).toString()
}
let response
try {
response = await fetch(url, { method, headers, body, redirect: 'follow' })
} catch {
throw new Error('Could not reach that site.')
}
const text = await response.text()
let data
try {
data = JSON.parse(text)
} catch {
throw new Error(`The site did not return API JSON (${response.status}).`)
}
return data
}
/**
* Map a TTP API error string to a short user-facing line.
*
* @param {string} code - API `error` value
* @return {string} - message for the login form
*/
export function apiErrorMessage(code) {
switch (code) {
case 'malformed input':
return 'Username and password are required.'
case 'bad credentials':
return 'Those credentials were not accepted.'
case 'invalid token':
case 'invalid secret':
return 'That API token was not accepted.'
case 'token expired':
return 'That API token has expired.'
case 'IRDK':
return 'The site could not refresh this token.'
default:
return code || 'The site returned an error.'
}
}
/**
* Sign in with username and password. POST api/login.
*
* @param {string} siteUrl - canonical site base
* @param {string} username - TTP username
* @param {string} password - TTP password
* @return {Promise<string>} - user token
*/
export async function loginWithPassword(siteUrl, username, password) {
const data = await ttpRequest({
siteUrl,
path: '/api/login',
method: 'POST',
form: { username, password }
})
if (data.error) {
throw new Error(apiErrorMessage(data.error))
}
if (!data.token) {
throw new Error('The site did not return a token.')
}
return data.token
}
/**
* Look up a user id. GET api/users/find/{id|username}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string} idOrUsername - user id or username
* @return {Promise<{userId: number|string|null, error: string}>} - id on success
*/
export async function findUser(siteUrl, token, idOrUsername) {
const path = `/api/users/find/${encodeURIComponent(idOrUsername)}`
const data = await ttpRequest({ siteUrl, path, method: 'GET', token })
if (data.error) {
return { userId: null, error: String(data.error) }
}
if (data.data == null) {
return { userId: null, error: 'No user found.' }
}
return { userId: data.data, error: '' }
}
/**
* True when the API rejected the stored Bearer token itself.
*
* @param {string} error - API `error` value
* @return {boolean} - true when the session should be cleared
*/
export function isDeadTokenError(error) {
return error === 'invalid token' || error === 'invalid secret' || error === 'token expired'
}

52
src/preload/index.js Normal file
View File

@ -0,0 +1,52 @@
import { contextBridge, ipcRenderer } from 'electron'
const capsule = {
/**
* Read the public session. Never includes the token.
*
* @return {Promise<object>} - connection state
*/
session() {
return ipcRenderer.invoke('capsule:session')
},
/**
* Sign in with a TTP username and password.
*
* @param {object} payload - siteUrl, username, password
* @return {Promise<object>} - public session
*/
login(payload) {
return ipcRenderer.invoke('capsule:login', payload)
},
/**
* Connect with an existing API token from Admin ? Tokens.
*
* @param {object} payload - siteUrl, token, optional username
* @return {Promise<object>} - public session
*/
connectToken(payload) {
return ipcRenderer.invoke('capsule:connectToken', payload)
},
/**
* Recheck a stored token against api/users/find.
*
* @return {Promise<object>} - public session
*/
verify() {
return ipcRenderer.invoke('capsule:verify')
},
/**
* Clear the stored token and return to the login view.
*
* @return {Promise<object>} - public session
*/
logout() {
return ipcRenderer.invoke('capsule:logout')
}
}
contextBridge.exposeInMainWorld('capsule', capsule)

149
src/renderer/index.html Normal file
View File

@ -0,0 +1,149 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capsule</title>
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<div>
<p class="brand-name">Capsule</p>
<p class="brand-tag">The Tempus Project</p>
</div>
</div>
<p id="top-status" class="top-status" hidden></p>
</header>
<main class="stage">
<section id="view-login" class="view" hidden>
<div class="card">
<h1>Connect a site</h1>
<p class="lede">
Sign in to any Tempus Project install. Capsule talks to that site<74>s API and
keeps the session on this machine.
</p>
<form id="login-form" class="form">
<label class="field">
<span>Site URL</span>
<input
id="login-site"
name="siteUrl"
type="url"
autocomplete="url"
placeholder="https://example.com"
required
/>
</label>
<label class="field">
<span>Username</span>
<input id="login-username" name="username" type="text" autocomplete="username" required />
</label>
<label class="field">
<span>Password</span>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
<p id="login-error" class="error" hidden></p>
<button id="login-submit" class="btn btn-primary" type="submit">Sign in</button>
</form>
<details id="token-panel" class="token-panel">
<summary>Use an API token instead</summary>
<form id="token-form" class="form">
<p class="hint">
Paste a personal or app token from Admin ? Tokens. Username is optional and
only used to confirm the token against <code>api/users/find</code>.
</p>
<label class="field">
<span>Site URL</span>
<input
id="token-site"
name="siteUrl"
type="url"
autocomplete="url"
placeholder="https://example.com"
required
/>
</label>
<label class="field">
<span>API token</span>
<input id="token-value" name="token" type="password" autocomplete="off" required />
</label>
<label class="field">
<span>Username <em>(optional)</em></span>
<input id="token-username" name="username" type="text" autocomplete="username" />
</label>
<p id="token-error" class="error" hidden></p>
<button id="token-submit" class="btn btn-secondary" type="submit">
Connect with token
</button>
</form>
</details>
</div>
</section>
<section id="view-home" class="view" hidden>
<div class="home-grid">
<article class="card identity">
<p class="eyebrow">Connected</p>
<h1 id="home-username">Signed in</h1>
<p id="home-site" class="site-line"></p>
<dl class="meta">
<div>
<dt>User ID</dt>
<dd id="home-userid"><EFBFBD></dd>
</div>
<div>
<dt>Auth</dt>
<dd id="home-method"><EFBFBD></dd>
</div>
<div>
<dt>API</dt>
<dd id="home-api"><EFBFBD></dd>
</div>
</dl>
<p id="home-note" class="hint" hidden></p>
<div class="actions">
<button id="logout-button" class="btn btn-secondary" type="button">Sign out</button>
<a id="open-site" class="btn btn-ghost" href="#" target="_blank" rel="noreferrer">
Open site
</a>
</div>
</article>
<article class="card workspace">
<p class="eyebrow">Workspace</p>
<h2>Desktop tools land here</h2>
<p class="lede">
This area will host the full desktop experience for the plugins enabled on
the connected site. The current API can sign you in, refresh a token, and
look up a user id. Feature screens wait on those API expansions.
</p>
<ul class="plan">
<li>Talk to a site of your choice with a user login or an API token</li>
<li>Keep the token in the OS keychain, not in the renderer</li>
<li>Load plugin-backed tools as the HTTP API grows</li>
</ul>
</article>
</div>
</section>
</main>
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>

166
src/renderer/src/main.js Normal file
View File

@ -0,0 +1,166 @@
/**
* Login and home views. The token never enters this process.
*/
const loginView = document.getElementById('view-login')
const homeView = document.getElementById('view-home')
const topStatus = document.getElementById('top-status')
const loginForm = document.getElementById('login-form')
const loginError = document.getElementById('login-error')
const loginSubmit = document.getElementById('login-submit')
const loginSite = document.getElementById('login-site')
const loginUsername = document.getElementById('login-username')
const tokenForm = document.getElementById('token-form')
const tokenError = document.getElementById('token-error')
const tokenSubmit = document.getElementById('token-submit')
const tokenSite = document.getElementById('token-site')
const tokenUsername = document.getElementById('token-username')
const homeUsername = document.getElementById('home-username')
const homeSite = document.getElementById('home-site')
const homeUserId = document.getElementById('home-userid')
const homeMethod = document.getElementById('home-method')
const homeApi = document.getElementById('home-api')
const homeNote = document.getElementById('home-note')
const openSite = document.getElementById('open-site')
const logoutButton = document.getElementById('logout-button')
/**
* Show or hide a status/error line.
*
* @param {HTMLElement} el - message node
* @param {string} [message] - text to show; empty hides the node
* @return {void}
*/
function setMessage(el, message) {
const text = String(message || '')
el.hidden = text === ''
el.textContent = text
}
/**
* Fill both site URL fields from the last used host.
*
* @param {string} siteUrl - canonical site base
* @return {void}
*/
function fillSiteFields(siteUrl) {
if (!siteUrl) {
return
}
loginSite.value = siteUrl
tokenSite.value = siteUrl
}
/**
* Render the login or home view from a public session.
*
* @param {object} session - connection state from main
* @return {void}
*/
function render(session) {
const connected = Boolean(session?.connected)
loginView.hidden = connected
homeView.hidden = !connected
fillSiteFields(session?.lastSiteUrl || session?.siteUrl || '')
if (!connected) {
topStatus.hidden = true
return
}
const username = session.username || 'API token'
homeUsername.textContent = session.username ? username : 'Signed in with token'
homeSite.textContent = session.siteUrl
homeUserId.textContent = session.userId == null ? '<27>' : String(session.userId)
homeMethod.textContent = session.authMethod === 'token' ? 'API token' : 'Password'
homeApi.textContent = session.apiReady ? 'Ready' : 'Limited'
openSite.href = session.siteUrl
topStatus.hidden = false
topStatus.classList.add('is-ok')
topStatus.textContent = 'Connected'
if (session.apiReady) {
setMessage(homeNote, '')
} else {
setMessage(
homeNote,
'Signed in, but api/users/find did not confirm this user. Turn on personal API access on the site, or add a username when connecting with a token.'
)
}
}
/**
* Run an auth IPC call and render the result or an error.
*
* @param {HTMLButtonElement} button - submit button to disable
* @param {HTMLElement} errorEl - error line
* @param {() => Promise<object>} work - IPC call
* @return {Promise<void>}
*/
async function runAuth(button, errorEl, work) {
button.disabled = true
setMessage(errorEl, '')
try {
render(await work())
} catch (err) {
setMessage(errorEl, err?.message || 'Sign-in failed.')
} finally {
button.disabled = false
}
}
loginForm.addEventListener('submit', (event) => {
event.preventDefault()
runAuth(loginSubmit, loginError, () =>
window.capsule.login({
siteUrl: loginSite.value,
username: loginUsername.value,
password: document.getElementById('login-password').value
})
)
})
tokenForm.addEventListener('submit', (event) => {
event.preventDefault()
runAuth(tokenSubmit, tokenError, () =>
window.capsule.connectToken({
siteUrl: tokenSite.value,
token: document.getElementById('token-value').value,
username: tokenUsername.value
})
)
})
logoutButton.addEventListener('click', async () => {
render(await window.capsule.logout())
})
openSite.addEventListener('click', (event) => {
const href = openSite.getAttribute('href')
if (!href || href === '#') {
event.preventDefault()
}
})
/**
* Load any stored session, then confirm it against the site when possible.
*
* @return {Promise<void>}
*/
async function boot() {
if (!window.capsule) {
setMessage(loginError, 'Preload bridge is missing. Restart Capsule.')
loginView.hidden = false
return
}
const session = await window.capsule.session()
if (session.connected) {
render(await window.capsule.verify())
return
}
render(session)
}
boot()

314
src/renderer/src/styles.css Normal file
View File

@ -0,0 +1,314 @@
/**
* Capsule desktop chrome. Tokens follow the TTP brand (logo #3fa9f5, chrome #0c1929).
*/
:root {
color-scheme: dark;
--canvas: #08111c;
--chrome: #0c1929;
--surface: #122033;
--surface-alt: #173049;
--text: #e8eef5;
--muted: #8aa0b5;
--border: #274056;
--brand: #3fa9f5;
--primary: #1784c9;
--primary-hover: #146ea8;
--danger: #f07178;
--ok: #3dd68c;
--focus-ring: rgba(63, 169, 245, 0.35);
--shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
--radius: 0.85rem;
--font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--canvas);
color: var(--text);
font-family: var(--font);
}
body {
background:
radial-gradient(900px 420px at 10% -10%, rgba(63, 169, 245, 0.16), transparent 55%),
radial-gradient(700px 360px at 100% 0%, rgba(23, 132, 201, 0.12), transparent 50%),
var(--canvas);
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem;
border-bottom: 1px solid var(--border);
background: rgba(12, 25, 41, 0.86);
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-mark {
width: 2rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(135deg, #7ec8f8, var(--brand) 55%, #1784c9);
box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12), 0 8px 18px rgba(63, 169, 245, 0.25);
}
.brand-name,
.brand-tag,
.eyebrow,
h1,
h2,
p,
dt,
dd,
label span,
button,
summary,
li {
margin: 0;
}
.brand-name {
font-size: 1rem;
font-weight: 650;
letter-spacing: 0.02em;
}
.brand-tag,
.eyebrow,
.hint,
.lede,
.meta dt,
.top-status {
color: var(--muted);
}
.brand-tag,
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.top-status {
font-size: 0.85rem;
}
.top-status.is-ok {
color: var(--ok);
}
.stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.view {
width: min(920px, 100%);
}
.card {
background: rgba(18, 32, 51, 0.92);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.6rem 1.7rem 1.5rem;
}
.card h1,
.card h2 {
margin: 0.35rem 0 0.7rem;
font-size: 1.55rem;
font-weight: 650;
}
.lede {
line-height: 1.55;
margin-bottom: 1.2rem;
}
.form {
display: grid;
gap: 0.85rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field span {
font-size: 0.82rem;
color: var(--muted);
}
.field em {
font-style: normal;
opacity: 0.75;
}
input {
width: 100%;
border: 1px solid var(--border);
background: #0c1929;
color: var(--text);
border-radius: 0.55rem;
padding: 0.65rem 0.75rem;
font: inherit;
}
input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 0.55rem;
padding: 0.65rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: wait;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-secondary {
background: var(--surface-alt);
color: var(--text);
border-color: var(--border);
}
.btn-ghost {
background: transparent;
color: var(--brand);
border-color: var(--border);
}
.error {
color: var(--danger);
font-size: 0.9rem;
}
.token-panel {
margin-top: 1.2rem;
border-top: 1px solid var(--border);
padding-top: 0.9rem;
}
.token-panel summary {
cursor: pointer;
color: var(--brand);
font-weight: 600;
}
.token-panel .form {
margin-top: 0.9rem;
}
.hint {
font-size: 0.88rem;
line-height: 1.5;
}
.hint code {
font-size: 0.84em;
}
.home-grid {
display: grid;
grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr);
gap: 1rem;
}
.identity h1 {
word-break: break-word;
}
.site-line {
color: var(--brand);
margin-bottom: 1rem;
}
.meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.7rem;
margin: 0 0 1rem;
}
.meta dt {
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.meta dd {
margin: 0.2rem 0 0;
font-weight: 600;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1rem;
}
.plan {
margin: 0;
padding-left: 1.1rem;
color: var(--muted);
display: grid;
gap: 0.45rem;
}
.plan li::marker {
color: var(--brand);
}
@media (max-width: 800px) {
.home-grid,
.meta {
grid-template-columns: 1fr;
}
}