Add Rails backend: accounts via magic link, server documents, sharing

The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+944
View File
@@ -0,0 +1,944 @@
<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(() => {
if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert';
if (library.state.syncState === 'saving') return 'Speichert …';
return '';
});
// ---- 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">
{{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }}
</span>
<span v-else-if="statusNote" class="status-note">{{ statusNote }}</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 {
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>