Merge branch 'feat/outliner-polish' into dev

Offer /number at start or end like the other block commands and start a
numbered list when typing "1. " into an empty bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 19:44:20 +01:00
commit 7a90e6dae3
8 files changed
+680 -212

No files matched your search

+7 -2
View File
@@ -36,7 +36,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
}, [onClose]);
const desktopAnchor = anchor
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
? ({
'--sheet-left': `${anchor.x}px`,
'--sheet-top': `${anchor.y}px`,
'--sheet-x': `clamp(12px, calc(${anchor.x}px - 8.25rem), calc(100vw - 16.5rem - 12px))`,
'--sheet-y': `calc(${anchor.y}px + 8px)`,
} as React.CSSProperties)
: undefined;
return (
@@ -54,7 +59,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
anchor
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
}`}
>
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
import {
Mic,
MicOff,
ArrowUp,
ArrowDown,
Sparkles,
Calendar,
Check,
@@ -73,6 +73,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
});
const isListening = voice.state === 'listening';
const isTranscribing = voice.state === 'transcribing';
const [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current;
@@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const scrollH = targetEl.scrollHeight;
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
setIsOverMaxHeight(scrollH > 140);
} else {
setIsMultiline(false);
setIsOverMaxHeight(false);
}
};
@@ -267,16 +270,18 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
: isTranscribing
? 'Wird erkannt…'
: hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen… (Enter zum Speichern)'
? 'Quick Dump erfassen…'
: target === 'task'
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
? 'Aufgabe erfassen…'
: target === 'list_item'
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
: target === 'document'
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
? 'Notiz erfassen…'
: 'Quick Dump…'
}
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto"
className={`w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
}`}
style={{ minHeight: '22px', maxHeight: '140px' }}
autoComplete="off"
/>
@@ -360,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</>
) : (
<>
<ArrowUp className="w-3 h-3" />
<ArrowDown className="w-3 h-3" />
<span className="inline">
{hideTargetPicker || (fixedTarget || target) === 'inbox'
? 'Quick Dump'
+67 -1
View File
@@ -725,7 +725,73 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</div>
)}
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
{/* Mobile Quick Action Buttons under the card (Touch targets >= 44px) */}
<div className="grid grid-cols-4 gap-1.5 sm:hidden">
<button
type="button"
onClick={() => handleAction('discard')}
title="Wegwerfen"
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
>
<X className="w-4 h-4 text-muted flex-shrink-0" />
<span>Wegwerfen</span>
</button>
<button
type="button"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer ${
writableLists.length === 0
? 'opacity-40 cursor-not-allowed'
: showListSelector
? 'border-accent text-accent bg-accent-dim'
: ''
}`}
>
<ShoppingCart className="w-4 h-4 text-accent flex-shrink-0" />
<span className="truncate max-w-full">{singleWritableList ? singleWritableList.title : 'Liste'}</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'document' })}
title="In Notiz umwandeln"
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
>
<FileText className="w-4 h-4 text-accent flex-shrink-0" />
<span>Notiz</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'task' })}
title="Als Aufgabe übernehmen"
className="btn-primary h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] font-semibold whitespace-nowrap touch-manipulation cursor-pointer"
>
<CheckSquare className="w-4 h-4 flex-shrink-0" />
<span>Aufgabe</span>
</button>
</div>
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
<div className="hidden sm:flex items-center gap-1.5">
<button
onClick={() => handleAction('discard')}
@@ -160,4 +160,26 @@ describe('TriageDeck single-list shortcut', () => {
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
});
it('renders mobile quick action buttons and triggers discard', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
fireEvent.click(mobileDiscard);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
});
+448 -194
View File
@@ -25,6 +25,7 @@ import {
Mic,
AudioLines,
FileOutput,
Type,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
@@ -57,6 +58,7 @@ const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: <Type className="w-3.5 h-3.5" />, aliases: ['p', 'paragraph', 'bullet', 'standard'] },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
@@ -161,7 +163,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const activeFocus = isFocused || isLocallyFocused;
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null);
const slashSearch = slashMatchInfo ? slashMatchInfo.query : null;
const linkBeforeRef = useRef<string>('');
const linkAfterRef = useRef<string>('');
const dictationBeforeRef = useRef<string>('');
const dictationAfterRef = useRef<string>('');
const slashMenuRef = useRef<HTMLDivElement | null>(null);
const todoMenuRef = useRef<HTMLDivElement | null>(null);
const blurSlashTimerRef = useRef<number | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
@@ -277,9 +287,48 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
};
}, []);
// Close slash command menu on click outside
useEffect(() => {
if (!slashMatchInfo) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (
slashMenuRef.current &&
!slashMenuRef.current.contains(target) &&
textareaRef.current &&
!textareaRef.current.contains(target)
) {
setSlashMatchInfo(null);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [slashMatchInfo]);
// Close todo menu on click outside
useEffect(() => {
if (!showTodoMenu) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (todoMenuRef.current && !todoMenuRef.current.contains(target)) {
setShowTodoMenu(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [showTodoMenu]);
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
@@ -363,22 +412,55 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [adjustHeight, block.type]);
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
if (!slashMatchInfo) return [];
const q = slashMatchInfo.query.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter(
(c) =>
(!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
(c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
);
}, [slashSearch, localContent, onExtractBlock]);
const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart);
const textAfterCursor = localContent.slice(slashMatchInfo.cursor);
const restOfBlock = (textBeforeSlash + textAfterCursor).trim();
const isRestEmpty = restOfBlock === '';
const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === '';
return SLASH_COMMANDS.filter((c) => {
const matchesQuery =
c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery));
if (!matchesQuery) return false;
// /link and /dictation everywhere
if (c.type === 'link') return true;
if (c.type === 'dictation') {
return ['server', 'browser'].includes(currentVoiceConfig().mode);
}
// /divider, /image, /audio, /code only when rest of block is empty
if (c.type === 'divider' || c.type === 'image' || c.type === 'audio' || c.type === 'code') {
return isRestEmpty;
}
// /extract only when note has content and is at start/end
if (c.type === 'extract') {
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock;
}
// /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end
if (
c.type === 'h1' ||
c.type === 'h2' ||
c.type === 'h3' ||
c.type === 'todo' ||
c.type === 'numbered' ||
c.type === 'callout' ||
c.type === 'bullet'
) {
return isAtStartOrEnd;
}
return true;
});
}, [slashMatchInfo, localContent, onExtractBlock]);
useEffect(() => {
if (slashSearch === null) return;
@@ -386,15 +468,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
const slashStart = slashMatchInfo ? slashMatchInfo.slashStart : 0;
const slashEnd = slashMatchInfo ? slashMatchInfo.cursor : localContent.length;
setSlashMatchInfo(null);
const before = localContent.slice(0, slashStart);
const after = localContent.slice(slashEnd);
const clean = `${before}${after}`.replace(/\s{2,}/g, ' ').trim();
if (cmd.type === 'link') {
linkBeforeRef.current = before;
linkAfterRef.current = after;
setLocalContent(clean);
localContentRef.current = clean;
setShowLinkModal(true);
return;
}
if (cmd.type === 'dictation') {
dictationBeforeRef.current = before;
dictationAfterRef.current = after;
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean);
setMediaPicker('dictation');
return;
}
if (cmd.type === 'extract') {
setLocalContent(clean);
localContentRef.current = clean;
@@ -402,31 +506,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onExtractBlock?.(block.id);
return;
}
if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent('');
localContentRef.current = '';
flushDebounce('');
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
setMediaPicker(cmd.type as 'image' | 'audio');
return;
}
const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i);
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
: undefined;
setLocalContent(clean);
localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, {
type: cmd.type as BlockType, // media commands returned above
type: cmd.type as BlockType,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}),
});
};
const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false);
if (localContent.trim() === '') {
const b = linkBeforeRef.current ?? '';
const a = linkAfterRef.current ?? '';
if (b.trim() === '' && a.trim() === '') {
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) {
const bareUrl = webMatch[1];
@@ -436,10 +547,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return;
}
}
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim();
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
@@ -512,6 +622,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') {
// "1. " typed into an empty bullet starts a numbered list
val = '';
typeOverride = 'numbered';
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
@@ -521,18 +635,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
} else if (
(val.startsWith('/text ') ||
val.startsWith('/p ') ||
val.startsWith('/paragraph ') ||
val.startsWith('/bullet ') ||
val.startsWith('/standard ')) &&
block.type !== 'bullet'
) {
val = val.replace(/^\/(text|p|paragraph|bullet|standard)\s+/, '');
typeOverride = 'bullet';
}
// Check for slash command invocation
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
// Check for slash command invocation at cursor position
const sel = e.currentTarget.selectionStart;
const cursor = typeof sel === 'number' && sel > 0 ? sel : val.length;
const textBefore = val.slice(0, cursor);
const slashMatch = !typeOverride ? textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) {
setSlashSearch(slashMatch[2]);
const slashStart = slashMatch.index! + slashMatch[1].length;
setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor });
setSlashIndex(0);
} else {
setSlashSearch(null);
setSlashMatchInfo(null);
}
} else {
setSlashSearch(null);
setSlashMatchInfo(null);
}
setLocalContent(val);
@@ -548,6 +676,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
type: typeOverride,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: checkedOverride ?? latestBlockRef.current.checked,
...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}),
});
return;
}
@@ -562,6 +691,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
};
const handleFocus = () => {
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
isFocusedRef.current = true;
setIsLocallyFocused(true);
onFocusBlock?.(block.id);
@@ -572,6 +705,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isFocusedRef.current = false;
setIsLocallyFocused(false);
onBlurBlock?.(block.id);
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
}
blurSlashTimerRef.current = window.setTimeout(() => {
setSlashMatchInfo(null);
}, 150);
if (isPastingRef.current) {
isPastingRef.current = false;
return;
@@ -756,7 +895,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (slashSearch !== null) {
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
setSlashMatchInfo(null);
return;
}
if (filteredSlashCommands.length > 0) {
@@ -796,7 +935,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
if (fenceMatch) {
@@ -856,7 +995,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Backspace' && localContent === '') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -882,7 +1021,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
}
return;
}
@@ -932,6 +1071,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return (
<div
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
block.type === 'h1'
? 'mt-2.5 sm:mt-3'
: block.type === 'h2'
? 'mt-1.5 sm:mt-2'
: ''
} ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
@@ -1071,14 +1216,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<DictationBlock
onDone={(text) => {
setMediaPicker(null);
setLocalContent(text);
localContentRef.current = text;
flushDebounce(text);
const b = dictationBeforeRef.current ?? '';
const a = dictationAfterRef.current ?? '';
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
const newContent = `${b}${spaceBefore}${text}${spaceAfter}${a}`.trim();
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
const pos = (b + spaceBefore + text).length;
ta.setSelectionRange(pos, pos);
});
}}
onCancel={() => {
@@ -1128,8 +1279,43 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
/>
) : block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
<div
className="relative py-2 group/divider cursor-pointer w-full"
onClick={() => {
textareaRef.current?.focus();
}}
>
<hr
className={`border-t transition-colors ${
activeFocus ? 'border-accent ring-1 ring-accent/30' : 'border-border'
}`}
/>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value=""
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={() => {}}
onKeyDown={(e) => {
if (!canEdit) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
refocusAfterTypeChangeRef.current = true;
flushDebounce('', { type: 'bullet' });
setLocalContent('');
localContentRef.current = '';
return;
}
handleKeyDown(e);
}}
className="sr-only"
aria-label="Trennlinie"
/>
</div>
) : block.type === 'code' ? (
<CodeBlockEditor
@@ -1169,7 +1355,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
) : (
<div className="relative w-full min-h-[1.5rem]">
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
{/* View Layer with Inline Markdown */}
<div
onClick={(e) => {
if (!activeFocus && canFocus && textareaRef.current) {
@@ -1182,11 +1368,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
@@ -1196,104 +1382,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
</span>
{/* Inline Badge and 3-Dots directly behind the text */}
{block.type === 'todo' && (
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
{block.taskId && (
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.taskId as string);
}}
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
title="Erweiterte Aufgabe öffnen"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</button>
)}
{canEdit && (
<span className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const targetDocId = docId || block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(targetDocId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: thought.id,
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
onOpenTask?.(thought.id);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
) : (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
if (block.taskId) {
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
}
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: '',
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<X className="w-3.5 h-3.5 text-status-warn" />
<span>Verknüpfung aufheben</span>
</button>
)}
</div>
)}
</span>
)}
</span>
)}
</div>
<textarea
@@ -1309,15 +1397,47 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onChange={handleContentChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onClick={() => {
if (slashMatchInfo) {
const ta = textareaRef.current;
if (ta) {
const cursor = ta.selectionStart;
const textBefore = ta.value.slice(0, cursor);
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
if (match) {
const slashStart = match.index! + match[1].length;
setSlashMatchInfo({ query: match[2], slashStart, cursor });
} else {
setSlashMatchInfo(null);
}
}
}
}}
onKeyUp={(e) => {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key) && slashMatchInfo) {
const ta = textareaRef.current;
if (ta) {
const cursor = ta.selectionStart;
const textBefore = ta.value.slice(0, cursor);
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
if (match) {
const slashStart = match.index! + match[1].length;
setSlashMatchInfo({ query: match[2], slashStart, cursor });
} else {
setSlashMatchInfo(null);
}
}
}
}}
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
@@ -1326,6 +1446,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
)}
{/* If To-Do is an enhanced task, render badge stably outside text layer */}
{block.type === 'todo' && block.taskId && (
<div className="flex items-center gap-1 mt-0.5 select-none">
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.taskId as string);
}}
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
title="Erweiterte Aufgabe öffnen"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</button>
</div>
)}
{/* Conflict copy: text that lost against someone else's change */}
{block.conflictOf && (
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
@@ -1417,7 +1556,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1">
<div
ref={slashMenuRef}
className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1"
>
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen
</div>
@@ -1435,7 +1577,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
i === slashIndex
? 'bg-accent text-on-accent'
? 'bg-accent-dim text-accent font-semibold'
: 'text-main hover:bg-panel-elevated'
}`}
>
@@ -1456,29 +1598,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
// Phones zoom by tapping the bullet; the button would only take width from the text there
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Someone else is editing this block */}
{lockedBy && (
<span
@@ -1490,29 +1609,164 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</span>
)}
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
{canEdit && (
<button
type="button"
onClick={() => {
if (hasChildren) {
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
onDeleteBlockRef.current(block.id, true);
{/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
<div
className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
activeFocus
? 'opacity-100 pointer-events-auto'
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
}`}
>
{/* Convert back to bullet for headings, callouts, divider */}
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && (
<button
type="button"
onClick={() => {
refocusAfterTypeChangeRef.current = true;
flushDebounce(localContent, { type: 'bullet', checked: false });
}}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In normalen Text umwandeln"
>
<Type className="w-3.5 h-3.5" />
</button>
)}
{/* To-Do 3-dots Menu */}
{block.type === 'todo' && canEdit && (
<div className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
ref={todoMenuRef}
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const targetDocId = docId || block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(targetDocId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: thought.id,
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
onOpenTask?.(thought.id);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
) : (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
if (block.taskId) {
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
}
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: '',
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<X className="w-3.5 h-3.5 text-status-warn" />
<span>Verknüpfung aufheben</span>
</button>
)}
<button
type="button"
onClick={() => {
setShowTodoMenu(false);
refocusAfterTypeChangeRef.current = true;
flushDebounce(localContent, { type: 'bullet', checked: false, taskId: '' });
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer border-t border-border mt-0.5 pt-1.5"
>
<Type className="w-3.5 h-3.5 text-accent" />
<span>In normalen Text umwandeln</span>
</button>
</div>
)}
</div>
)}
{/* Branch into its own note */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button */}
{onZoomBlock && (
<button
type="button"
onClick={() => onZoomBlock(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
)}
{/* Delete button */}
{canEdit && (
<button
type="button"
onClick={() => {
if (hasChildren) {
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
onDeleteBlockRef.current(block.id, true);
}
} else {
onDeleteBlockRef.current(block.id, false);
}
} else {
onDeleteBlockRef.current(block.id, false);
}
}}
className={
activeFocus
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
}
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
}}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-status-err rounded-btn transition-colors cursor-pointer"
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Link Insertion Modal */}
{showLinkModal && (
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
expect(queryByText('Befehl auswählen')).toBeNull();
fireEvent.change(ta, { target: { value: 'Text /co' } });
fireEvent.change(ta, { target: { value: 'Text /ca' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/code')).toBeTruthy();
expect(queryByText('/callout')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
@@ -375,3 +375,106 @@ describe('formatting shortcuts (Teil C)', () => {
expect(readerTa.value).toBe('text');
});
});
describe('outliner polish (Teil A)', () => {
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
// Menu should be open with /link, but without /divider or /h1
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/link')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
expect(queryByText('/h1')).toBeNull();
// Select /link by pressing Enter
fireEvent.keyDown(ta, { key: 'Enter' });
// Link modal opens
expect(queryByText('Web-Link')).toBeTruthy();
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
// Result has link inserted in the middle
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
});
it('does not offer /divider at end of text, but offers it in empty block', () => {
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// At end of non-empty text
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
// In an empty block
fireEvent.change(ta, { target: { value: '/div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeTruthy();
});
it('converts H1 block back to bullet via /text slash command', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/text')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Enter' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('Meine Überschrift');
});
it('closes slash menu on mousedown outside', () => {
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
// Click outside on document body
fireEvent.mouseDown(document.body);
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('converts focused divider to empty bullet on Backspace', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.keyDown(ta, { key: 'Backspace' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('');
});
});
+6 -4
View File
@@ -318,12 +318,14 @@ body {
pointer-events: none;
}
.count-badge-accent {
background-color: var(--accent-primary);
color: var(--text-on-accent);
background: var(--accent-dim);
color: var(--accent-primary);
border: 1px solid var(--accent-border);
}
.count-badge-muted {
background-color: var(--text-muted);
color: var(--bg-panel);
background-color: var(--bg-panel-elevated);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.count-badge-corner {
position: absolute;