Files
numolus/app/frontend/components/AppToolbar.vue
T
wkhandClaude Opus 5.5 7fce972fdc Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:41:02 +02:00

974 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue';
import DocumentsModal from './DocumentsModal.vue';
import LoginModal from './LoginModal.vue';
import ShareModal from './ShareModal.vue';
import UploadModal from './UploadModal.vue';
import { useLibrary } from '../store/library';
import { useSession } from '../store/session';
import { parseDocument } from '../io/json';
import TheLogo from './TheLogo.vue';
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand';
import {
exportJson,
exportNum,
readNumFile,
exportActiveTableCsv,
exportXlsx,
importJsonReplace,
importJsonAppend,
importCsv,
importXlsxFile,
readAsText,
readAsArrayBuffer,
extensionOf,
type CsvImportConfig,
} from '../io/operations';
import type { Delimiter } from '../io/csv';
const store = useDocument();
const library = useLibrary();
const session = useSession();
const { mode: themeMode, cycle: cycleTheme } = useTheme();
const toasts = useToasts();
const fileMenuOpen = ref(false);
// ---- wordmark only when there's room ----------------------------------
// "numolus" + "wicom" next to the logo are shown only while the whole
// toolbar still fits on one line with them; otherwise just the logo. Measured
// rather than a fixed breakpoint, since what fits depends on the toolbar's
// actual contents. The wordmark's own width is remembered from the last time
// it was visible, so hiding it can't make it look like it would fit again
// (no flip-flopping at the threshold).
const toolbarEl = ref<HTMLElement | null>(null);
const brandExtra = ref<HTMLElement | null>(null);
const showWordmark = ref(true);
let wordmarkWidth = 0;
function fitWordmark() {
const bar = toolbarEl.value;
if (!bar) return;
const cs = getComputedStyle(bar);
const avail = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const gap = parseFloat(cs.columnGap) || 0;
let need = 0;
let items = 0;
for (const child of Array.from(bar.children) as HTMLElement[]) {
if (getComputedStyle(child).display === 'none') continue;
items++;
if (!child.classList.contains('spacer')) need += child.getBoundingClientRect().width;
}
need += gap * Math.max(0, items - 1);
if (showWordmark.value && brandExtra.value) {
// plus the gap to the logo, which goes away with it
const brandGap = parseFloat(getComputedStyle(brandExtra.value.parentElement!).columnGap) || 0;
wordmarkWidth = brandExtra.value.getBoundingClientRect().width + brandGap;
} else {
need += wordmarkWidth;
}
showWordmark.value = need <= avail + 0.5;
}
let resizeObs: ResizeObserver | null = null;
onMounted(() => {
resizeObs = new ResizeObserver(() => fitWordmark());
if (toolbarEl.value) resizeObs.observe(toolbarEl.value);
fitWordmark();
// web fonts arriving late change the measured widths
void document.fonts?.ready.then(fitWordmark);
});
onBeforeUnmount(() => resizeObs?.disconnect());
const fileMenuWrap = ref<HTMLElement | null>(null);
// Close the Datei menu on Escape or on a press anywhere outside it. Capture
// phase, so canvas/grid handlers that stop propagation can't swallow it.
function onOutsidePointer(e: PointerEvent) {
if (!fileMenuWrap.value?.contains(e.target as Node)) fileMenuOpen.value = false;
}
function onMenuKey(e: KeyboardEvent) {
if (e.key === 'Escape') fileMenuOpen.value = false;
}
function detachMenuListeners() {
document.removeEventListener('pointerdown', onOutsidePointer, true);
document.removeEventListener('keydown', onMenuKey, true);
}
watch(fileMenuOpen, (open) => {
if (open) {
document.addEventListener('pointerdown', onOutsidePointer, true);
document.addEventListener('keydown', onMenuKey, true);
} else {
detachMenuListeners();
}
});
onBeforeUnmount(detachMenuListeners);
const aboutOpen = ref(false);
const functionsOpen = ref(false);
const namedRangesOpen = ref(false);
const macrosOpen = ref(false);
const documentsOpen = ref(false);
const shareOpen = ref(false);
const uploadOpen = ref(false);
const loginOpen = ref(false);
const loginReason = ref<string | undefined>();
const accountMenuOpen = ref(false);
function openLogin(reason?: string) {
loginReason.value = reason;
documentsOpen.value = false;
shareOpen.value = false;
loginOpen.value = true;
}
// after signing in: offer once (per account, per browser) to move this
// browser's documents into the account
onMounted(() => {
const user = session.state.user;
if (!user || !library.state.docs.length) return;
const key = `numolus:upload-asked:${user.id}`;
try {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, '1');
} catch {
return;
}
uploadOpen.value = true;
});
const statusNote = computed(() =>
library.state.current?.source === 'server' && !library.online.value
? 'Offline – Änderungen werden übertragen, sobald die Verbindung zurück ist'
: '',
);
// ---- document title (inline rename) ------------------------------------
const titleDraft = ref(library.currentTitle.value);
watch(library.currentTitle, (t) => (titleDraft.value = t));
function commitTitle() {
const ref = library.state.current;
const t = titleDraft.value.trim();
if (!ref || !t) titleDraft.value = library.currentTitle.value;
else if (t !== library.currentTitle.value) void library.rename(ref, t);
}
const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const addr = `${WICOM.address.street}, ${WICOM.address.city}, ${WICOM.address.country}`;
const themeLabel = computed(
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
);
const themeIcon = computed(
() => ({ system: 'auto', light: 'sun', dark: 'moon' })[themeMode.value] as ToolbarIconName,
);
// ---- insert objects ------------------------------------------------
function insertTable() {
store.addTable(store.doc.activeSheetId);
toasts.info('Tabelle eingefügt.');
}
function insertChart() {
const id = store.addChart(store.doc.activeSheetId);
if (!id) toasts.error('Diagramm konnte nicht eingefügt werden.');
}
function insertText() {
store.addText(store.doc.activeSheetId);
toasts.info('Textfeld eingefügt.');
}
function insertButton() {
store.addButton(store.doc.activeSheetId);
toasts.info('Button eingefügt.');
}
// ---- CSV export dialog -------------------------------------------
const csvExportOpen = ref(false);
const csvExport = ref<{ delimiter: Delimiter; bom: boolean; crlf: boolean }>({
delimiter: ',',
bom: false,
crlf: true,
});
function openCsvExport() {
csvExport.value.delimiter = store.doc.csvDelimiter; // start from the document default
csvExportOpen.value = true;
}
function runCsvExport() {
try {
exportActiveTableCsv(store, csvExport.value);
csvExportOpen.value = false;
toasts.success('CSV exportiert.');
} catch (err) {
toasts.error((err as Error).message);
}
}
async function runNumExport() {
try {
await exportNum(store, library.currentTitle.value);
toasts.success('Dokument als .num gespeichert.');
} catch (err) {
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
}
}
function runJsonExport() {
exportJson(store, library.currentTitle.value);
toasts.success('Dokument als JSON exportiert.');
}
async function runXlsxExport() {
busy.value = true;
try {
await exportXlsx(store, library.currentTitle.value);
toasts.success('Arbeitsmappe als XLSX exportiert.');
} catch (err) {
toasts.error(`XLSX-Export fehlgeschlagen: ${(err as Error).message}`);
} finally {
busy.value = false;
}
}
function exportSelectedChartPng() {
const o = store.activeObject.value;
if (o?.kind !== 'chart') {
toasts.error('Kein Diagramm ausgewählt — Diagramm anklicken, dann exportieren.');
return;
}
// ChartView listens for this via a window event keyed by object id
window.dispatchEvent(new CustomEvent('export-chart-png', { detail: o.id }));
}
// ---- import -----------------------------------------------------
type ImportCtx =
| { kind: 'json'; text: string; fileName: string }
| ({ kind: 'csv'; text: string; fileName: string } & CsvImportConfig)
| { kind: 'xlsx'; buffer: ArrayBuffer; fileName: string };
const importCtx = ref<ImportCtx | null>(null);
const importOpen = ref(false);
function pickFile() {
fileMenuOpen.value = false;
fileInput.value?.click();
}
async function onFileChosen(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) await handleFile(file);
}
/** Also called from the canvas drag & drop. */
async function handleFile(file: File) {
const ext = extensionOf(file.name);
try {
if (ext === 'json') {
importCtx.value = { kind: 'json', text: await readAsText(file), fileName: file.name };
} else if (ext === 'num') {
// a zipped JSON document -- unpacked, then the same replace/append choice
importCtx.value = { kind: 'json', text: await readNumFile(await readAsArrayBuffer(file)), fileName: file.name };
} else if (ext === 'csv') {
importCtx.value = {
kind: 'csv',
text: await readAsText(file),
fileName: file.name,
mode: 'new-table',
delimiter: 'auto',
detectNumbers: true,
name: file.name.replace(/\.csv$/i, ''),
};
} else if (ext === 'xlsx') {
importCtx.value = {
kind: 'xlsx',
buffer: await readAsArrayBuffer(file),
fileName: file.name,
};
} else {
toasts.error(`Nicht unterstützter Dateityp: .${ext}`);
return;
}
importOpen.value = true;
} catch (err) {
toasts.error((err as Error).message);
}
}
defineExpose({ handleFile });
async function doImport(action: 'new' | 'replace' | 'append' | 'confirm') {
const ctx = importCtx.value;
if (!ctx) return;
busy.value = true;
try {
if (ctx.kind === 'json') {
if (action === 'new') {
await library.create(parseDocument(ctx.text), ctx.fileName.replace(/\.(num|json)$/i, ''));
} else if (action === 'replace') {
if (!confirm('Aktuelles Dokument vollständig ersetzen?')) return;
importJsonReplace(store, ctx.text);
} else {
importJsonAppend(store, ctx.text);
}
} else if (ctx.kind === 'csv') {
await importCsv(store, ctx.text, {
mode: ctx.mode,
delimiter: ctx.delimiter,
detectNumbers: ctx.detectNumbers,
name: ctx.name,
});
} else {
await importXlsxFile(store, ctx.buffer);
}
toasts.success('Import erfolgreich.');
importOpen.value = false;
importCtx.value = null;
} catch (err) {
toasts.error(`Import fehlgeschlagen: ${(err as Error).message}`);
} finally {
busy.value = false;
}
}
async function onNew() {
await library.createBlank();
toasts.info('Neues Dokument angelegt.');
}
async function onSample() {
await library.createSample();
toasts.info('Musterdokument als neues Dokument angelegt.');
}
</script>
<template>
<header ref="toolbarEl" class="toolbar">
<div class="brand" title="Numolus — wicom, Vaduz (FL)">
<TheLogo class="brand-mark" />
<span v-show="showWordmark" ref="brandExtra" class="brand-extra">
<span class="wordmark">numolus</span>
<button class="by" type="button" @click="aboutOpen = true">wicom</button>
</span>
</div>
<input
v-model="titleDraft"
class="doc-title"
type="text"
title="Dokumentname (klicken zum Umbenennen)"
aria-label="Dokumentname"
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
:readonly="library.currentRole.value === 'viewer'"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
@blur="commitTitle()"
/>
<button
class="gear"
:class="{ on: store.ui.settingsOpen }"
title="Einstellungen"
aria-label="Einstellungen"
@click="store.ui.settingsOpen = !store.ui.settingsOpen"
>
<ToolbarIcon name="settings" />
</button>
<div class="tgroup">
<button
class="ibtn"
title="Rückgängig (Strg/Cmd+Z)"
aria-label="Rückgängig"
:disabled="!store.canUndo.value"
@click="store.undo()"
>
<ToolbarIcon name="undo" />
</button>
<button
class="ibtn"
title="Wiederholen (Strg/Cmd+Y)"
aria-label="Wiederholen"
:disabled="!store.canRedo.value"
@click="store.redo()"
>
<ToolbarIcon name="redo" />
</button>
</div>
<div class="tgroup accent">
<button class="ibtn" title="Tabelle einfügen" aria-label="Tabelle einfügen" @click="insertTable">
<ToolbarIcon name="table" />
</button>
<button class="ibtn" title="Diagramm einfügen" aria-label="Diagramm einfügen" @click="insertChart">
<ToolbarIcon name="chart" />
</button>
<button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText">
<ToolbarIcon name="text" />
</button>
<button class="ibtn" title="Button einfügen" aria-label="Button einfügen" @click="insertButton">
<ToolbarIcon name="button" />
</button>
</div>
<div class="tgroup accent">
<button
class="ibtn fx"
title="Benutzerdefinierte Funktionen"
aria-label="Benutzerdefinierte Funktionen"
@click="functionsOpen = true"
>
<em>fx</em>
</button>
<button
class="ibtn"
title="Benannte Bereiche"
aria-label="Benannte Bereiche"
@click="namedRangesOpen = true"
>
<ToolbarIcon name="tag" />
</button>
<button
class="ibtn"
title="Makros"
aria-label="Makros"
@click="macrosOpen = true"
>
<ToolbarIcon name="macro" />
</button>
</div>
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">Nur Lesen</span>
<span v-if="statusNote" class="status-note" :title="statusNote">Offline</span>
<span v-if="library.peers.value.length" class="peers" :title="library.peers.value.map((p) => p.name).join(', ')">
<span
v-for="p in library.peers.value.slice(0, 5)"
:key="p.clientId"
class="avatar peer"
:style="{ background: p.color }"
>{{ p.name.replace(/^Anonym \(/, '').slice(0, 1).toUpperCase() }}</span>
<span v-if="library.peers.value.length > 5" class="more">+{{ library.peers.value.length - 5 }}</span>
</span>
<div class="spacer" />
<button class="btn share" title="Dokument teilen" @click="shareOpen = true">Teilen</button>
<div ref="fileMenuWrap" class="menu-wrap">
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
<li><button @click="documentsOpen = true">Dokumente …</button></li>
<li><button @click="onNew">Neues Dokument</button></li>
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
<li class="sep" />
<li><button @click="pickFile">Öffnen / Importieren … (.num .json .csv .xlsx)</button></li>
<li><button @click="runNumExport">Speichern · Dokument (.num)</button></li>
<li class="sep" />
<li><button @click="runJsonExport">Export · Dokument (.json)</button></li>
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
<li class="sep" />
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
</ul>
</div>
<button
class="ibtn"
:title="`Theme: ${themeLabel} (klicken zum Wechseln)`"
:aria-label="`Theme: ${themeLabel}`"
@click="cycleTheme"
>
<ToolbarIcon :name="themeIcon" />
</button>
<div class="menu-wrap">
<button
v-if="session.state.user"
class="btn account"
:title="session.state.user.email"
@click="accountMenuOpen = !accountMenuOpen"
@blur="accountMenuOpen = false"
>
<span class="avatar" :style="{ background: session.state.user.color }">{{ session.state.user.name.slice(0, 1).toUpperCase() }}</span>
{{ session.state.user.name }} ▾
</button>
<button v-else class="btn" @click="openLogin()">Anmelden</button>
<ul v-if="accountMenuOpen" class="menu" @mousedown.prevent>
<li class="menu-note">{{ session.state.user?.email }}</li>
<li><button @click="accountMenuOpen = false; documentsOpen = true">Dokumente …</button></li>
<li class="sep" />
<li><button @click="session.logout()">Abmelden</button></li>
</ul>
</div>
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
<span v-if="busy" class="busy">Verarbeite …</span>
</header>
<!-- CSV export options -->
<BaseModal :open="csvExportOpen" title="CSV exportieren" @close="csvExportOpen = false">
<label class="row">
Trennzeichen
<select v-model="csvExport.delimiter">
<option value=",">Komma ,</option>
<option value=";">Semikolon ;</option>
</select>
</label>
<label class="row"><input v-model="csvExport.crlf" type="checkbox" /> Zeilenende CRLF (\r\n)</label>
<label class="row"><input v-model="csvExport.bom" type="checkbox" /> UTF-8 BOM (für Excel)</label>
<template #footer>
<button @click="csvExportOpen = false">Abbrechen</button>
<button class="primary" @click="runCsvExport">Exportieren</button>
</template>
</BaseModal>
<!-- Import dialog -->
<BaseModal
:open="importOpen"
:title="`Import · ${importCtx?.fileName ?? ''}`"
@close="importOpen = false"
>
<template v-if="importCtx?.kind === 'json'">
<p>Dokument erkannt. Als eigenes Dokument öffnen oder in das aktuelle übernehmen?</p>
</template>
<template v-else-if="importCtx?.kind === 'csv'">
<label class="row">
Ziel
<select v-model="importCtx.mode">
<option value="new-table">Als neue Tabelle (aktuelles Blatt)</option>
<option value="replace-active">Aktive Tabelle ab A1 ersetzen</option>
</select>
</label>
<label v-if="importCtx.mode === 'new-table'" class="row">
Name <input v-model="importCtx.name" type="text" />
</label>
<label class="row">
Trennzeichen
<select v-model="importCtx.delimiter">
<option value="auto">Automatisch erkennen</option>
<option value=",">Komma ,</option>
<option value=";">Semikolon ;</option>
<option :value="'\t'">Tab</option>
</select>
</label>
<label class="row">
<input v-model="importCtx.detectNumbers" type="checkbox" />
Zahlen-/Locale-Erkennung (1.234,56 ↔ 1,234.56)
</label>
<p class="hint">Werte mit führendem „=" werden als Formel übernommen.</p>
</template>
<template v-else-if="importCtx?.kind === 'xlsx'">
<p>Arbeitsmappe wird als neue Blätter angehängt – je Worksheet ein Blatt mit einer Tabelle.</p>
</template>
<template #footer>
<button @click="importOpen = false">Abbrechen</button>
<template v-if="importCtx?.kind === 'json'">
<button @click="doImport('append')">Anhängen</button>
<button @click="doImport('replace')">Ersetzen</button>
<button class="primary" @click="doImport('new')">Als neues Dokument</button>
</template>
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
</template>
</BaseModal>
<!-- Benutzerdefinierte Funktionen -->
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" @login="openLogin()" />
<ShareModal
:open="shareOpen"
@close="shareOpen = false"
@login="openLogin('Zum Teilen brauchst du ein Konto.')"
/>
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
<!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
<p class="about-lead">
<strong>Numolus</strong> — browserbasierte Tabellenkalkulation nach dem Vorbild von
Apple&nbsp;Numbers, mit selbst geschriebener Formel-Engine.
</p>
<dl class="about">
<dt>Herausgeber</dt>
<dd>
<a :href="WICOM.url" target="_blank" rel="noopener noreferrer">{{ WICOM.name }}</a>
— {{ WICOM.descriptor }}<br />
<span class="dim">„{{ WICOM.tagline }}" · seit {{ WICOM.since }}</span>
</dd>
<dt>Anschrift</dt>
<dd>{{ addr }}</dd>
<dt>Kontakt</dt>
<dd>
<a :href="`mailto:${WICOM.email}`">{{ WICOM.email }}</a><br />
<a :href="WICOM.phoneHref">{{ WICOM.phone }}</a>
</dd>
<dt>Autor</dt>
<dd>{{ WICOM.author }}</dd>
<dt>Social Media</dt>
<dd>
<template v-if="WICOM_SOCIAL.length">
<a
v-for="s in WICOM_SOCIAL"
:key="s.url"
class="social"
:href="s.url"
target="_blank"
rel="noopener noreferrer"
>{{ s.label }}</a>
</template>
<span v-else class="dim">wicom.li führt keine Social-Media-Profile.</span>
</dd>
</dl>
<template #footer>
<a class="btn-link" :href="WICOM.url" target="_blank" rel="noopener noreferrer">wicom.li ↗</a>
<button class="primary" @click="aboutOpen = false">Schließen</button>
</template>
</BaseModal>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 9px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
@media (max-width: 640px) {
.spacer {
display: none;
}
.toolbar {
gap: 6px;
padding: 8px 10px;
}
}
.brand {
display: flex;
align-items: center;
gap: 9px;
margin-right: 2px;
user-select: none;
}
.brand-mark {
height: 24px;
flex: none;
}
.brand-extra {
display: flex;
align-items: center;
gap: 9px;
}
.wordmark {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text);
}
.by {
border: 0;
background: transparent;
padding: 0;
font: inherit;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
.by span {
font-weight: 600;
}
.by:hover {
color: var(--accent);
}
/* --- Über-Dialog --- */
.about-lead {
margin: 0 0 14px;
line-height: 1.5;
}
.about {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 14px;
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.about dt {
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 2px;
}
.about dd {
margin: 0;
}
.about a {
color: var(--accent);
}
.about .dim {
color: var(--text-dim);
}
.about .social {
display: inline-block;
margin-right: 10px;
}
.btn-link {
margin-right: auto;
align-self: center;
font-size: 12.5px;
color: var(--text-dim);
text-decoration: none;
}
.btn-link:hover {
color: var(--accent);
}
.ro-badge,
.status-note {
font-size: 12px;
white-space: nowrap;
}
.ro-badge {
padding: 3px 9px;
border-radius: 999px;
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
cursor: help;
}
.status-note {
padding: 3px 9px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-weight: 600;
cursor: help;
}
.peers {
display: inline-flex;
align-items: center;
}
.peer {
margin-left: -5px;
box-shadow: 0 0 0 2px var(--surface);
cursor: help;
}
.peer:first-child {
margin-left: 0;
}
.more {
margin-left: 4px;
font-size: 12px;
color: var(--text-dim);
}
.account {
display: inline-flex;
align-items: center;
gap: 6px;
}
.avatar {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: #fff;
font-size: 11px;
font-weight: 700;
}
.menu-note {
padding: 6px 12px;
font-size: 12px;
color: var(--text-dim);
}
.doc-title {
min-width: 0;
max-width: 240px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
padding: 4px 7px;
font: inherit;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
text-overflow: ellipsis;
}
.doc-title:hover {
border-color: var(--border);
}
.doc-title:focus {
outline: none;
border-color: var(--accent);
background: var(--surface-2);
}
.gear {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: transparent;
border-radius: 9px;
width: 32px;
height: 32px;
cursor: pointer;
color: var(--text-dim);
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.gear:hover {
background: var(--surface-2);
color: var(--text);
}
.gear.on {
background: var(--accent-weak);
border-color: var(--accent);
color: var(--accent);
}
/* --- grouped icon-button clusters (segmented-control look) --- */
.tgroup {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
}
.ibtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 28px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
.tgroup.accent .ibtn {
color: var(--accent);
}
.ibtn:hover {
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-sm);
}
.tgroup.accent .ibtn:hover {
color: var(--accent);
}
.ibtn:disabled {
opacity: 0.35;
cursor: default;
pointer-events: none;
}
.ibtn.fx em {
font-style: italic;
font-weight: 700;
font-size: 14px;
font-family: Georgia, 'Times New Roman', serif;
}
.spacer {
flex: 1;
}
.btn {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 8px;
padding: 6px 12px;
font-size: 12.5px;
cursor: pointer;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.btn:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
.menu-wrap {
position: relative;
}
.menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 50;
min-width: 300px;
margin: 0;
padding: 6px;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.menu li.sep {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.menu button {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.menu button:hover {
background: var(--accent);
color: #fff;
}
.busy {
font-size: 12px;
color: var(--accent);
}
.row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
justify-content: space-between;
}
.row select,
.row input[type='text'] {
flex: 1;
max-width: 220px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.hint {
color: var(--text-dim);
font-size: 12px;
}
</style>