add main appearance

This commit is contained in:
Joey Kimsey
2026-09-12 00:57:48 -04:00
parent f785af89cd
commit 17d83f7209
7 changed files with 1185 additions and 407 deletions

View File

@ -12,3 +12,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Electron + electron-vite desktop shell with a login view and a connected workspace.
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens.
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app. Email, password, and phone open the connected site.

View File

@ -6,7 +6,7 @@ This replaces the old `TempusToolkit` Electron stub. The keepers were the login
## Run it
From this folder (Windows source checkout is fine <20> Capsule is Node, not PHP):
From this folder (Windows source checkout is fine <20> Capsule is Node, not PHP):
```bash
npm install
@ -17,7 +17,7 @@ npm run dev
## How auth works
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP<54>s same-origin CORS policy does not apply.
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP<54>s same-origin CORS policy does not apply.
| Action | Endpoint | Notes |
|--------|----------|-------|
@ -35,7 +35,9 @@ App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
|------|-----|
| `src/main/` | Window, session file, TTP HTTP, IPC |
| `src/preload/` | `window.capsule` bridge |
| `src/renderer/` | Login view and connected workspace |
| `src/renderer/` | Login, TTP-styled chrome, search / notifications / messages / profile |
The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone <20> those open `{site}/usercp/<2F>`. Lists are placeholders until the API step.
## Remote

View File

@ -10,10 +10,10 @@ import { registerSessionIpc } from './sessionIpc.js'
*/
function createWindow() {
const mainWindow = new BrowserWindow({
width: 920,
height: 640,
minWidth: 760,
minHeight: 520,
width: 1180,
height: 760,
minWidth: 900,
minHeight: 600,
show: false,
autoHideMenuBar: true,
title: 'Capsule',

View File

@ -1,149 +1,493 @@
<!doctype html>
<html lang="en">
<html lang="en" data-bs-theme="light">
<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:;"
content="default-src 'self'; style-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src https://cdnjs.cloudflare.com; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: https: http:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capsule</title>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css"
integrity="sha384-QI8z31KmtR+tk1MYi0DfgxrjYgpTpLLol3bqZA/Q1Y8BvH+6k7/Huoj38gQOaCS7"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
crossorigin="anonymous"
/>
<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>
<body data-shell="login">
<header class="text-bg-dark">
<div class="capsule-header p-3">
<a href="#/" class="capsule-brand text-white text-decoration-none d-flex align-items-center" data-route="home">
<img id="header-logo" class="capsule-logo" width="40" height="40" alt="" hidden />
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Capsule</span>
</a>
<form id="header-search" class="capsule-search" role="search">
<fieldset>
<legend class="visually-hidden">Search the site</legend>
<label class="visually-hidden" for="search-resource">Search in</label>
<label class="visually-hidden" for="search-q">Search terms</label>
<div class="input-group">
<select class="form-select flex-grow-0 w-auto" name="resource" id="search-resource">
<option value="all">All</option>
<option value="posts">Posts</option>
<option value="pages">Pages</option>
</select>
<input
type="search"
class="form-control"
name="q"
id="search-q"
placeholder="Search"
autocomplete="off"
/>
<button type="submit" class="btn btn-primary" aria-label="Search">
<i class="fa fa-fw fa-search" aria-hidden="true"></i>
</button>
</div>
</fieldset>
</form>
<div class="capsule-account">
<div class="dropdown nav-item mx-2">
<a
href="#"
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
id="notificationsDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="Notifications"
>
<i class="fa fa-fw fa-bell" aria-hidden="true"></i>
<span id="notification-badge" class="badge bg-danger rounded-pill">2</span>
</a>
<ul
id="notification-menu"
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
data-bs-theme="dark"
aria-labelledby="notificationsDropdown"
>
<li><hr class="dropdown-divider" /></li>
<li>
<a href="#/notifications" class="dropdown-item text-center" data-route="notifications">
See All Notifications
</a>
</li>
</ul>
</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.
<div class="dropdown nav-item mx-2">
<a
href="#"
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
id="messagesDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="Messages"
>
<i class="fa fa-fw fa-envelope" aria-hidden="true"></i>
<span id="message-badge" class="badge bg-danger rounded-pill">1</span>
</a>
<ul
id="message-menu"
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
data-bs-theme="dark"
aria-labelledby="messagesDropdown"
>
<li><hr class="dropdown-divider" /></li>
<li>
<a href="#/messages" class="dropdown-item text-center" data-route="messages">All messages</a>
</li>
</ul>
</div>
<div class="dropdown nav-item mx-2">
<a
href="#"
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
id="userDropdown"
data-bs-toggle="dropdown"
aria-label="Account menu"
aria-expanded="false"
>
<img
id="header-avatar"
src=""
alt=""
width="32"
height="32"
class="rounded-circle me-2"
hidden
/>
<i id="header-avatar-fallback" class="fa fa-user me-2" aria-hidden="true"></i>
<strong id="header-username">Account</strong>
</a>
<ul
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
data-bs-theme="dark"
>
<li>
<a href="#/profile" class="dropdown-item" data-route="profile">
<i class="fa fa-fw fa-user"></i> Profile
</a>
</li>
<li>
<a href="#/notifications" class="dropdown-item" data-route="notifications">
<i class="fa fa-fw fa-bell"></i> Notifications
</a>
</li>
<li>
<a href="#/messages" class="dropdown-item" data-route="messages">
<i class="fa fa-fw fa-envelope"></i> Messages
</a>
</li>
<li>
<a href="#/settings" class="dropdown-item" data-route="settings">
<i class="fa fa-fw fa-gear"></i> Settings
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<a id="link-email" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
<i class="fa fa-fw fa-envelope"></i> Change email
<i class="fa fa-fw fa-external-link ms-1"></i>
</a>
</li>
<li>
<a id="link-password" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
<i class="fa fa-fw fa-lock"></i> Change password
<i class="fa fa-fw fa-external-link ms-1"></i>
</a>
</li>
<li>
<a id="link-phone" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
<i class="fa fa-fw fa-phone"></i> Phone
<i class="fa fa-fw fa-external-link ms-1"></i>
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button id="logout-button" type="button" class="dropdown-item">
<i class="fa fa-fw fa-power-off"></i> Log Out
</button>
</li>
</ul>
</div>
</div>
</div>
<nav class="capsule-mainnav" aria-label="Main">
<ul class="capsule-mainnav-list">
<li><a href="#/game" data-nav="game">Game</a></li>
<li><a href="#/friends" data-nav="friends">Friends</a></li>
<li><a href="#/cabal" data-nav="cabal">Cabal</a></li>
<li><a href="#/hiscores" data-nav="hiscores">HiScores</a></li>
<li><a href="#/shop" data-nav="shop">Shop</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<section id="view-login" class="view" hidden>
<div class="container pt-4">
<div class="mx-auto p-4 rounded context-main-bg capsule-login-card">
<h1 class="h3 mb-3 text-center">Connect a site</h1>
<p class="text-muted text-center mb-4">
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>
<form id="login-form">
<div class="mb-3">
<label class="form-label" for="login-site">Site URL</label>
<input
id="login-site"
class="form-control"
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>
</div>
<div class="mb-3">
<label class="form-label" for="login-username">Username</label>
<input
id="login-username"
class="form-control"
name="username"
type="text"
autocomplete="username"
required
/>
</div>
<div class="mb-3">
<label class="form-label" for="login-password">Password</label>
<input
id="login-password"
class="form-control"
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>
</div>
<p id="login-error" class="text-danger" hidden></p>
<button id="login-submit" class="btn btn-primary w-100" type="submit">
<i class="fa fa-fw fa-right-to-bracket"></i> Sign in
</button>
</form>
<details id="token-panel" class="token-panel">
<details id="token-panel" class="mt-4">
<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>.
<form id="token-form" class="mt-3">
<p class="small text-muted">
Paste a personal or app token from Admin ? Tokens. Username is optional until the
API can identify you.
</p>
<label class="field">
<span>Site URL</span>
<div class="mb-3">
<label class="form-label" for="token-site">Site URL</label>
<input
id="token-site"
class="form-control"
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">
</div>
<div class="mb-3">
<label class="form-label" for="token-value">API token</label>
<input id="token-value" class="form-control" name="token" type="password" autocomplete="off" required />
</div>
<div class="mb-3">
<label class="form-label" for="token-username">Username <span class="text-muted">(optional)</span></label>
<input id="token-username" class="form-control" name="username" type="text" autocomplete="username" />
</div>
<p id="token-error" class="text-danger" hidden></p>
<button id="token-submit" class="btn btn-outline-primary w-100" type="submit">
Connect with token
</button>
</form>
</details>
<p class="text-center mt-4 mb-0">
<button id="preview-shell" type="button" class="btn btn-link">
Preview the desktop chrome
</button>
</p>
</div>
</section>
</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>
<section id="view-stub" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3" id="stub-title"></h1>
</div>
</div>
</section>
<section id="view-home" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Workspace</h1>
<p class="text-muted" id="home-note">
Search, notifications, messages, and profile are here. Site API wiring is the next
step.
</p>
<p class="mb-0">
Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>
as <strong id="home-username"></strong>.
</p>
</div>
</div>
</section>
<section id="view-search" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Search</h1>
<p class="text-muted" id="search-summary"></p>
<p class="mb-0 text-muted">Results will come from the site search API next.</p>
</div>
</div>
</section>
<section id="view-notifications" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3 text-center">Notifications</h1>
<table class="table">
<tbody id="notification-list"></tbody>
</table>
</div>
</div>
</section>
<section id="view-messages" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Messages</h1>
<button type="button" class="btn btn-sm btn-primary" disabled>
<i class="fa fa-fw fa-pen"></i> New message
</button>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>With</th>
<th>Last message</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="message-list"></tbody>
</table>
</div>
</div>
</section>
<section id="view-profile" class="view" hidden>
<div class="container p-4 context-main-bg mb-4 text-center">
<h1 class="h3 mb-4">Profile</h1>
<hr />
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow">
<div class="card-header text-center bg-dark text-white">
<h2 class="h3 card-title mb-0" id="profile-name"></h2>
</div>
<div>
<dt>Auth</dt>
<dd id="home-method"><EFBFBD></dd>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-4 text-center">
<img
id="profile-avatar"
src=""
alt="User Pic"
class="rounded-circle img-fluid"
style="max-width: 150px"
/>
</div>
<div class="col-md-8">
<table class="table table-borderless text-start">
<tbody>
<tr>
<th scope="row">Registered:</th>
<td id="profile-registered"></td>
</tr>
<tr>
<th scope="row">Last Seen:</th>
<td id="profile-last-login"></td>
</tr>
<tr>
<th scope="row">Gender:</th>
<td id="profile-gender"></td>
</tr>
</tbody>
</table>
<a href="#/settings" class="btn btn-primary btn-sm" data-route="settings">
<i class="fa fa-fw fa-pencil"></i> Edit profile
</a>
</div>
</div>
</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>
</div>
</section>
</main>
</div>
</div>
</section>
<section id="view-settings" class="view" hidden>
<div class="container p-4 context-main-bg mb-4">
<h1 class="h3 mb-4 text-center">Settings</h1>
<hr />
<div class="row justify-content-center">
<div class="col-md-6">
<form id="settings-form">
<fieldset>
<legend class="visually-hidden">Preferences</legend>
<div class="mb-3 text-center">
<img
id="settings-avatar"
src=""
alt="Avatar"
class="rounded-circle img-fluid mb-2"
style="max-width: 125px"
/>
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
</div>
<div class="mb-3">
<label class="form-label" for="settings-gender">Gender</label>
<select id="settings-gender" class="form-select" name="gender">
<option value="unspecified">unspecified</option>
<option value="male">male</option>
<option value="female">female</option>
<option value="other">other</option>
</select>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="settings-newsletter" />
<label class="form-check-label" for="settings-newsletter">Receive our Newsletter?</label>
</div>
<div class="mb-3">
<label class="form-label" for="settings-timezone">Timezone</label>
<select id="settings-timezone" class="form-select" name="timezone"></select>
</div>
<div class="mb-3">
<label class="form-label" for="settings-date">Date Format</label>
<select id="settings-date" class="form-select" name="dateFormat"></select>
</div>
<div class="mb-3">
<label class="form-label" for="settings-time">Time Format</label>
<select id="settings-time" class="form-select" name="timeFormat"></select>
</div>
<div class="mb-3">
<label class="form-label" for="settings-limit">Items Displayed Per Page</label>
<select id="settings-limit" class="form-select" name="pageLimit"></select>
</div>
<div class="form-check form-switch mb-4">
<input class="form-check-input" type="checkbox" role="switch" id="settings-dark" />
<label class="form-check-label" for="settings-dark">Enable Dark-Mode viewing</label>
</div>
<p id="settings-note" class="small text-muted">
Saving will use the site API next. This form matches User CP preferences.
</p>
<p id="settings-status" class="text-success" hidden></p>
<button class="btn btn-lg btn-primary w-100" type="submit">Update</button>
</fieldset>
</form>
<div class="alert alert-info mt-4 mb-0">
Email, password, and phone stay on the site.
<div class="d-flex flex-wrap gap-2 mt-2">
<a id="settings-link-email" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
Change email <i class="fa fa-fw fa-external-link"></i>
</a>
<a id="settings-link-password" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
Change password <i class="fa fa-fw fa-external-link"></i>
</a>
<a id="settings-link-phone" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
Phone <i class="fa fa-fw fa-external-link"></i>
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"
></script>
<script type="module" src="./src/main.js"></script>
</body>
</html>

96
src/renderer/src/demo.js Normal file
View File

@ -0,0 +1,96 @@
/**
* Placeholder lists until the TTP API grows. Shapes match the live plugins.
*/
export const previewSession = {
connected: true,
preview: true,
siteUrl: 'https://ttp.joeykimsey.com',
username: 'Joey',
userId: 1,
authMethod: 'password',
apiReady: false,
lastSiteUrl: 'https://ttp.joeykimsey.com'
}
export const demoNotifications = [
{
id: 'n1',
unread: true,
createdAt: '2 hours ago',
html: 'Welcome to Capsule. Notifications will load from the site API next.'
},
{
id: 'n2',
unread: false,
createdAt: 'Yesterday',
html: 'Your profile preferences can be edited here. Email, password, and phone stay on the site.'
}
]
export const demoMessages = [
{
id: 'm1',
unread: true,
otherUser: 'Alex',
preview: 'Did the desktop shell pick up the new header?',
lastMessageAt: 'Today'
},
{
id: 'm2',
unread: false,
otherUser: 'Sam',
preview: 'Search should stay in the top middle <20> full width.',
lastMessageAt: 'Monday'
}
]
export const demoProfile = {
username: 'Joey',
usernamePretty: 'Joey',
gender: 'unspecified',
newsletter: true,
timezone: 'America/New_York',
dateFormat: 'F-j-Y',
timeFormat: 'g:i:s A',
pageLimit: '10',
darkMode: false,
registered: 'September 2024',
lastLogin: 'Just now'
}
export const dateFormatOptions = [
{ label: 'January 8, 1991', value: 'F-j-Y' },
{ label: '8 January, 1991', value: 'j-F-Y' },
{ label: 'Jan 8, 1991', value: 'M-j-Y' },
{ label: '1-8-1991', value: 'n-j-Y' },
{ label: '01-08-1991', value: 'm-d-Y' },
{ label: '8-1-1991', value: 'j-n-Y' },
{ label: '08-01-1991', value: 'd-m-Y' }
]
export const timeFormatOptions = [
{ label: '3:33:33 AM', value: 'g:i:s A' },
{ label: '03:33:33 AM', value: 'h:i:s A' },
{ label: '3:33:33 (military)', value: 'G:i:s' },
{ label: '03:33:33 (military)', value: 'H:i:s' }
]
export const timezoneOptions = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Phoenix',
'UTC',
'Europe/London',
'Europe/Paris'
]
export const pageLimitOptions = ['10', '15', '20', '25', '50']
export const searchResources = [
{ value: 'all', label: 'All' },
{ value: 'posts', label: 'Posts' },
{ value: 'pages', label: 'Pages' }
]

View File

@ -1,10 +1,37 @@
/**
* Login and home views. The token never enters this process.
* Login, chrome, and in-app views. API lists are placeholders until the next step.
*/
const loginView = document.getElementById('view-login')
const homeView = document.getElementById('view-home')
const topStatus = document.getElementById('top-status')
import {
dateFormatOptions,
demoMessages,
demoNotifications,
demoProfile,
pageLimitOptions,
previewSession,
timeFormatOptions,
timezoneOptions
} from './demo.js'
const views = {
login: document.getElementById('view-login'),
home: document.getElementById('view-home'),
stub: document.getElementById('view-stub'),
search: document.getElementById('view-search'),
notifications: document.getElementById('view-notifications'),
messages: document.getElementById('view-messages'),
profile: document.getElementById('view-profile'),
settings: document.getElementById('view-settings')
}
const mainPages = {
game: 'Game',
friends: 'Friends',
cabal: 'Cabal',
hiscores: 'HiScores',
shop: 'Shop'
}
const loginForm = document.getElementById('login-form')
const loginError = document.getElementById('login-error')
const loginSubmit = document.getElementById('login-submit')
@ -15,17 +42,13 @@ 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')
const previewButton = document.getElementById('preview-shell')
let session = null
let profile = { ...demoProfile }
/**
* Show or hide a status/error line.
* Show or hide a status line.
*
* @param {HTMLElement} el - message node
* @param {string} [message] - text to show; empty hides the node
@ -38,61 +61,327 @@ function setMessage(el, message) {
}
/**
* Fill both site URL fields from the last used host.
* Site path that opens in the system browser.
*
* @param {string} siteUrl - canonical site base
* @return {void}
* @param {string} path - path after the site root
* @return {string} - absolute URL, or #
*/
function fillSiteFields(siteUrl) {
if (!siteUrl) {
return
function siteHref(path) {
if (!session?.siteUrl) {
return '#'
}
loginSite.value = siteUrl
tokenSite.value = siteUrl
return `${session.siteUrl.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`
}
/**
* Render the login or home view from a public session.
* Avatar URL for the connected site, or empty when none.
*
* @param {object} session - connection state from main
* @return {string} - image URL
*/
function avatarUrl() {
if (!session?.siteUrl) {
return ''
}
return `${session.siteUrl.replace(/\/+$/, '')}/images/defaultAvatar.png`
}
/**
* Logo on navy chrome.
*
* @return {string} - image URL
*/
function logoUrl() {
if (!session?.siteUrl) {
return ''
}
return `${session.siteUrl.replace(/\/+$/, '')}/images/logoWhite.png`
}
/**
* Fill a select from label/value pairs or plain strings.
*
* @param {HTMLSelectElement} select - target
* @param {Array<string|{label: string, value: string}>} options - choices
* @param {string} selected - current value
* @return {void}
*/
function render(session) {
function fillSelect(select, options, selected) {
select.innerHTML = ''
options.forEach((option) => {
const value = typeof option === 'string' ? option : option.value
const label = typeof option === 'string' ? option : option.label
const node = document.createElement('option')
node.value = value
node.textContent = label
if (value === selected) {
node.selected = true
}
select.appendChild(node)
})
}
/**
* Paint header identity, badges, and site links.
*
* @return {void}
*/
function renderChrome() {
const connected = Boolean(session?.connected)
loginView.hidden = connected
homeView.hidden = !connected
fillSiteFields(session?.lastSiteUrl || session?.siteUrl || '')
document.body.dataset.shell = connected ? 'app' : 'login'
document.getElementById('header-username').textContent = session?.username || 'Account'
if (!connected) {
topStatus.hidden = true
return
const logo = document.getElementById('header-logo')
const avatar = document.getElementById('header-avatar')
const avatarFallback = document.getElementById('header-avatar-fallback')
const nextLogo = logoUrl()
const nextAvatar = avatarUrl()
logo.onerror = () => {
logo.hidden = true
}
avatar.onerror = () => {
avatar.hidden = true
avatarFallback.hidden = false
}
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.'
)
logo.hidden = !nextLogo
if (nextLogo) {
logo.src = nextLogo
}
avatar.hidden = !nextAvatar
avatarFallback.hidden = Boolean(nextAvatar)
if (nextAvatar) {
avatar.src = nextAvatar
document.getElementById('profile-avatar').src = nextAvatar
document.getElementById('settings-avatar').src = nextAvatar
}
const email = siteHref('usercp/email')
const password = siteHref('usercp/password')
const phone = siteHref('usercp/phone')
document.getElementById('link-email').href = email
document.getElementById('link-password').href = password
document.getElementById('link-phone').href = phone
document.getElementById('settings-link-email').href = email
document.getElementById('settings-link-password').href = password
document.getElementById('settings-link-phone').href = phone
const unreadNotes = demoNotifications.filter((item) => item.unread).length
const unreadMail = demoMessages.filter((item) => item.unread).length
const noteBadge = document.getElementById('notification-badge')
const mailBadge = document.getElementById('message-badge')
noteBadge.hidden = unreadNotes === 0
mailBadge.hidden = unreadMail === 0
noteBadge.textContent = String(unreadNotes)
mailBadge.textContent = String(unreadMail)
fillDropdown(
document.getElementById('notification-menu'),
demoNotifications
.map(
(item) => `
<li data-demo-item>
<a href="#/notifications" class="dropdown-item dropdown-item-block" data-route="notifications">
<p class="small text-muted mb-1"><i class="fa fa-fw fa-clock me-1"></i>${item.createdAt}</p>
<span>${item.html}</span>
</a>
</li>`
)
.join('')
)
fillDropdown(
document.getElementById('message-menu'),
demoMessages
.map(
(item) => `
<li data-demo-item>
<a href="#/messages" class="dropdown-item dropdown-item-block" data-route="messages">
<strong>${item.otherUser}</strong>
<p class="small text-muted mb-1"><i class="fa fa-fw fa-clock me-1"></i>${item.lastMessageAt}</p>
<span>${item.preview}</span>
</a>
</li>`
)
.join('')
)
}
/**
* Run an auth IPC call and render the result or an error.
* Replace demo rows at the top of a header dropdown.
*
* @param {HTMLButtonElement} button - submit button to disable
* @param {HTMLElement} list - dropdown ul
* @param {string} html - li markup
* @return {void}
*/
function fillDropdown(list, html) {
list.querySelectorAll('[data-demo-item]').forEach((node) => node.remove())
list.insertAdjacentHTML('afterbegin', html)
}
/**
* Fill the in-app pages from demo (and later API) data.
*
* @return {void}
*/
function renderPages() {
if (!session?.connected) {
return
}
document.getElementById('home-username').textContent = session.username || 'this account'
const siteLink = document.getElementById('home-site')
siteLink.textContent = session.siteUrl
siteLink.href = session.siteUrl
document.getElementById('notification-list').innerHTML = demoNotifications
.map(
(item) => `
<tr class="${item.unread ? 'is-unread' : ''}">
<td>${item.html}</td>
<td class="text-muted text-nowrap">${item.createdAt}</td>
<td>
<button type="button" class="btn btn-sm btn-primary" disabled title="Mark as read">
<i class="fa-solid fa-fw fa-envelope-open"></i>
</button>
</td>
</tr>`
)
.join('')
document.getElementById('message-list').innerHTML = demoMessages
.map(
(item) => `
<tr class="${item.unread ? 'is-unread' : ''}">
<td>${item.otherUser}</td>
<td>${item.preview}</td>
<td class="text-muted">${item.lastMessageAt}</td>
</tr>`
)
.join('')
profile.username = session.username || profile.username
profile.usernamePretty = profile.username
document.getElementById('profile-name').textContent = profile.usernamePretty
document.getElementById('profile-registered').textContent = profile.registered
document.getElementById('profile-last-login').textContent = profile.lastLogin
document.getElementById('profile-gender').textContent = profile.gender
document.getElementById('settings-gender').value = profile.gender
document.getElementById('settings-newsletter').checked = profile.newsletter
document.getElementById('settings-dark').checked = profile.darkMode
fillSelect(document.getElementById('settings-timezone'), timezoneOptions, profile.timezone)
fillSelect(document.getElementById('settings-date'), dateFormatOptions, profile.dateFormat)
fillSelect(document.getElementById('settings-time'), timeFormatOptions, profile.timeFormat)
fillSelect(document.getElementById('settings-limit'), pageLimitOptions, profile.pageLimit)
applyTheme(profile.darkMode)
}
/**
* Toggle TTP light/dark tokens on the document.
*
* @param {boolean} dark - dark-mode preference
* @return {void}
*/
function applyTheme(dark) {
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light')
}
/**
* Show one named view.
*
* @param {string} name - views key
* @return {void}
*/
function showView(name) {
Object.entries(views).forEach(([key, node]) => {
node.hidden = key !== name
})
}
/**
* Mark the matching main-nav link, or none.
*
* @param {string} path - hash path without query
* @return {void}
*/
function setMainNav(path) {
document.querySelectorAll('.capsule-mainnav [data-nav]').forEach((link) => {
link.classList.toggle('active', link.dataset.nav === path)
})
}
/**
* Route from the hash. Login when there is no session.
*
* @return {void}
*/
function route() {
if (!session?.connected) {
showView('login')
return
}
const hash = window.location.hash.replace(/^#\/?/, '')
const [path] = hash.split('?')
setMainNav(path)
if (mainPages[path]) {
document.getElementById('stub-title').textContent = mainPages[path]
showView('stub')
return
}
if (path === 'notifications') {
showView('notifications')
return
}
if (path === 'messages') {
showView('messages')
return
}
if (path === 'profile') {
showView('profile')
return
}
if (path === 'settings') {
showView('settings')
return
}
if (path === 'search') {
showView('search')
return
}
showView('home')
}
/**
* Apply a public session and go to the current route.
*
* @param {object} next - connection state
* @return {void}
*/
function applySession(next) {
session = next
if (!session?.connected) {
window.location.hash = ''
applyTheme(false)
} else if (!window.location.hash) {
window.location.hash = '#/'
}
if (loginSite && session?.lastSiteUrl) {
loginSite.value = session.lastSiteUrl
tokenSite.value = session.lastSiteUrl
}
renderChrome()
renderPages()
route()
}
/**
* Run an auth IPC call.
*
* @param {HTMLButtonElement} button - submit button
* @param {HTMLElement} errorEl - error line
* @param {() => Promise<object>} work - IPC call
* @return {Promise<void>}
@ -101,7 +390,7 @@ async function runAuth(button, errorEl, work) {
button.disabled = true
setMessage(errorEl, '')
try {
render(await work())
applySession(await work())
} catch (err) {
setMessage(errorEl, err?.message || 'Sign-in failed.')
} finally {
@ -111,6 +400,10 @@ async function runAuth(button, errorEl, work) {
loginForm.addEventListener('submit', (event) => {
event.preventDefault()
if (!window.capsule) {
setMessage(loginError, 'Preload bridge is missing. Restart Capsule.')
return
}
runAuth(loginSubmit, loginError, () =>
window.capsule.login({
siteUrl: loginSite.value,
@ -122,6 +415,10 @@ loginForm.addEventListener('submit', (event) => {
tokenForm.addEventListener('submit', (event) => {
event.preventDefault()
if (!window.capsule) {
setMessage(tokenError, 'Preload bridge is missing. Restart Capsule.')
return
}
runAuth(tokenSubmit, tokenError, () =>
window.capsule.connectToken({
siteUrl: tokenSite.value,
@ -131,36 +428,85 @@ tokenForm.addEventListener('submit', (event) => {
)
})
logoutButton.addEventListener('click', async () => {
render(await window.capsule.logout())
previewButton.addEventListener('click', () => {
applySession({ ...previewSession })
})
openSite.addEventListener('click', (event) => {
const href = openSite.getAttribute('href')
if (!href || href === '#') {
event.preventDefault()
document.getElementById('logout-button').addEventListener('click', async () => {
if (session?.preview || !window.capsule) {
applySession({ connected: false, lastSiteUrl: session?.siteUrl || '' })
return
}
applySession(await window.capsule.logout())
})
document.getElementById('header-search').addEventListener('submit', (event) => {
event.preventDefault()
const q = document.getElementById('search-q').value.trim()
const resource = document.getElementById('search-resource').value
const params = new URLSearchParams({ q, resource })
document.getElementById('search-summary').textContent = q
? `Searching <20>${q}<EFBFBD> in ${resource}.`
: `Choose a term to search ${resource}.`
window.location.hash = `#/search?${params.toString()}`
route()
})
document.getElementById('settings-form').addEventListener('submit', (event) => {
event.preventDefault()
profile.gender = document.getElementById('settings-gender').value
profile.newsletter = document.getElementById('settings-newsletter').checked
profile.timezone = document.getElementById('settings-timezone').value
profile.dateFormat = document.getElementById('settings-date').value
profile.timeFormat = document.getElementById('settings-time').value
profile.pageLimit = document.getElementById('settings-limit').value
profile.darkMode = document.getElementById('settings-dark').checked
applyTheme(profile.darkMode)
document.getElementById('profile-gender').textContent = profile.gender
setMessage(
document.getElementById('settings-status'),
'Saved in Capsule for now. The site API will persist this next.'
)
})
document.getElementById('settings-avatar-file').addEventListener('change', (event) => {
const file = event.target.files?.[0]
if (!file) {
return
}
const url = URL.createObjectURL(file)
document.getElementById('settings-avatar').src = url
document.getElementById('profile-avatar').src = url
document.getElementById('header-avatar').src = url
document.getElementById('header-avatar').hidden = false
document.getElementById('header-avatar-fallback').hidden = true
})
document.getElementById('settings-dark').addEventListener('change', (event) => {
applyTheme(event.target.checked)
})
window.addEventListener('hashchange', route)
/**
* Load any stored session, then confirm it against the site when possible.
* Load a stored session, or stay on login.
*
* @return {Promise<void>}
*/
async function boot() {
if (!window.capsule) {
setMessage(loginError, 'Preload bridge is missing. Restart Capsule.')
loginView.hidden = false
applySession({ connected: false })
setMessage(loginError, '')
return
}
const session = await window.capsule.session()
if (session.connected) {
render(await window.capsule.verify())
const stored = await window.capsule.session()
if (stored.connected) {
applySession(await window.capsule.verify())
return
}
render(session)
applySession(stored)
}
boot()

View File

@ -1,314 +1,303 @@
/**
* Capsule desktop chrome. Tokens follow the TTP brand (logo #3fa9f5, chrome #0c1929).
* Capsule chrome. Tokens and header paint follow TTP main.css / main-dark.css.
*/
: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;
color-scheme: light;
--ttp-canvas: #e8eef5;
--ttp-surface: #ffffff;
--ttp-surface-alt: #f1f5fa;
--ttp-surface-muted: #dfe7f0;
--ttp-surface-inset: #f7f9fc;
--ttp-text: #122033;
--ttp-text-muted: #5c6e82;
--ttp-border: #d0dbe6;
--ttp-border-strong: #122033;
--ttp-link: #1577b8;
--ttp-link-hover: #0f5f94;
--ttp-input-bg: #ffffff;
--ttp-input-border: #c5d3e0;
--ttp-input-text: #122033;
--ttp-focus: #3fa9f5;
--ttp-focus-ring: rgba(63, 169, 245, 0.35);
--ttp-brand: #3fa9f5;
--ttp-primary: #1784c9;
--ttp-primary-hover: #146ea8;
--ttp-primary-active: #115a8a;
--ttp-primary-rgb: 23, 132, 201;
--ttp-chrome: #0c1929;
--ttp-chrome-rgb: 12, 25, 41;
--ttp-radius: 0.85rem;
--ttp-radius-sm: 0.5rem;
--ttp-shadow: 0 1px 2px rgba(18, 32, 51, 0.05), 0 10px 28px rgba(18, 32, 51, 0.07);
--ttp-shadow-lg: 0 16px 40px rgba(18, 32, 51, 0.14);
--ttp-font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bs-body-color: var(--ttp-text);
--bs-body-bg: var(--ttp-canvas);
--bs-primary: var(--ttp-primary);
--bs-primary-rgb: var(--ttp-primary-rgb);
--bs-dark: var(--ttp-chrome);
--bs-dark-rgb: var(--ttp-chrome-rgb);
--bs-link-color: var(--ttp-link);
--bs-link-hover-color: var(--ttp-link-hover);
--bs-border-color: var(--ttp-border);
--bs-border-radius: var(--ttp-radius);
--bs-body-font-family: var(--ttp-font);
}
* {
box-sizing: border-box;
html[data-bs-theme='dark'] {
color-scheme: dark;
--ttp-canvas: #0b1220;
--ttp-surface: #151d2c;
--ttp-surface-alt: #1c2638;
--ttp-surface-muted: #243044;
--ttp-surface-inset: #0f1623;
--ttp-text: #e9eef6;
--ttp-text-muted: #9aaabb;
--ttp-border: #2c3a50;
--ttp-border-strong: #e9eef6;
--ttp-link: #6eb6ff;
--ttp-link-hover: #9ccfff;
--ttp-input-bg: #0f1623;
--ttp-input-border: #3a4b64;
--ttp-input-text: #e9eef6;
--ttp-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 32px rgba(0, 0, 0, 0.28);
--ttp-shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.45);
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--canvas);
color: var(--text);
font-family: var(--font);
margin: 0;
background: var(--ttp-canvas);
color: var(--ttp-text);
font-family: var(--ttp-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);
.context-main-bg {
background: var(--ttp-surface);
color: var(--ttp-text);
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
header.text-bg-dark {
background-color: var(--ttp-chrome) !important;
box-shadow: 0 1px 0 rgba(63, 169, 245, 0.42), 0 10px 28px rgba(12, 25, 41, 0.18);
}
.topbar {
display: flex;
.capsule-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
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;
.capsule-brand {
gap: 0.65rem;
min-width: 8.5rem;
}
.capsule-logo {
border-radius: 0.35rem;
}
.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);
background: linear-gradient(135deg, #7ec8f8, var(--ttp-brand) 55%, #1784c9);
box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12);
}
.brand-name,
.brand-tag,
.eyebrow,
h1,
h2,
p,
dt,
dd,
label span,
button,
summary,
li {
margin: 0;
.capsule-logo:not([hidden]) + .brand-mark {
display: none;
}
.brand-name {
font-size: 1rem;
font-weight: 650;
letter-spacing: 0.02em;
}
.brand-tag,
.eyebrow,
.hint,
.lede,
.meta dt,
.top-status {
color: var(--muted);
.capsule-search {
width: 100%;
justify-self: stretch;
}
.brand-tag,
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
.capsule-search fieldset {
border: 0;
margin: 0;
padding: 0;
}
.top-status {
font-size: 0.85rem;
.capsule-search .input-group {
flex-wrap: nowrap;
border-radius: var(--ttp-radius-sm);
overflow: hidden;
}
.top-status.is-ok {
color: var(--ok);
.capsule-search .input-group > :first-child {
border-top-left-radius: var(--ttp-radius-sm);
border-bottom-left-radius: var(--ttp-radius-sm);
}
.stage {
flex: 1;
.capsule-search .input-group > :last-child {
border-top-right-radius: var(--ttp-radius-sm);
border-bottom-right-radius: var(--ttp-radius-sm);
}
.capsule-account {
display: flex;
align-items: center;
justify-self: end;
}
body[data-shell='login'] .capsule-search,
body[data-shell='login'] .capsule-account,
body[data-shell='login'] .capsule-mainnav {
display: none;
}
.capsule-mainnav {
display: flex;
justify-content: center;
padding: 1.5rem;
padding: 0 1rem 0.85rem;
}
.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;
.capsule-mainnav-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
border: 1px solid transparent;
border-radius: 0.55rem;
padding: 0.65rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
gap: 0.15rem;
list-style: none;
margin: 0;
padding: 0;
}
.capsule-mainnav a {
display: block;
border-radius: 999px;
padding: 0.4rem 0.9rem;
color: rgba(255, 255, 255, 0.88);
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: wait;
}
.btn-primary {
background: var(--primary);
.capsule-mainnav a:hover,
.capsule-mainnav a:focus,
.capsule-mainnav a.active {
color: #fff;
background-color: rgba(63, 169, 245, 0.18);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
.capsule-mainnav a.active {
font-weight: 650;
}
.btn-secondary {
background: var(--surface-alt);
color: var(--text);
border-color: var(--border);
body[data-shell='login'] .capsule-header {
grid-template-columns: auto;
justify-content: start;
}
.btn-ghost {
background: transparent;
color: var(--brand);
border-color: var(--border);
header .dropdown {
position: relative;
flex-shrink: 0;
}
.error {
color: var(--danger);
font-size: 0.9rem;
header .dropdown-toggle {
outline: 0;
}
.token-panel {
margin-top: 1.2rem;
border-top: 1px solid var(--border);
padding-top: 0.9rem;
header .dropdown-menu.dropdown-menu-dark {
--bs-dropdown-min-width: 16rem;
--bs-dropdown-bg: #0c1929;
--bs-dropdown-color: #fff;
--bs-dropdown-border-color: rgba(255, 255, 255, 0.18);
--bs-dropdown-link-color: rgba(255, 255, 255, 0.9);
--bs-dropdown-link-hover-color: #fff;
--bs-dropdown-link-hover-bg: rgba(63, 169, 245, 0.18);
--bs-dropdown-link-active-bg: var(--ttp-primary);
--bs-dropdown-divider-bg: rgba(255, 255, 255, 0.12);
background-color: #0c1929 !important;
z-index: 1050;
}
.token-panel summary {
cursor: pointer;
color: var(--brand);
header .form-control,
header .form-select {
background-color: var(--ttp-input-bg);
border-color: var(--ttp-input-border);
color: var(--ttp-input-text);
}
header .form-control:focus,
header .form-select:focus {
border-color: var(--ttp-focus);
box-shadow: 0 0 0 0.2rem var(--ttp-focus-ring);
}
.badge.rounded-pill {
margin-left: 0.15rem;
vertical-align: super;
font-size: 0.65rem;
}
.card {
--bs-card-bg: var(--ttp-surface);
--bs-card-color: var(--ttp-text);
--bs-card-border-color: var(--ttp-border);
--bs-card-border-radius: var(--ttp-radius);
box-shadow: var(--ttp-shadow);
}
.card-header.bg-dark {
background-color: var(--ttp-chrome) !important;
border-bottom: 0;
}
.table {
--bs-table-bg: var(--ttp-surface);
--bs-table-color: var(--ttp-text);
--bs-table-border-color: var(--ttp-border);
--bs-table-striped-bg: var(--ttp-surface-alt);
}
.form-control,
.form-select {
background-color: var(--ttp-input-bg);
color: var(--ttp-input-text);
border-color: var(--ttp-input-border);
border-radius: var(--ttp-radius-sm);
}
.capsule-login-card {
max-width: 28rem;
box-shadow: var(--ttp-shadow-lg);
}
.dropdown-item-block {
display: block;
white-space: normal;
}
.is-unread {
font-weight: 600;
}
.token-panel .form {
margin-top: 0.9rem;
}
@media (max-width: 900px) {
.capsule-header {
grid-template-columns: auto auto;
grid-template-areas:
'brand account'
'search search';
}
.hint {
font-size: 0.88rem;
line-height: 1.5;
}
.capsule-brand {
grid-area: brand;
}
.hint code {
font-size: 0.84em;
}
.capsule-account {
grid-area: account;
}
.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;
.capsule-search {
grid-area: search;
width: 100%;
}
}