bug reports and contacts

This commit is contained in:
Joey Kimsey
2026-09-12 17:11:35 -04:00
parent 0f5a33d6db
commit 7634219706
8 changed files with 424 additions and 85 deletions

View File

@ -13,8 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. POSTs send session CSRF (`X-CSRF-Token`); HTTP uses Electron `net.fetch` so the PHP session cookie persists.
- 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 and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data.
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box.
- After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) and post through `POST /api/contact` and `POST /api/bugreport`. Those footer items appear only when profile `features` (or `GET /api/contact` / `GET /api/bugreport`) say the plugin is available, accepting submissions, and allowed for this user. A hash to a disabled page shows the same “not accepting … right now” notice as the site. Disabled plugins show an unavailable note instead of demo data.
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, and Terms of Service. Contact and Report a Bug join that band only when the site is accepting them. There is no subscribe box.
### Changed
- Footer upper band is Contact Us on the left, dark mode in the middle, and More Info on the right, in three equal columns. Contact and Report a Bug stay off the menu until the connected site says those plugins are accepting submissions.
- Profile page: space the white panel below the main nav, and cap the avatar at 200×200.
### Fixed

View File

@ -29,7 +29,7 @@ All HTTP runs in the **main process** (`net.fetch`, so the PHP session cookie st
| Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
| Profile save | `POST /api/profile/update` | Avatar and prefs. |
| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. |
| Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Footer pages when those plugins are enabled. |
| Contact / bugs | `GET` / `POST /api/contact`, `GET` / `POST /api/bugreport` | Footer pages when those plugins are enabled, accepting, and allowed. Hashing in while they are off shows a not-accepting notice. |
| Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. |
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available. MFA `loginCode` is not stored.
@ -44,7 +44,7 @@ App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
| `src/preload/` | `window.capsule` bridge |
| `src/renderer/` | Login, MFA, TTP-styled chrome, and live API views |
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 load from the site API after sign-in. The footer matches TTP copyright and social icons. Above that: dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. No subscribe box. Privacy and terms open the connected site; contact and bug reports stay in-app.
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 load from the site API after sign-in. The footer matches TTP copyright and social icons. Above that: Contact Us on the left (Contact and Report a Bug only when the site is accepting them), dark-mode in the middle, More Info (Privacy Policy, Terms of Service) on the right. No subscribe box. Privacy and terms open the connected site; contact and bug reports stay in-app.
## Remote

View File

@ -8,6 +8,8 @@ import {
createMessage,
deleteMessage,
deleteNotification,
getBugreportStatus,
getContactStatus,
getProfile,
isDeadTokenError,
listMessages,
@ -221,22 +223,30 @@ export function registerApiIpc() {
)
})
ipcMain.handle('capsule:contactStatus', async () => {
return runUserApi((session) => getContactStatus(session.siteUrl, session.token))
})
ipcMain.handle('capsule:contact', async (_event, payload) => {
return runUserApi((session) =>
sendContact(session.siteUrl, session.token, {
name: payload?.name || '',
entry: payload?.entry || '',
email: payload?.email || ''
contactEmail: payload?.contactEmail || payload?.email || ''
})
)
})
ipcMain.handle('capsule:bugreportStatus', async () => {
return runUserApi((session) => getBugreportStatus(session.siteUrl, session.token))
})
ipcMain.handle('capsule:bugreport', async (_event, payload) => {
return runUserApi((session) =>
sendBugreport(session.siteUrl, session.token, {
url: payload?.url || '',
ourl: payload?.ourl || '',
repeat: payload?.repeat ? 'true' : 'false',
repeat: payload?.repeat,
entry: payload?.entry || ''
})
)

View File

@ -263,6 +263,14 @@ export function apiErrorMessage(code, errors) {
return 'This action needs a personal (user) token, not an app token.'
case 'not found':
return 'This site is missing that Capsule API action. Pull the latest TTP on the server.'
case 'Contact submissions are disabled.':
return 'The contact form is not accepting new messages right now.'
case 'Bug report submissions are disabled.':
return 'New bug reports are not being accepted right now.'
case 'Contact is not available':
return 'The contact form is not available.'
case 'Bug reports is not available':
return 'Bug reports are not available.'
default:
return code || 'The site returned an error.'
}
@ -779,24 +787,54 @@ export async function searchSite(siteUrl, token, query) {
})
}
/**
* Contact plugin status. GET api/contact.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @return {Promise<object>}
*/
export async function getContactStatus(siteUrl, token) {
return ttpRequest({ siteUrl, path: '/api/contact', method: 'GET', token })
}
/**
* Contact form. POST api/contact.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {object} fields - name, entry, optional email
* @param {object} fields - name, entry, optional contactEmail or email
* @return {Promise<object>}
*/
export async function sendContact(siteUrl, token, fields) {
const form = {
name: fields?.name || '',
entry: fields?.entry || ''
}
const email = fields?.contactEmail || fields?.email || ''
if (email) {
form.contactEmail = email
}
return ttpRequest({
siteUrl,
path: '/api/contact',
method: 'POST',
token,
form: fields
form
})
}
/**
* Bug report plugin status. GET api/bugreport.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @return {Promise<object>}
*/
export async function getBugreportStatus(siteUrl, token) {
return ttpRequest({ siteUrl, path: '/api/bugreport', method: 'GET', token })
}
/**
* Bug report. POST api/bugreport.
*
@ -806,11 +844,18 @@ export async function sendContact(siteUrl, token, fields) {
* @return {Promise<object>}
*/
export async function sendBugreport(siteUrl, token, fields) {
const url = fields?.url || ''
const ourl = fields?.ourl || url
return ttpRequest({
siteUrl,
path: '/api/bugreport',
method: 'POST',
token,
form: fields
form: {
url,
ourl,
repeat: fields?.repeat === true || fields?.repeat === 'true' ? 'true' : 'false',
entry: fields?.entry || ''
}
})
}

View File

@ -234,16 +234,34 @@ const capsule = {
return ipcRenderer.invoke('capsule:search', payload)
},
/**
* Contact plugin status. GET api/contact.
*
* @return {Promise<object>}
*/
contactStatus() {
return ipcRenderer.invoke('capsule:contactStatus')
},
/**
* Contact form submit.
*
* @param {object} payload - name, entry, email
* @param {object} payload - name, entry, contactEmail or email
* @return {Promise<object>}
*/
contact(payload) {
return ipcRenderer.invoke('capsule:contact', payload)
},
/**
* Bug report plugin status. GET api/bugreport.
*
* @return {Promise<object>}
*/
bugreportStatus() {
return ipcRenderer.invoke('capsule:bugreportStatus')
},
/**
* Bug report submit.
*

View File

@ -358,8 +358,8 @@
<div class="m-2 m-lg-4">
<div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4">
<h1 class="h2 text-center mb-4">Contact Us</h1>
<p id="contact-unavailable" class="text-muted text-center" hidden></p>
<div id="contact-body">
<p id="contact-unavailable" class="alert alert-warning text-center" hidden></p>
<div id="contact-body" hidden>
<div class="col-12 col-lg-6 offset-lg-3">
<p class="text-center text-lg-start">
Here at <strong id="contact-sitename">this site</strong>, we highly value your
@ -381,6 +381,7 @@
name="name"
type="text"
required
maxlength="20"
autocomplete="name"
/>
</div>
@ -393,7 +394,7 @@
<input
id="contact-email"
class="form-control"
name="email"
name="contactEmail"
type="email"
autocomplete="email"
/>
@ -428,8 +429,8 @@
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h2 text-center mb-4">Report a Bug</h1>
<hr />
<p id="bug-unavailable" class="text-muted text-center" hidden></p>
<div id="bug-body">
<p id="bug-unavailable" class="alert alert-warning text-center" hidden></p>
<div id="bug-body" hidden>
<p class="text-center text-sm-start">
Thank you for visiting our bug reporting page. We value our users' input highly and
in an effort to better serve your needs, please fill out the form below to help us
@ -599,7 +600,8 @@
</section>
<section id="view-profile" class="view" hidden>
<div class="container p-4 context-main-bg mb-4 text-center">
<div class="m-2 m-lg-4">
<div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4 text-center">
<h1 class="h3 mb-4">Profile</h1>
<hr />
<div class="row justify-content-center">
@ -615,8 +617,9 @@
id="profile-avatar"
src=""
alt="User Pic"
class="rounded-circle img-fluid"
style="max-width: 150px"
class="rounded-circle capsule-profile-avatar"
width="200"
height="200"
/>
</div>
<div class="col-md-8">
@ -646,6 +649,7 @@
</div>
</div>
</div>
</div>
</section>
<section id="view-settings" class="view" hidden>
@ -746,19 +750,26 @@
</div>
<div class="collapse d-md-block my-4" id="footerMenu">
<div class="row">
<div id="footer-contact-col" class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center">
<div class="capsule-footer-cols">
<div id="footer-contact-col" class="capsule-footer-contact" hidden>
<h2 class="h5">Contact Us</h2>
<ul class="nav flex-column">
<li id="footer-link-contact" class="nav-item mb-2">
<li id="footer-link-contact" class="nav-item mb-2" hidden>
<a href="#/contact" class="nav-link p-0 text-muted">Contact</a>
</li>
<li id="footer-link-bug" class="nav-item mb-2">
<li id="footer-link-bug" class="nav-item mb-2" hidden>
<a href="#/bugreport" class="nav-link p-0 text-muted">Report a Bug</a>
</li>
</ul>
</div>
<div class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center">
<div class="capsule-footer-theme">
<h2 class="h5">Dark Mode</h2>
<div class="material-switch px-4 mt-2">
<input name="dark-mode-toggle" type="checkbox" id="dark-mode-toggle" class="form-check-input" />
<label for="dark-mode-toggle" class="label-default"><span class="visually-hidden">Dark mode</span></label>
</div>
</div>
<div class="capsule-footer-info">
<h2 class="h5">More Info</h2>
<ul class="nav flex-column">
<li class="nav-item mb-2">
@ -783,13 +794,6 @@
</li>
</ul>
</div>
<div class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center">
<h2 class="h5">Dark Mode</h2>
<div class="material-switch px-4 mt-2">
<input name="dark-mode-toggle" type="checkbox" id="dark-mode-toggle" class="form-check-input" />
<label for="dark-mode-toggle" class="label-default"><span class="visually-hidden">Dark mode</span></label>
</div>
</div>
</div>
</div>

View File

@ -50,6 +50,10 @@ const previewButton = document.getElementById('preview-shell')
let session = null
let profile = { ...demoProfile }
let plugins = []
let features = {
contact: { available: false, accepting: false, canSubmit: false },
bugreport: { available: false, accepting: false, canSubmit: false }
}
let notifications = { items: [], unread: 0 }
let messages = { items: [], unread: 0 }
let recentMessages = null
@ -122,6 +126,130 @@ function hasPlugin(name) {
return plugins.includes(name)
}
/**
* Normalize Capsule feature flags from the API.
*
* @param {object} [raw] - available, accepting, canSubmit
* @return {{available: boolean, accepting: boolean, canSubmit: boolean}}
*/
function normalizeFeature(raw) {
return {
available: Boolean(raw?.available),
accepting: Boolean(raw?.accepting),
canSubmit: Boolean(raw?.canSubmit)
}
}
/**
* Status for contact or bugreport from profile, GET, or the plugin list.
*
* @param {string} name - contact or bugreport
* @return {{available: boolean, accepting: boolean, canSubmit: boolean}}
*/
function featureState(name) {
return features[name] || { available: false, accepting: false, canSubmit: false }
}
/**
* True when that footer item should appear (plugin on, accepting, permitted).
*
* @param {string} name - contact or bugreport
* @return {boolean}
*/
function featureInMenu(name) {
if (session?.preview) {
return true
}
const state = featureState(name)
return state.available && state.accepting && state.canSubmit
}
/**
* Copy for a contact or bugreport page that is not taking submissions.
*
* @param {string} name - contact or bugreport
* @return {string}
*/
function featureGateMessage(name) {
const state = featureState(name)
if (name === 'contact') {
if (!state.available) {
return 'The contact form is not available.'
}
if (!state.accepting) {
return 'The contact form is not accepting new messages right now.'
}
if (!state.canSubmit) {
return 'You do not have permission to use the contact form.'
}
return ''
}
if (!state.available) {
return 'Bug reports are not available.'
}
if (!state.accepting) {
return 'New bug reports are not being accepted right now.'
}
if (!state.canSubmit) {
return 'You do not have permission to report bugs.'
}
return ''
}
/**
* Store contact/bugreport flags from profile JSON or a GET status body.
*
* @param {object} [body] - GET/POST api/profile or GET api/contact|bugreport
* @return {void}
*/
function applyFeatures(body) {
if (body?.features && typeof body.features === 'object') {
features = {
contact: normalizeFeature(body.features.contact),
bugreport: normalizeFeature(body.features.bugreport)
}
return
}
features = {
contact: featureFromPlugin('contact'),
bugreport: featureFromPlugin('bugreport')
}
}
/**
* Merge one GET status payload into local feature flags.
*
* @param {string} name - contact or bugreport
* @param {object} [body] - GET api/contact or GET api/bugreport
* @return {void}
*/
function applyFeatureStatus(name, body) {
if (body && typeof body === 'object' && !body.error) {
if ('available' in body || 'accepting' in body || 'canSubmit' in body) {
features = {
...features,
[name]: normalizeFeature(body)
}
return
}
}
features = {
...features,
[name]: featureFromPlugin(name)
}
}
/**
* Feature flags from the enabled-plugin list when the site has no features blob.
*
* @param {string} name - plugin folder
* @return {{available: boolean, accepting: boolean, canSubmit: boolean}}
*/
function featureFromPlugin(name) {
const on = hasPlugin(name)
return { available: on, accepting: on, canSubmit: on }
}
/**
* Site path that opens in the system browser.
*
@ -267,6 +395,7 @@ function timezoneList() {
function applyProfile(body) {
const user = body?.user || {}
plugins = Array.isArray(body?.plugins) ? body.plugins : []
applyFeatures(body)
profile = {
id: user.id,
username: user.username || session?.username || '',
@ -462,11 +591,11 @@ function renderFooter() {
}
})
const showContact = Boolean(session?.connected) && (session.preview || hasPlugin('contact'))
const showBug = Boolean(session?.connected) && (session.preview || hasPlugin('bugreport'))
document.getElementById('footer-link-contact').hidden = !showContact
document.getElementById('footer-link-bug').hidden = !showBug
document.getElementById('footer-contact-col').hidden = !showContact && !showBug
const contactOn = featureInMenu('contact')
const bugOn = featureInMenu('bugreport')
document.getElementById('footer-link-contact').hidden = !contactOn
document.getElementById('footer-link-bug').hidden = !bugOn
document.getElementById('footer-contact-col').hidden = !contactOn && !bugOn
}
/**
@ -564,19 +693,18 @@ function renderPages() {
? `Enabled plugins: ${plugins.join(', ')}`
: 'Plugin list will show after profile loads.'
const contactOk = session.preview || hasPlugin('contact')
const bugOk = session.preview || hasPlugin('bugreport')
const contactOk = featureInMenu('contact')
const bugOk = featureInMenu('bugreport')
document.getElementById('contact-body').hidden = !contactOk
document.getElementById('bug-body').hidden = !bugOk
const contactUnavailable = document.getElementById('contact-unavailable')
const bugUnavailable = document.getElementById('bug-unavailable')
contactUnavailable.hidden = contactOk
bugUnavailable.hidden = bugOk
contactUnavailable.textContent = contactOk ? '' : 'Contact is not available on this site.'
bugUnavailable.textContent = bugOk ? '' : 'Bug reports are not available on this site.'
if (contactOk && !document.getElementById('contact-name').value) {
document.getElementById('contact-name').value = profile.username || session.username || ''
document.getElementById('contact-email').value = profile.email || ''
contactUnavailable.textContent = contactOk ? '' : featureGateMessage('contact')
bugUnavailable.textContent = bugOk ? '' : featureGateMessage('bugreport')
if (contactOk && !document.getElementById('contact-email').value && profile.email) {
document.getElementById('contact-email').value = profile.email
}
if (bugOk && session.siteUrl && !document.getElementById('bug-url').value) {
document.getElementById('bug-url').value = session.siteUrl
@ -888,10 +1016,16 @@ function route() {
}
if (path === 'contact') {
showView('contact')
if (!session.preview) {
loadFeatureStatus('contact')
}
return
}
if (path === 'bugreport') {
showView('bugreport')
if (!session.preview) {
loadFeatureStatus('bugreport')
}
return
}
if (path === 'search') {
@ -928,6 +1062,10 @@ function applySession(next) {
window.location.hash = ''
applyTheme(false)
plugins = []
features = {
contact: { available: false, accepting: false, canSubmit: false },
bugreport: { available: false, accepting: false, canSubmit: false }
}
notifications = { items: [], unread: 0 }
messages = { items: [], unread: 0 }
recentMessages = null
@ -951,7 +1089,11 @@ function applySession(next) {
recentMessages = { items: demoMessages, unread: messages.unread }
canSendMessages = true
profile = { ...demoProfile }
plugins = []
plugins = ['contact', 'bugreport']
features = {
contact: { available: true, accepting: true, canSubmit: true },
bugreport: { available: true, accepting: true, canSubmit: true }
}
} else if (session?.connected && window.capsule) {
loadWorkspace()
}
@ -992,6 +1134,33 @@ async function loadWorkspace() {
}
}
/**
* Recheck whether contact or bug reports are accepting submissions.
*
* @param {string} name - contact or bugreport
* @return {Promise<void>}
*/
async function loadFeatureStatus(name) {
if (!window.capsule || session?.preview) {
return
}
try {
const data = await runApi(() =>
name === 'contact' ? window.capsule.contactStatus() : window.capsule.bugreportStatus()
)
if (!data) {
return
}
applyFeatureStatus(name, data)
renderChrome()
renderPages()
} catch {
applyFeatureStatus(name, null)
renderChrome()
renderPages()
}
}
/**
* Load one conversation.
*
@ -1362,6 +1531,11 @@ document.getElementById('contact-form').addEventListener('submit', async (event)
const button = event.target.querySelector('button[type="submit"]')
button.disabled = true
setMessage(status, '')
if (!featureInMenu('contact')) {
setMessage(status, featureGateMessage('contact'), 'text-danger')
button.disabled = false
return
}
if (session?.preview || !window.capsule) {
setMessage(status, 'Saved in preview only.', 'text-muted')
button.disabled = false
@ -1371,14 +1545,18 @@ document.getElementById('contact-form').addEventListener('submit', async (event)
await runApi(() =>
window.capsule.contact({
name: document.getElementById('contact-name').value.trim(),
email: document.getElementById('contact-email').value.trim(),
contactEmail: document.getElementById('contact-email').value.trim(),
entry: document.getElementById('contact-entry').value
})
)
document.getElementById('contact-entry').value = ''
setMessage(status, 'Sent.', 'text-success')
} catch (err) {
setMessage(status, err?.message || 'Could not send that message.', 'text-danger')
const msg = err?.message || 'Could not send that message.'
if (/disabled|not available|permission/i.test(msg)) {
loadFeatureStatus('contact')
}
setMessage(status, msg, 'text-danger')
} finally {
button.disabled = false
}
@ -1390,16 +1568,23 @@ document.getElementById('bugreport-form').addEventListener('submit', async (even
const button = event.target.querySelector('button[type="submit"]')
button.disabled = true
setMessage(status, '')
if (!featureInMenu('bugreport')) {
setMessage(status, featureGateMessage('bugreport'), 'text-danger')
button.disabled = false
return
}
if (session?.preview || !window.capsule) {
setMessage(status, 'Saved in preview only.', 'text-muted')
button.disabled = false
return
}
try {
const url = document.getElementById('bug-url').value.trim()
const ourl = document.getElementById('bug-ourl').value.trim() || url
await runApi(() =>
window.capsule.bugreport({
url: document.getElementById('bug-url').value.trim(),
ourl: document.getElementById('bug-ourl').value.trim(),
url,
ourl,
repeat: document.getElementById('bug-repeat-yes').checked,
entry: document.getElementById('bug-entry').value
})
@ -1407,7 +1592,11 @@ document.getElementById('bugreport-form').addEventListener('submit', async (even
document.getElementById('bug-entry').value = ''
setMessage(status, 'Sent.', 'text-success')
} catch (err) {
setMessage(status, err?.message || 'Could not send that report.', 'text-danger')
const msg = err?.message || 'Could not send that report.'
if (/disabled|not available|permission/i.test(msg)) {
loadFeatureStatus('bugreport')
}
setMessage(status, msg, 'text-danger')
} finally {
button.disabled = false
}

View File

@ -107,6 +107,63 @@ footer a.context-main {
text-decoration: none;
}
/**
* Footer upper band: Contact Us, dark mode, More Info.
*/
.capsule-footer-cols {
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
'contact'
'theme'
'info';
gap: 1.5rem;
text-align: center;
}
.capsule-footer-contact {
grid-area: contact;
}
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-areas:
'theme'
'info';
}
.capsule-footer-theme {
grid-area: theme;
}
.capsule-footer-info {
grid-area: info;
}
@media (min-width: 768px) {
.capsule-footer-cols {
grid-template-columns: repeat(3, 1fr);
grid-template-areas: 'contact theme info';
align-items: start;
}
.capsule-footer-contact {
text-align: start;
}
.capsule-footer-theme {
text-align: center;
}
.capsule-footer-info {
text-align: end;
}
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'theme info';
}
}
/**
* Dark-mode footer switch.
*/
@ -361,6 +418,17 @@ header .form-select:focus {
flex-shrink: 0;
}
/**
* Profile page avatar.
*/
.capsule-profile-avatar {
width: 200px;
height: 200px;
max-width: 200px;
max-height: 200px;
object-fit: cover;
}
.is-unread {
font-weight: 600;
}