From 17d83f72090d010d840b3e87229f15c6d8e743ea Mon Sep 17 00:00:00 2001 From: Joey Kimsey Date: Sat, 12 Sep 2026 00:57:48 -0400 Subject: [PATCH] add main appearance --- CHANGELOG.md | 1 + README.md | 8 +- src/main/index.js | 8 +- src/renderer/index.html | 536 +++++++++++++++++++++++++++++------- src/renderer/src/demo.js | 96 +++++++ src/renderer/src/main.js | 474 ++++++++++++++++++++++++++----- src/renderer/src/styles.css | 469 +++++++++++++++---------------- 7 files changed, 1185 insertions(+), 407 deletions(-) create mode 100644 src/renderer/src/demo.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b80777e..971bb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index f9c1fbc..7254ba3 100644 --- a/README.md +++ b/README.md @@ -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 — Capsule is Node, not PHP): +From this folder (Windows source checkout is fine ¯ 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’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¯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 — those open `{site}/usercp/…`. Lists are placeholders until the API step. ## Remote diff --git a/src/main/index.js b/src/main/index.js index 57d55ff..ea81985 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -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', diff --git a/src/renderer/index.html b/src/renderer/index.html index d2eaff4..11eda84 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1,149 +1,493 @@ - + Capsule + + - -
-
-
- -
-

Capsule

-

The Tempus Project

+ +
+
+ + + + Capsule + + + + +
-
-
+ +
+ +
+ +
+ - - - + + + + + + + diff --git a/src/renderer/src/demo.js b/src/renderer/src/demo.js new file mode 100644 index 0000000..bacb71e --- /dev/null +++ b/src/renderer/src/demo.js @@ -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 — 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' } +] diff --git a/src/renderer/src/main.js b/src/renderer/src/main.js index 3b59b9f..a41cc3d 100644 --- a/src/renderer/src/main.js +++ b/src/renderer/src/main.js @@ -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} 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 ? '—' : 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) => ` +
  • + +

    ${item.createdAt}

    + ${item.html} +
    +
  • ` + ) + .join('') + ) + + fillDropdown( + document.getElementById('message-menu'), + demoMessages + .map( + (item) => ` +
  • + + ${item.otherUser} +

    ${item.lastMessageAt}

    + ${item.preview} +
    +
  • ` + ) + .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) => ` + + ${item.html} + ${item.createdAt} + + + + ` + ) + .join('') + + document.getElementById('message-list').innerHTML = demoMessages + .map( + (item) => ` + + ${item.otherUser} + ${item.preview} + ${item.lastMessageAt} + ` + ) + .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} work - IPC call * @return {Promise} @@ -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 ¯${q}¯ 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} */ 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() diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 32139dc..b68d3e9 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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%; } }