Files
numolus/app/frontend/components/AppToolbar.vue
T
wkh 0fca61eb65 Add in-app help, formula reference outlines, underline and auto-closing
- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the
  formula functions with one search over everything. Topics and shortcuts
  are plain data in data/help.ts; the function list comes from
  codeReference.ts so new built-ins appear by themselves.
- While a formula is edited, the grid outlines each referenced cell/range
  in the colour of its position in the text, and the outline can be
  dragged to move or resize the reference, as in Excel. Point-mode picks
  (click, drag, arrow keys) are outlined too, so keyboard picking is no
  longer blind.
- Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on
  unless every selected cell already has the format.
- A formula committed with open parentheses or an open text literal gets
  the missing closers appended; anything that still doesn't parse is left
  alone so the usual error shows instead of a guess.
- Cells and tables with a fill but no text colour get dark or light text
  by WCAG contrast instead of the theme colour (no black on black).
2026-10-24 00:38:01 +02:00

995 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 HelpModal from './HelpModal.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);
const helpOpen = ref(false);
// F1 opens the help from anywhere (also out of a cell editor -- the browser's
// own F1 help is what preventDefault keeps away)
function onHelpKey(e: KeyboardEvent) {
if (e.key !== 'F1') return;
e.preventDefault();
helpOpen.value = true;
}
onMounted(() => window.addEventListener('keydown', onHelpKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onHelpKey));
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.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="helpOpen = true">Hilfe … (F1)</button></li>
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
</ul>
</div>
<button class="ibtn help" title="Hilfe (F1)" aria-label="Hilfe" @click="helpOpen = true">?</button>
<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" />
<HelpModal :open="helpOpen" @close="helpOpen = 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;
}
/* the help button is a plain "?" rather than an icon */
.ibtn.help {
font-size: 15px;
font-weight: 700;
}
.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>