diff --git a/.impeccable/design.json b/.impeccable/design.json index 72646ef..28f1b41 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -1,54 +1,452 @@ { "schemaVersion": 2, - "generatedAt": "2026-08-28T00:00:00Z", + "generatedAt": "2026-08-29T00:00:00Z", "title": "Design System: Beaver admin", "extensions": { "colorMeta": { - "primary": { "role": "primary", "displayName": "Mulberry", "canonical": "oklch(0.47 0.13 340)", "dark": "#7c3871", "tonalRamp": ["oklch(0.15 0.13 340)", "oklch(0.26 0.13 340)", "oklch(0.37 0.13 340)", "oklch(0.47 0.13 340)", "oklch(0.58 0.13 340)", "oklch(0.70 0.13 340)", "oklch(0.83 0.13 340)", "oklch(0.95 0.13 340)"] }, - "signal": { "role": "primary", "displayName": "Orchid Signal", "canonical": "oklch(0.48 0.21 342)", "dark": "#ff82f3", "tonalRamp": ["oklch(0.15 0.21 342)", "oklch(0.26 0.21 342)", "oklch(0.37 0.21 342)", "oklch(0.48 0.21 342)", "oklch(0.60 0.21 342)", "oklch(0.72 0.21 342)", "oklch(0.84 0.21 342)", "oklch(0.95 0.21 342)"] }, - "ring": { "role": "primary", "displayName": "Focus Ring", "canonical": "oklch(0.55 0.2 342)", "dark": "#ff82f3", "tonalRamp": ["oklch(0.15 0.2 342)", "oklch(0.26 0.2 342)", "oklch(0.38 0.2 342)", "oklch(0.55 0.2 342)", "oklch(0.64 0.2 342)", "oklch(0.74 0.2 342)", "oklch(0.85 0.2 342)", "oklch(0.95 0.2 342)"] }, - "status-reply": { "role": "secondary", "displayName": "Branch Blue", "canonical": "oklch(0.55 0.14 235)", "dark": "oklch(0.72 0.13 235)", "tonalRamp": ["oklch(0.15 0.14 235)", "oklch(0.26 0.14 235)", "oklch(0.40 0.14 235)", "oklch(0.55 0.14 235)", "oklch(0.65 0.14 235)", "oklch(0.75 0.14 235)", "oklch(0.85 0.14 235)", "oklch(0.95 0.14 235)"] }, - "status-meeting": { "role": "secondary", "displayName": "Deep Violet", "canonical": "oklch(0.5 0.2 300)", "dark": "oklch(0.72 0.16 300)", "tonalRamp": ["oklch(0.15 0.2 300)", "oklch(0.26 0.2 300)", "oklch(0.38 0.2 300)", "oklch(0.50 0.2 300)", "oklch(0.62 0.2 300)", "oklch(0.74 0.2 300)", "oklch(0.85 0.2 300)", "oklch(0.95 0.2 300)"] }, - "status-work": { "role": "secondary", "displayName": "Job Teal", "canonical": "oklch(0.55 0.13 195)", "dark": "oklch(0.75 0.12 195)", "tonalRamp": ["oklch(0.15 0.13 195)", "oklch(0.26 0.13 195)", "oklch(0.40 0.13 195)", "oklch(0.55 0.13 195)", "oklch(0.65 0.13 195)", "oklch(0.75 0.13 195)", "oklch(0.85 0.13 195)", "oklch(0.95 0.13 195)"] }, - "status-skip": { "role": "secondary", "displayName": "Fork Grey", "canonical": "oklch(0.5 0.04 340)", "dark": "#a989a3", "tonalRamp": ["oklch(0.15 0.04 340)", "oklch(0.26 0.04 340)", "oklch(0.38 0.04 340)", "oklch(0.50 0.04 340)", "oklch(0.62 0.04 340)", "oklch(0.74 0.04 340)", "oklch(0.85 0.04 340)", "oklch(0.95 0.04 340)"] }, - "status-done": { "role": "tertiary", "displayName": "OK Green", "canonical": "oklch(0.55 0.14 155)", "dark": "oklch(0.74 0.14 155)", "tonalRamp": ["oklch(0.15 0.14 155)", "oklch(0.26 0.14 155)", "oklch(0.40 0.14 155)", "oklch(0.55 0.14 155)", "oklch(0.65 0.14 155)", "oklch(0.75 0.14 155)", "oklch(0.85 0.14 155)", "oklch(0.95 0.14 155)"] }, - "warn": { "role": "tertiary", "displayName": "Warn Amber", "canonical": "oklch(0.55 0.13 75)", "dark": "oklch(0.8 0.13 75)", "tonalRamp": ["oklch(0.15 0.13 75)", "oklch(0.26 0.13 75)", "oklch(0.40 0.13 75)", "oklch(0.55 0.13 75)", "oklch(0.65 0.13 75)", "oklch(0.75 0.13 75)", "oklch(0.85 0.13 75)", "oklch(0.95 0.13 75)"] }, - "note": { "role": "tertiary", "displayName": "Inject Amber", "canonical": "oklch(0.52 0.13 78)", "dark": "oklch(0.84 0.14 88)", "tonalRamp": ["oklch(0.15 0.13 78)", "oklch(0.26 0.13 78)", "oklch(0.40 0.13 78)", "oklch(0.52 0.13 78)", "oklch(0.64 0.13 78)", "oklch(0.75 0.13 78)", "oklch(0.85 0.13 78)", "oklch(0.95 0.13 78)"] }, - "destructive": { "role": "tertiary", "displayName": "Error Red", "canonical": "oklch(0.58 0.22 25)", "dark": "oklch(0.62 0.2 25)", "tonalRamp": ["oklch(0.15 0.22 25)", "oklch(0.26 0.22 25)", "oklch(0.40 0.22 25)", "oklch(0.58 0.22 25)", "oklch(0.66 0.22 25)", "oklch(0.75 0.22 25)", "oklch(0.85 0.22 25)", "oklch(0.95 0.22 25)"] }, - "background": { "role": "neutral", "displayName": "Blush Paper", "canonical": "oklch(0.99 0.004 340)", "dark": "#22111e", "tonalRamp": ["oklch(0.15 0.004 340)", "oklch(0.26 0.004 340)", "oklch(0.40 0.004 340)", "oklch(0.55 0.004 340)", "oklch(0.70 0.004 340)", "oklch(0.85 0.004 340)", "oklch(0.95 0.004 340)", "oklch(0.99 0.004 340)"] }, - "foreground": { "role": "neutral", "displayName": "Plum Ink", "canonical": "oklch(0.24 0.03 340)", "dark": "#f5f5f5", "tonalRamp": ["oklch(0.15 0.03 340)", "oklch(0.24 0.03 340)", "oklch(0.38 0.03 340)", "oklch(0.50 0.03 340)", "oklch(0.62 0.03 340)", "oklch(0.75 0.03 340)", "oklch(0.87 0.03 340)", "oklch(0.95 0.03 340)"] }, - "sidebar": { "role": "neutral", "displayName": "Sidebar Layer", "canonical": "oklch(0.975 0.01 340)", "dark": "#2c1b29", "tonalRamp": ["oklch(0.15 0.01 340)", "oklch(0.26 0.01 340)", "oklch(0.40 0.01 340)", "oklch(0.55 0.01 340)", "oklch(0.70 0.01 340)", "oklch(0.85 0.01 340)", "oklch(0.94 0.01 340)", "oklch(0.975 0.01 340)"] }, - "sidebar-accent": { "role": "neutral", "displayName": "Sidebar Accent", "canonical": "oklch(0.94 0.016 340)", "dark": "#341d2f", "tonalRamp": ["oklch(0.15 0.016 340)", "oklch(0.26 0.016 340)", "oklch(0.40 0.016 340)", "oklch(0.55 0.016 340)", "oklch(0.70 0.016 340)", "oklch(0.85 0.016 340)", "oklch(0.94 0.016 340)", "oklch(0.98 0.016 340)"] }, - "muted": { "role": "neutral", "displayName": "Muted", "canonical": "oklch(0.96 0.012 340)", "dark": "#40283a", "tonalRamp": ["oklch(0.15 0.012 340)", "oklch(0.26 0.012 340)", "oklch(0.40 0.012 340)", "oklch(0.55 0.012 340)", "oklch(0.70 0.012 340)", "oklch(0.85 0.012 340)", "oklch(0.96 0.012 340)", "oklch(0.99 0.012 340)"] }, - "muted-foreground": { "role": "neutral", "displayName": "Muted Ink", "canonical": "oklch(0.5 0.04 340)", "dark": "#a989a3", "tonalRamp": ["oklch(0.15 0.04 340)", "oklch(0.26 0.04 340)", "oklch(0.38 0.04 340)", "oklch(0.50 0.04 340)", "oklch(0.62 0.04 340)", "oklch(0.74 0.04 340)", "oklch(0.85 0.04 340)", "oklch(0.95 0.04 340)"] }, - "icon": { "role": "neutral", "displayName": "Icon", "canonical": "oklch(0.55 0.03 340)", "dark": "#877384", "tonalRamp": ["oklch(0.15 0.03 340)", "oklch(0.26 0.03 340)", "oklch(0.40 0.03 340)", "oklch(0.55 0.03 340)", "oklch(0.65 0.03 340)", "oklch(0.75 0.03 340)", "oklch(0.85 0.03 340)", "oklch(0.95 0.03 340)"] }, - "border": { "role": "neutral", "displayName": "Hairline", "canonical": "oklch(0.9 0.02 340)", "dark": "oklch(0.92 0.04 340 / 10%)", "tonalRamp": ["oklch(0.15 0.02 340)", "oklch(0.26 0.02 340)", "oklch(0.40 0.02 340)", "oklch(0.55 0.02 340)", "oklch(0.70 0.02 340)", "oklch(0.80 0.02 340)", "oklch(0.90 0.02 340)", "oklch(0.97 0.02 340)"] } + "primary": { + "role": "primary", + "displayName": "Mulberry", + "canonical": "oklch(0.47 0.13 340)", + "dark": "#7c3871", + "tonalRamp": [ + "oklch(0.15 0.13 340)", + "oklch(0.26 0.13 340)", + "oklch(0.37 0.13 340)", + "oklch(0.47 0.13 340)", + "oklch(0.58 0.13 340)", + "oklch(0.70 0.13 340)", + "oklch(0.83 0.13 340)", + "oklch(0.95 0.13 340)" + ] + }, + "signal": { + "role": "primary", + "displayName": "Orchid Signal", + "canonical": "oklch(0.48 0.21 342)", + "dark": "#ff82f3", + "tonalRamp": [ + "oklch(0.15 0.21 342)", + "oklch(0.26 0.21 342)", + "oklch(0.37 0.21 342)", + "oklch(0.48 0.21 342)", + "oklch(0.60 0.21 342)", + "oklch(0.72 0.21 342)", + "oklch(0.84 0.21 342)", + "oklch(0.95 0.21 342)" + ] + }, + "ring": { + "role": "primary", + "displayName": "Focus Ring", + "canonical": "oklch(0.55 0.2 342)", + "dark": "#ff82f3", + "tonalRamp": [ + "oklch(0.15 0.2 342)", + "oklch(0.26 0.2 342)", + "oklch(0.38 0.2 342)", + "oklch(0.55 0.2 342)", + "oklch(0.64 0.2 342)", + "oklch(0.74 0.2 342)", + "oklch(0.85 0.2 342)", + "oklch(0.95 0.2 342)" + ] + }, + "status-reply": { + "role": "secondary", + "displayName": "Branch Blue", + "canonical": "oklch(0.55 0.14 235)", + "dark": "oklch(0.72 0.13 235)", + "tonalRamp": [ + "oklch(0.15 0.14 235)", + "oklch(0.26 0.14 235)", + "oklch(0.40 0.14 235)", + "oklch(0.55 0.14 235)", + "oklch(0.65 0.14 235)", + "oklch(0.75 0.14 235)", + "oklch(0.85 0.14 235)", + "oklch(0.95 0.14 235)" + ] + }, + "status-meeting": { + "role": "secondary", + "displayName": "Deep Violet", + "canonical": "oklch(0.5 0.2 300)", + "dark": "oklch(0.72 0.16 300)", + "tonalRamp": [ + "oklch(0.15 0.2 300)", + "oklch(0.26 0.2 300)", + "oklch(0.38 0.2 300)", + "oklch(0.50 0.2 300)", + "oklch(0.62 0.2 300)", + "oklch(0.74 0.2 300)", + "oklch(0.85 0.2 300)", + "oklch(0.95 0.2 300)" + ] + }, + "status-work": { + "role": "secondary", + "displayName": "Job Teal", + "canonical": "oklch(0.55 0.13 195)", + "dark": "oklch(0.75 0.12 195)", + "tonalRamp": [ + "oklch(0.15 0.13 195)", + "oklch(0.26 0.13 195)", + "oklch(0.40 0.13 195)", + "oklch(0.55 0.13 195)", + "oklch(0.65 0.13 195)", + "oklch(0.75 0.13 195)", + "oklch(0.85 0.13 195)", + "oklch(0.95 0.13 195)" + ] + }, + "status-skip": { + "role": "secondary", + "displayName": "Fork Grey", + "canonical": "oklch(0.5 0.04 340)", + "dark": "#a989a3", + "tonalRamp": [ + "oklch(0.15 0.04 340)", + "oklch(0.26 0.04 340)", + "oklch(0.38 0.04 340)", + "oklch(0.50 0.04 340)", + "oklch(0.62 0.04 340)", + "oklch(0.74 0.04 340)", + "oklch(0.85 0.04 340)", + "oklch(0.95 0.04 340)" + ] + }, + "status-done": { + "role": "tertiary", + "displayName": "OK Green", + "canonical": "oklch(0.55 0.14 155)", + "dark": "oklch(0.74 0.14 155)", + "tonalRamp": [ + "oklch(0.15 0.14 155)", + "oklch(0.26 0.14 155)", + "oklch(0.40 0.14 155)", + "oklch(0.55 0.14 155)", + "oklch(0.65 0.14 155)", + "oklch(0.75 0.14 155)", + "oklch(0.85 0.14 155)", + "oklch(0.95 0.14 155)" + ] + }, + "warn": { + "role": "tertiary", + "displayName": "Warn Amber", + "canonical": "oklch(0.55 0.13 75)", + "dark": "oklch(0.8 0.13 75)", + "tonalRamp": [ + "oklch(0.15 0.13 75)", + "oklch(0.26 0.13 75)", + "oklch(0.40 0.13 75)", + "oklch(0.55 0.13 75)", + "oklch(0.65 0.13 75)", + "oklch(0.75 0.13 75)", + "oklch(0.85 0.13 75)", + "oklch(0.95 0.13 75)" + ] + }, + "note": { + "role": "tertiary", + "displayName": "Inject Amber", + "canonical": "oklch(0.52 0.13 78)", + "dark": "oklch(0.84 0.14 88)", + "tonalRamp": [ + "oklch(0.15 0.13 78)", + "oklch(0.26 0.13 78)", + "oklch(0.40 0.13 78)", + "oklch(0.52 0.13 78)", + "oklch(0.64 0.13 78)", + "oklch(0.75 0.13 78)", + "oklch(0.85 0.13 78)", + "oklch(0.95 0.13 78)" + ] + }, + "destructive": { + "role": "tertiary", + "displayName": "Error Red", + "canonical": "oklch(0.58 0.22 25)", + "dark": "oklch(0.62 0.2 25)", + "tonalRamp": [ + "oklch(0.15 0.22 25)", + "oklch(0.26 0.22 25)", + "oklch(0.40 0.22 25)", + "oklch(0.58 0.22 25)", + "oklch(0.66 0.22 25)", + "oklch(0.75 0.22 25)", + "oklch(0.85 0.22 25)", + "oklch(0.95 0.22 25)" + ] + }, + "background": { + "role": "neutral", + "displayName": "Blush Paper", + "canonical": "oklch(0.99 0.004 340)", + "dark": "#22111e", + "tonalRamp": [ + "oklch(0.15 0.004 340)", + "oklch(0.26 0.004 340)", + "oklch(0.40 0.004 340)", + "oklch(0.55 0.004 340)", + "oklch(0.70 0.004 340)", + "oklch(0.85 0.004 340)", + "oklch(0.95 0.004 340)", + "oklch(0.99 0.004 340)" + ] + }, + "foreground": { + "role": "neutral", + "displayName": "Plum Ink", + "canonical": "oklch(0.24 0.03 340)", + "dark": "#f5f5f5", + "tonalRamp": [ + "oklch(0.15 0.03 340)", + "oklch(0.24 0.03 340)", + "oklch(0.38 0.03 340)", + "oklch(0.50 0.03 340)", + "oklch(0.62 0.03 340)", + "oklch(0.75 0.03 340)", + "oklch(0.87 0.03 340)", + "oklch(0.95 0.03 340)" + ] + }, + "sidebar": { + "role": "neutral", + "displayName": "Sidebar Layer", + "canonical": "oklch(0.975 0.01 340)", + "dark": "#2c1b29", + "tonalRamp": [ + "oklch(0.15 0.01 340)", + "oklch(0.26 0.01 340)", + "oklch(0.40 0.01 340)", + "oklch(0.55 0.01 340)", + "oklch(0.70 0.01 340)", + "oklch(0.85 0.01 340)", + "oklch(0.94 0.01 340)", + "oklch(0.975 0.01 340)" + ] + }, + "sidebar-accent": { + "role": "neutral", + "displayName": "Sidebar Accent", + "canonical": "oklch(0.94 0.016 340)", + "dark": "#341d2f", + "tonalRamp": [ + "oklch(0.15 0.016 340)", + "oklch(0.26 0.016 340)", + "oklch(0.40 0.016 340)", + "oklch(0.55 0.016 340)", + "oklch(0.70 0.016 340)", + "oklch(0.85 0.016 340)", + "oklch(0.94 0.016 340)", + "oklch(0.98 0.016 340)" + ] + }, + "muted": { + "role": "neutral", + "displayName": "Muted", + "canonical": "oklch(0.96 0.012 340)", + "dark": "#40283a", + "tonalRamp": [ + "oklch(0.15 0.012 340)", + "oklch(0.26 0.012 340)", + "oklch(0.40 0.012 340)", + "oklch(0.55 0.012 340)", + "oklch(0.70 0.012 340)", + "oklch(0.85 0.012 340)", + "oklch(0.96 0.012 340)", + "oklch(0.99 0.012 340)" + ] + }, + "muted-foreground": { + "role": "neutral", + "displayName": "Muted Ink", + "canonical": "oklch(0.5 0.04 340)", + "dark": "#a989a3", + "tonalRamp": [ + "oklch(0.15 0.04 340)", + "oklch(0.26 0.04 340)", + "oklch(0.38 0.04 340)", + "oklch(0.50 0.04 340)", + "oklch(0.62 0.04 340)", + "oklch(0.74 0.04 340)", + "oklch(0.85 0.04 340)", + "oklch(0.95 0.04 340)" + ] + }, + "icon": { + "role": "neutral", + "displayName": "Icon", + "canonical": "oklch(0.55 0.03 340)", + "dark": "#877384", + "tonalRamp": [ + "oklch(0.15 0.03 340)", + "oklch(0.26 0.03 340)", + "oklch(0.40 0.03 340)", + "oklch(0.55 0.03 340)", + "oklch(0.65 0.03 340)", + "oklch(0.75 0.03 340)", + "oklch(0.85 0.03 340)", + "oklch(0.95 0.03 340)" + ] + }, + "border": { + "role": "neutral", + "displayName": "Hairline", + "canonical": "oklch(0.9 0.02 340)", + "dark": "oklch(0.92 0.04 340 / 10%)", + "tonalRamp": [ + "oklch(0.15 0.02 340)", + "oklch(0.26 0.02 340)", + "oklch(0.40 0.02 340)", + "oklch(0.55 0.02 340)", + "oklch(0.70 0.02 340)", + "oklch(0.80 0.02 340)", + "oklch(0.90 0.02 340)", + "oklch(0.97 0.02 340)" + ] + } }, "typographyMeta": { - "headline": { "displayName": "Headline", "purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking." }, - "title": { "displayName": "Title", "purpose": "Thread title, stat values (tabular), memory file path." }, - "body": { "displayName": "Body", "purpose": "Rows, cells, bubbles, dialogs; primary row text at 500." }, - "label": { "displayName": "Label", "purpose": "Uppercase section headings; same size without uppercase for table headers, badges, pills, meta lines." }, - "mono": { "displayName": "Mono", "purpose": "Raw JSON, tool input/result blocks, event tape, ids, the plaintext token." } + "headline": { + "displayName": "Headline", + "purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking." + }, + "title": { + "displayName": "Title", + "purpose": "Thread title, stat values (tabular), memory file path." + }, + "body": { + "displayName": "Body", + "purpose": "Rows, cells, bubbles, dialogs; primary row text at 500." + }, + "label": { + "displayName": "Label", + "purpose": "Uppercase section headings; same size without uppercase for table headers, badges, pills, meta lines." + }, + "mono": { + "displayName": "Mono", + "purpose": "Raw JSON, tool input/result blocks, event tape, ids, the plaintext token." + } }, "shadows": [ - { "name": "login-lift", "value": "0 8px 24px -12px rgb(0 0 0 / 0.25)", "purpose": "The sign-in card only (routes/login/+page.svelte). The single application-layer shadow." }, - { "name": "popover", "value": "shadow-xl plus ring-1 ring-foreground/5 (10% dark)", "purpose": "Luma dialogs, sheets, menus, selects. Overlay surfaces only." } + { + "name": "login-lift", + "value": "0 8px 24px -12px rgb(0 0 0 / 0.25)", + "purpose": "The sign-in card only (routes/login/+page.svelte). The single application-layer shadow." + }, + { + "name": "popover", + "value": "shadow-xl plus ring-1 ring-foreground/5 (10% dark)", + "purpose": "Luma dialogs, sheets, menus, selects. Overlay surfaces only." + } ], "motion": [ - { "name": "pulse-dot", "value": "pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite", "purpose": "Signal-colored halo (0 to 5px, 55% to 0% alpha) on live and running dots." }, - { "name": "rise", "value": "rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both", "purpose": "Opacity 0 to 1 and translateY 3px to 0 when a turn card lands in the activity feed." }, - { "name": "row-hover", "value": "background-color 150ms ease-out", "purpose": "Ink wash at 4% on interactive rows." }, - { "name": "chevron", "value": "transform 150ms", "purpose": "90 degree rotation of expand chevrons in tool nodes, raw entries, memory tree." }, - { "name": "bar-fill", "value": "width 300ms", "purpose": "Utilization bar fill in limit-bar.svelte." }, - { "name": "dialog", "value": "fade-in and zoom-in-95 over 100ms", "purpose": "Luma dialog open and close." }, - { "name": "reduced-motion", "value": "transition-duration and animation-duration 0.01ms under prefers-reduced-motion", "purpose": "Global kill switch in layout.css." } + { + "name": "pulse-dot", + "value": "pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "purpose": "Signal-colored halo (0 to 5px, 55% to 0% alpha) on live and running dots." + }, + { + "name": "rise", + "value": "rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both", + "purpose": "Opacity 0 to 1 and translateY 3px to 0 when a turn card lands in the activity feed." + }, + { + "name": "row-hover", + "value": "background-color 150ms ease-out", + "purpose": "Ink wash at 4% on interactive rows." + }, + { + "name": "chevron", + "value": "transform 150ms", + "purpose": "90 degree rotation of expand chevrons in tool nodes, raw entries, memory tree." + }, + { + "name": "bar-fill", + "value": "width 300ms", + "purpose": "Utilization bar fill in limit-bar.svelte." + }, + { + "name": "dialog", + "value": "fade-in and zoom-in-95 over 100ms", + "purpose": "Luma dialog open and close." + }, + { + "name": "reduced-motion", + "value": "transition-duration and animation-duration 0.01ms under prefers-reduced-motion", + "purpose": "Global kill switch in layout.css." + } ], "breakpoints": [ - { "name": "sm", "value": "640px", "purpose": "Sidebar replaces bottom nav; header padding 24px; hidden row columns return." }, - { "name": "md", "value": "768px", "purpose": "Memory tree and file split side by side." }, - { "name": "lg", "value": "1024px", "purpose": "Thread meta rail (18rem) and two-column dashboard sections." } - ] + { + "name": "sm", + "value": "640px", + "purpose": "Sidebar replaces bottom nav; header padding 24px; hidden row columns return." + }, + { + "name": "md", + "value": "768px", + "purpose": "Memory tree and file split side by side." + }, + { + "name": "lg", + "value": "1024px", + "purpose": "Thread meta rail (18rem) and two-column dashboard sections." + }, + { + "name": "panel-rail", + "value": "768px", + "purpose": "Panel container width, not viewport: below it the switcher bar names the thread, from it the 18rem conversation rail shows." + } + ], + "obsidian": { + "root": ".beaver-root", + "source": "beaver-plugin-obsidian/src/tailwind.css", + "aliases": { + "background": "--background-primary", + "card": "--background-primary-alt", + "popover": "--background-secondary", + "sidebar": "--background-secondary", + "foreground": "--text-normal", + "muted-foreground": "--text-muted", + "icon": "--text-muted", + "status-skip": "--text-faint", + "border": "--background-modifier-border", + "input": "--background-modifier-border", + "muted": "--background-modifier-hover", + "secondary": "--background-modifier-hover", + "accent": "--background-modifier-hover", + "sidebar-accent": "--background-modifier-hover", + "primary": "--color-accent", + "signal": "--color-accent", + "ring": "--color-accent", + "status-new": "--color-accent", + "primary-foreground": "--text-on-accent", + "link": "--link-color, --text-accent", + "destructive": "--text-error", + "font-sans": "--font-interface", + "font-mono": "--font-monospace" + }, + "radii": { + "button, select-trigger": "--button-radius", + "input, tabs-trigger, menu items": "--input-radius", + "tabs-list, skeleton, dialog, popover, menus": "--radius-m (8px)", + "rows and notes": "--radius 0.45rem" + }, + "overlays": { + "border": "1px var(--border)", + "shadow": "var(--shadow-s)", + "menuBackground": "var(--popover), opaque, no backdrop", + "portal": ".beaver-root.beaver-portal on body at --layer-menu (65)" + }, + "tabs": { + "list": "color-mix(in oklab, var(--text-normal) 6%, transparent)", + "active": "color-mix(in oklab, var(--text-normal) 14%, transparent)" + } + } }, "components": [ { @@ -153,12 +551,51 @@ "Light and dark modes, both tinted toward hue 340; dark borders are 10% alpha overlays" ], "rules": [ - { "name": "The Pink Ration Rule", "body": "signal marks live state (pulsing dots, running rows, busy sessions, the running count) and selection (active mobile nav, master kind). It never decorates a heading, a button, or a static surface. Hover is a 4% ink wash, not pink.", "section": "colors" }, - { "name": "The Twelve Percent Rule", "body": "Every colored chip is its hue at 12% alpha behind full-strength text of the same hue (kind-badge.svelte, sidebar running count). Solid colored chips do not exist.", "section": "colors" }, - { "name": "The Two Layers Rule", "body": "Navigation and rails sit on sidebar (or sidebar/50); content sits on background. There is no third surface color; depth beyond that is a hairline.", "section": "colors" }, - { "name": "The Tabular Rule", "body": "Every number - durations, token counts, money, percentages, timestamps, ids, byte sizes - carries the tabular utility (font-variant-numeric: tabular-nums, layout.css). Numeric table columns are right-aligned.", "section": "typography" }, - { "name": "The One Heading Rule", "body": "A page has one headline (the header) and any number of uppercase 12px section labels; there is no intermediate heading size. Section labels are h2 elements, not decorative kickers.", "section": "typography" }, - { "name": "The Flat Ledger Rule", "body": "Nothing inside the main column casts a shadow. Overlays (dialog, sheet, menu) and the login card are the only lifted surfaces.", "section": "elevation" } + { + "name": "The Pink Ration Rule", + "body": "signal marks live state (pulsing dots, running rows, busy sessions, the running count) and selection (active mobile nav, master kind). It never decorates a heading, a button, or a static surface. Hover is a 4% ink wash, not pink.", + "section": "colors" + }, + { + "name": "The Twelve Percent Rule", + "body": "Every colored chip is its hue at 12% alpha behind full-strength text of the same hue (kind-badge.svelte, sidebar running count). Solid colored chips do not exist.", + "section": "colors" + }, + { + "name": "The Two Layers Rule", + "body": "Navigation and rails sit on sidebar (or sidebar/50); content sits on background. There is no third surface color; depth beyond that is a hairline.", + "section": "colors" + }, + { + "name": "The Tabular Rule", + "body": "Every number - durations, token counts, money, percentages, timestamps, ids, byte sizes - carries the tabular utility (font-variant-numeric: tabular-nums, layout.css). Numeric table columns are right-aligned.", + "section": "typography" + }, + { + "name": "The One Heading Rule", + "body": "A page has one headline (the header) and any number of uppercase 12px section labels; there is no intermediate heading size. Section labels are h2 elements, not decorative kickers.", + "section": "typography" + }, + { + "name": "The Flat Ledger Rule", + "body": "Nothing inside the main column casts a shadow. Overlays (dialog, sheet, menu) and the login card are the only lifted surfaces.", + "section": "elevation" + }, + { + "name": "The Host Accent Rule", + "body": "Inside Obsidian signal and primary are one variable. Live dots, running rows, the running count, the selected row, the primary button and the focus ring all take the user's accent; the Pink Ration Rule becomes an accent ration with the same two meanings, live and selected.", + "section": "obsidian" + }, + { + "name": "The Borrowed Corner Rule", + "body": "No control chooses its own radius. Rows and notes use --radius; luma primitives are re-rounded to Obsidian's --button-radius, --input-radius and --radius-m so the panel sits beside the app's own settings and menus without a seam.", + "section": "obsidian" + }, + { + "name": "The Alias Only Rule", + "body": "Panel components use token aliases (bg-primary/5, text-link, border-b) and the panel.css utilities. A literal colour, font or radius in ui/src/lib/panel/ breaks the Obsidian mapping.", + "section": "obsidian" + } ], "dos": [ "Do build lists as ledger-grid rows with border-b and a per-row column template; use a native table when there are more than four numeric columns.", @@ -170,7 +607,10 @@ "Do put secondary surfaces (rails, trees) on bg-sidebar/50 with a hairline, not on a card.", "Do show every error as error-note.svelte with a retry and every empty list as empty-state.svelte with a one-line hint.", "Do respect prefers-reduced-motion; new motion must be a CSS transition or one of animate-pulse-dot and animate-rise.", - "Do keep the luma primitives as shipped; compose them with size=\"sm\" and variant=\"outline\" or \"ghost\" for in-page actions." + "Do keep the luma primitives as shipped; compose them with size=\"sm\" and variant=\"outline\" or \"ghost\" for in-page actions.", + "Do write panel components (ui/src/lib/panel/) against token aliases and panel.css utilities only, and check them in both the admin and the plugin.", + "Do decide panel layout from the panel's own width (@container, bind:clientWidth); 768px is where the rail replaces the switcher.", + "Do add conversation actions to actions.svelte.ts so the header menu and the row menu stay one list." ], "donts": [ "Don't put a card inside a card, or wrap ledger rows in a bordered container.", @@ -179,7 +619,10 @@ "Don't introduce a heading size between the page headline (1rem) and the section label (0.75rem uppercase).", "Don't render solid colored chips; chips are hue/12 behind hue text.", "Don't use hue-chip, hairline, touch-shown or scrollbar-none from layout.css as if they were system rules - they are defined and currently unused by the admin.", - "Don't apply this palette inside Obsidian; the panel takes Obsidian's variables (PRODUCT.md brand commitment)." + "Don't apply this palette inside Obsidian; the panel takes Obsidian's variables (PRODUCT.md brand commitment).", + "Don't expect pills inside Obsidian; every radius there is the host's (--button-radius, --input-radius, --radius-m).", + "Don't mark an active tab with bg-background in panel code; the plugin overrides it to ink at 14%.", + "Don't let a menu or dialog render inside the sidedock; overlays mount in .beaver-root.beaver-portal at --layer-menu." ] } } diff --git a/.impeccable/surfaces/beaver-gateway-ui-src-lib-panel.md b/.impeccable/surfaces/beaver-gateway-ui-src-lib-panel.md new file mode 100644 index 0000000..9b66e93 --- /dev/null +++ b/.impeccable/surfaces/beaver-gateway-ui-src-lib-panel.md @@ -0,0 +1,13 @@ +--- +version: 1 +slug: "beaver-gateway-ui-src-lib-panel" +primary_target: "beaver-gateway/ui/src/lib/panel" +related_targets: ["beaver-gateway/ui/src/routes/panel","beaver-plugin-obsidian/src/ui"] +--- + +Scope: the Beaver panel as an Obsidian view (sidedock column, tab, mobile) and its browser twin at /admin/panel. Mode: Operate. +Audience and job: the operator (Kit) watching and steering the agent next to their notes; the task is to see what runs, answer, branch, send a branch back to Telegram, close a deep chat. +Constraints: shared components in ui/src/lib/panel, bundled into the plugin by esbuild ($lib alias, one Svelte runtime, svelte-sonner shimmed to Notice); container queries, never viewport breakpoints, because the same tree lives in a 300px column and a 1400px tab; Obsidian's variables only through .beaver-root (beaver-calendar is the reference); nothing pink inside Obsidian. +Direction: the agent's terminal, thread always on screen; below 48rem the switcher popover in the header, above it the grouped rail (master, branches, deep chats, jobs) on the left; one action list (actions.svelte.ts) behind the header menu and the row context menu; markdown through Obsidian's renderer with [[links]] opening notes. +Memorable moment: the panel follows the note you open and the running turn's tool tree grows in place. +Unresolved: a "new conversation" affordance in the panel; keyboard navigation beyond ↑/↓ and /; the pinned one-line summary when a branch returns to Telegram (§8.2) is not produced by the gateway yet. diff --git a/src/beaver_gateway/frontends/api/frontend.py b/src/beaver_gateway/frontends/api/frontend.py index 43dedbf..c2a97f1 100644 --- a/src/beaver_gateway/frontends/api/frontend.py +++ b/src/beaver_gateway/frontends/api/frontend.py @@ -497,14 +497,20 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa conv = await conv_of(public_id) data = await body_of(request) frontend = text_of(data, "frontend") - external_id = text_of(data, "external_id") + external_id = data.get("external_id") try: - await conversations.bind( - conv, - frontend=frontend, - external_id=external_id, - visible=bool(data.get("visible", True)), - ) + if isinstance(external_id, str) and external_id.strip(): + await conversations.bind( + conv, + frontend=frontend, + external_id=external_id, + visible=bool(data.get("visible", True)), + ) + elif await conversations.frontend(frontend).materialize(conv) is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"frontend {frontend!r} cannot open a window for {conv.kind!r}", + ) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc return await conversations.describe(conv) diff --git a/src/beaver_gateway/frontends/telegram/inbox.py b/src/beaver_gateway/frontends/telegram/inbox.py index 2a1141a..857e18a 100644 --- a/src/beaver_gateway/frontends/telegram/inbox.py +++ b/src/beaver_gateway/frontends/telegram/inbox.py @@ -35,6 +35,10 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram.inbox") ALLOWED_UPDATES = ("message", "callback_query") +def _unset(_value: object) -> None: + """Unset aiogram ``Default`` sentinels (link previews) are stored as null.""" + + class Inbox: def __init__( self, @@ -85,7 +89,9 @@ class Inbox: async def _store(self, update: Update) -> None: row = TelegramUpdate( update_id=update.update_id, - payload=update.model_dump(mode="json", by_alias=True, exclude_none=True), + payload=update.model_dump( + mode="json", by_alias=True, exclude_none=True, fallback=_unset + ), ) async with self._db.session() as session: session.add(row) diff --git a/tests/test_api.py b/tests/test_api.py index 4036562..eb645d2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -448,3 +448,45 @@ async def test_close_distills_a_deep_chat(world: World) -> None: f"/conversations/{branch.external_id}/close", headers=HEADERS ) assert plain.status_code == 200 and plain.json()["status"] == "closed" + + +async def test_bind_without_a_window_materializes_one(world: World) -> None: + api = Api(world) + chat = await world.conversations.create(kind="deep", agent="d", origin="test") + world.markdown.materialized.clear() + res = await api.http.post( + f"/conversations/{chat.external_id}/bind", + json={"frontend": "markdown"}, + headers=HEADERS, + ) + assert res.status_code == 200, res.text + assert world.markdown.materialized == [chat.external_id] + assert res.json()["bindings"] == [ + { + "frontend": "markdown", + "external_id": f"markdown:{chat.external_id}", + "visible": True, + } + ] + hidden = await api.http.post( + f"/conversations/{chat.external_id}/bind", + json={ + "frontend": "markdown", + "external_id": f"markdown:{chat.external_id}", + "visible": False, + }, + headers=HEADERS, + ) + assert hidden.json()["bindings"][0]["visible"] is False + no_window = await api.http.post( + f"/conversations/{chat.external_id}/bind", + json={"frontend": "api"}, + headers=HEADERS, + ) + assert no_window.status_code == 400 + unknown = await api.http.post( + f"/conversations/{chat.external_id}/bind", + json={"frontend": "nope"}, + headers=HEADERS, + ) + assert unknown.status_code == 400 diff --git a/tests/test_telegram.py b/tests/test_telegram.py index fe68bc9..3e8d99b 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -593,3 +593,24 @@ def test_render_helpers() -> None: assert status_label("Read", {"file_path": "/x"}) == "читаю vault" assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts" assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la" + + +async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack) -> None: + stack.bot.push( + { + "message": { + "message_id": 77, + "date": 1700000000, + "chat": {"id": USER, "type": "private"}, + "from": {"id": USER, "is_bot": False, "first_name": "h"}, + "text": "see https://x.y", + "link_preview_options": {"url": "https://x.y"}, + } + } + ) + await stack.until(lambda: stack.sent_with("see https://x.y"), what="reply") + async with stack.world.db.session() as session: + rows = list((await session.exec(select(TelegramUpdate))).all()) + preview = rows[0].payload["message"]["link_preview_options"] + assert preview["url"] == "https://x.y" + assert preview.get("is_disabled") is None diff --git a/ui/bun.lock b/ui/bun.lock index 8aa08a0..b8fb42d 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -12,7 +12,7 @@ "@biomejs/biome": "2.5.9", "@fontsource-variable/inter": "^5.3.0", "@internationalized/date": "^3.12.3", - "@lucide/svelte": "^1.35.0", + "@lucide/svelte": "^1.37.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", @@ -88,7 +88,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lucide/svelte": ["@lucide/svelte@1.35.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-1I5WeiEFc21HOLv/GR11vuN3MqN/k/mhCEMW7nEtwSAGOcZ0C5ZgI1Y2Rvs/egfKTUiOTw+R9Ys9BJ+GwAKSWw=="], + "@lucide/svelte": ["@lucide/svelte@1.37.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-AxaiSxeuErlt4fNmmN28oaL5R7yVoQLsfSeiAONxCEK+l0/SrLAjRktQ0DkTQpomv1oRzOA3hohWY0ZHTZCMmw=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], diff --git a/ui/package.json b/ui/package.json index 9f9854d..ded077d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,7 +21,7 @@ "@biomejs/biome": "2.5.9", "@fontsource-variable/inter": "^5.3.0", "@internationalized/date": "^3.12.3", - "@lucide/svelte": "^1.35.0", + "@lucide/svelte": "^1.37.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 40053bd..5c0c41a 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -245,14 +245,15 @@ export class ApiClient { return this.post(`/api/conversations/${id}/close`); } + // Without ``externalId`` the frontend opens a fresh window (a new topic). bind( id: string, frontend: string, - externalId: string, + externalId: string | null, visible: boolean ): Promise { return this.post(`/api/conversations/${id}/bind`, { - external_id: externalId, + external_id: externalId ?? undefined, frontend, visible, }); diff --git a/ui/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte b/ui/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte new file mode 100644 index 0000000..f1fa154 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte @@ -0,0 +1,41 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-content.svelte b/ui/src/lib/components/ui/context-menu/context-menu-content.svelte new file mode 100644 index 0000000..d05abed --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-content.svelte @@ -0,0 +1,30 @@ + + + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-group-heading.svelte b/ui/src/lib/components/ui/context-menu/context-menu-group-heading.svelte new file mode 100644 index 0000000..8c7e65d --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-group-heading.svelte @@ -0,0 +1,21 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-group.svelte b/ui/src/lib/components/ui/context-menu/context-menu-group.svelte new file mode 100644 index 0000000..7b769f3 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-group.svelte @@ -0,0 +1,12 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-item.svelte b/ui/src/lib/components/ui/context-menu/context-menu-item.svelte new file mode 100644 index 0000000..494ec83 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-label.svelte b/ui/src/lib/components/ui/context-menu/context-menu-label.svelte new file mode 100644 index 0000000..3973a69 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/context-menu/context-menu-portal.svelte b/ui/src/lib/components/ui/context-menu/context-menu-portal.svelte new file mode 100644 index 0000000..8674d44 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-radio-group.svelte b/ui/src/lib/components/ui/context-menu/context-menu-radio-group.svelte new file mode 100644 index 0000000..f043063 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-radio-item.svelte b/ui/src/lib/components/ui/context-menu/context-menu-radio-item.svelte new file mode 100644 index 0000000..f8ed7db --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-radio-item.svelte @@ -0,0 +1,35 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-separator.svelte b/ui/src/lib/components/ui/context-menu/context-menu-separator.svelte new file mode 100644 index 0000000..a3f13cf --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-shortcut.svelte b/ui/src/lib/components/ui/context-menu/context-menu-shortcut.svelte new file mode 100644 index 0000000..a248f64 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte new file mode 100644 index 0000000..c0bdb02 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte b/ui/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte new file mode 100644 index 0000000..3823930 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-sub.svelte b/ui/src/lib/components/ui/context-menu/context-menu-sub.svelte new file mode 100644 index 0000000..ddd0575 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-sub.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu-trigger.svelte b/ui/src/lib/components/ui/context-menu/context-menu-trigger.svelte new file mode 100644 index 0000000..e43428d --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/context-menu.svelte b/ui/src/lib/components/ui/context-menu/context-menu.svelte new file mode 100644 index 0000000..c24489e --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/context-menu.svelte @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/lib/components/ui/context-menu/index.ts b/ui/src/lib/components/ui/context-menu/index.ts new file mode 100644 index 0000000..a85dc96 --- /dev/null +++ b/ui/src/lib/components/ui/context-menu/index.ts @@ -0,0 +1,52 @@ +import CheckboxItem from "./context-menu-checkbox-item.svelte"; +import Content from "./context-menu-content.svelte"; +import GroupHeading from "./context-menu-group-heading.svelte"; +import Group from "./context-menu-group.svelte"; +import Item from "./context-menu-item.svelte"; +import Label from "./context-menu-label.svelte"; +import Portal from "./context-menu-portal.svelte"; +import RadioGroup from "./context-menu-radio-group.svelte"; +import RadioItem from "./context-menu-radio-item.svelte"; +import Separator from "./context-menu-separator.svelte"; +import Shortcut from "./context-menu-shortcut.svelte"; +import SubContent from "./context-menu-sub-content.svelte"; +import SubTrigger from "./context-menu-sub-trigger.svelte"; +import Sub from "./context-menu-sub.svelte"; +import Trigger from "./context-menu-trigger.svelte"; +import Root from "./context-menu.svelte"; + +export { + Root, + Sub, + Portal, + Item, + GroupHeading, + Label, + Group, + Trigger, + Content, + Shortcut, + Separator, + RadioItem, + SubContent, + SubTrigger, + RadioGroup, + CheckboxItem, + // + Root as ContextMenu, + Sub as ContextMenuSub, + Portal as ContextMenuPortal, + Item as ContextMenuItem, + GroupHeading as ContextMenuGroupHeading, + Group as ContextMenuGroup, + Content as ContextMenuContent, + Trigger as ContextMenuTrigger, + Shortcut as ContextMenuShortcut, + RadioItem as ContextMenuRadioItem, + Separator as ContextMenuSeparator, + RadioGroup as ContextMenuRadioGroup, + SubContent as ContextMenuSubContent, + SubTrigger as ContextMenuSubTrigger, + CheckboxItem as ContextMenuCheckboxItem, + Label as ContextMenuLabel, +}; diff --git a/ui/src/lib/components/ui/popover/index.ts b/ui/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..e074cee --- /dev/null +++ b/ui/src/lib/components/ui/popover/index.ts @@ -0,0 +1,28 @@ +import Close from "./popover-close.svelte"; +import Content from "./popover-content.svelte"; +import Description from "./popover-description.svelte"; +import Header from "./popover-header.svelte"; +import Portal from "./popover-portal.svelte"; +import Title from "./popover-title.svelte"; +import Trigger from "./popover-trigger.svelte"; +import Root from "./popover.svelte"; + +export { + Root, + Content, + Description, + Header, + Title, + Trigger, + Close, + Portal, + // + Root as Popover, + Content as PopoverContent, + Description as PopoverDescription, + Header as PopoverHeader, + Title as PopoverTitle, + Trigger as PopoverTrigger, + Close as PopoverClose, + Portal as PopoverPortal, +}; diff --git a/ui/src/lib/components/ui/popover/popover-close.svelte b/ui/src/lib/components/ui/popover/popover-close.svelte new file mode 100644 index 0000000..6f57946 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/components/ui/popover/popover-content.svelte b/ui/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..aef2600 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/ui/src/lib/components/ui/popover/popover-description.svelte b/ui/src/lib/components/ui/popover/popover-description.svelte new file mode 100644 index 0000000..751d73b --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-description.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/popover/popover-header.svelte b/ui/src/lib/components/ui/popover/popover-header.svelte new file mode 100644 index 0000000..90dd63d --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/popover/popover-portal.svelte b/ui/src/lib/components/ui/popover/popover-portal.svelte new file mode 100644 index 0000000..8115718 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/popover/popover-title.svelte b/ui/src/lib/components/ui/popover/popover-title.svelte new file mode 100644 index 0000000..17646c3 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/popover/popover-trigger.svelte b/ui/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..f50b897 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/popover/popover.svelte b/ui/src/lib/components/ui/popover/popover.svelte new file mode 100644 index 0000000..891ef1e --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/gateway.svelte.ts b/ui/src/lib/gateway.svelte.ts index a6f1d5d..d003e77 100644 --- a/ui/src/lib/gateway.svelte.ts +++ b/ui/src/lib/gateway.svelte.ts @@ -1,124 +1,54 @@ -import { LiveStream } from "./api/live.svelte"; -import type { - BusEvent, - ConversationSummary, - LimitsResponse, - LimitWindow, -} from "./api/types"; +import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types"; +import { ConversationIndex } from "./panel/index.svelte"; import { session } from "./session.svelte"; const TAPE_SIZE = 120; -const RELOAD_DEBOUNCE_MS = 1500; const QUIET = new Set(["stream", "hello"]); -// Gateway-wide live state: the conversation index kept fresh by -// ``/api/events``, the last non-chatty events as a tape, and the quota -// windows updated the moment the SDK reports them. -class Gateway { - conversations = $state([]); +// Gateway-wide live state for the admin: the conversation index, the last +// non-chatty events as a tape, and the quota windows updated the moment +// the SDK reports them. +class Gateway extends ConversationIndex { tape = $state([]); limits = $state(null); - loaded = $state(false); - readonly live = new LiveStream(() => session.client, "/api/events", { - onEvent: (event) => this.apply(event), - prepare: () => this.load(), - }); - get running(): ConversationSummary[] { - return this.conversations.filter((row) => row.running_turn); + constructor() { + super(() => session.client); } - get open(): ConversationSummary[] { - return this.conversations.filter((row) => row.status === "open"); - } - - byId(id: string): ConversationSummary | undefined { - return this.conversations.find((row) => row.id === id); - } - - async load(): Promise { - const { client } = session; + override async load(): Promise { + const client = this.client(); if (!client) { return; } - const [list, limits] = await Promise.all([ - client.conversations({ limit: 500 }), + const [, limits] = await Promise.all([ + super.load(), client.limits().catch(() => null), ]); - this.conversations = list.conversations; if (limits) { this.limits = limits; } - this.loaded = true; } async refreshLimits(): Promise { - const { client } = session; + const client = this.client(); if (client) { this.limits = await client.limits(); } } - private upsert(row: ConversationSummary): void { - const index = this.conversations.findIndex((c) => c.id === row.id); - if (index >= 0) { - this.conversations[index] = { ...this.conversations[index], ...row }; - } else { - this.conversations.unshift(row); - } - } - - apply(event: BusEvent): void { + override apply(event: BusEvent): void { if (!QUIET.has(event.type)) { this.tape.unshift(event); if (this.tape.length > TAPE_SIZE) { this.tape.length = TAPE_SIZE; } } - switch (event.type) { - case "conversation.created": - case "conversation.updated": { - if (typeof event.id === "string") { - this.upsert(event as unknown as ConversationSummary); - } - return; - } - case "turn.start": - case "turn.end": { - const row = event.conversation_id - ? this.byId(event.conversation_id) - : undefined; - if (row) { - row.running_turn = - event.type === "turn.start" ? (event.turn_id ?? null) : null; - row.last_activity_at = event.ts; - } - return; - } - case "rate_limit": { - this.applyLimit(event); - return; - } - case "message.queued": - case "inject.queued": - case "reply": { - this.reloadSoon(); - return; - } - default: - } - } - - private reloadTimer: ReturnType | null = null; - - private reloadSoon(): void { - if (this.reloadTimer) { + if (event.type === "rate_limit") { + this.applyLimit(event); return; } - this.reloadTimer = setTimeout(() => { - this.reloadTimer = null; - this.load().catch(() => undefined); - }, RELOAD_DEBOUNCE_MS); + super.apply(event); } private applyLimit(event: BusEvent): void { @@ -140,14 +70,6 @@ class Gateway { this.refreshLimits().catch(() => undefined); } } - - start(): void { - this.live.start(); - } - - stop(): void { - this.live.stop(); - } } export const gateway = new Gateway(); diff --git a/ui/src/lib/panel/actions.svelte.ts b/ui/src/lib/panel/actions.svelte.ts new file mode 100644 index 0000000..cb1a971 --- /dev/null +++ b/ui/src/lib/panel/actions.svelte.ts @@ -0,0 +1,224 @@ +import ArchiveIcon from "@lucide/svelte/icons/archive"; +import ArrowRightIcon from "@lucide/svelte/icons/arrow-right"; +import BookCheckIcon from "@lucide/svelte/icons/book-check"; +import BrainIcon from "@lucide/svelte/icons/brain"; +import CircleXIcon from "@lucide/svelte/icons/circle-x"; +import CopyIcon from "@lucide/svelte/icons/copy"; +import EyeOffIcon from "@lucide/svelte/icons/eye-off"; +import FileTextIcon from "@lucide/svelte/icons/file-text"; +import GitBranchIcon from "@lucide/svelte/icons/git-branch"; +import GitMergeIcon from "@lucide/svelte/icons/git-merge"; +import PencilIcon from "@lucide/svelte/icons/pencil"; +import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; +import SendIcon from "@lucide/svelte/icons/send"; +import type { Component } from "svelte"; +import { toast } from "svelte-sonner"; +import type { ApiClient } from "$lib/api/client"; +import type { ConversationInfo } from "$lib/api/types"; +import type { PanelHost } from "./host"; + +export interface MenuAction { + // Present: rendered as a checkbox item with this state. + checked?: boolean; + danger?: boolean; + disabled?: boolean; + // A separator sits above this action. + gap?: boolean; + icon?: Component<{ class?: string }>; + label: string; + run: () => void | Promise; +} + +export interface ActionScope { + client: ApiClient; + // The conversation is the one on screen, so "Open" is pointless. + current: boolean; + host: PanelHost; + onBranch: () => void; + onChanged: () => void; + onOpen: (id: string) => void; + onRename: () => void; +} + +const TELEGRAM = "telegram"; +const MARKDOWN = "markdown"; + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function attempt( + action: () => Promise, + done: string | null +): Promise { + try { + await action(); + if (done) { + toast.success(done); + } + } catch (error) { + toast.error(describe(error)); + } +} + +function copyId(id: string): void { + navigator.clipboard + .writeText(id) + .then(() => toast.success("Id copied")) + .catch(() => toast.error("Clipboard is not available")); +} + +// The one list of things you can do to a conversation, in the order the +// header menu and the row's context menu both show them. +export function conversationActions( + info: ConversationInfo, + scope: ActionScope +): MenuAction[] { + const { client, host } = scope; + const open = info.status === "open"; + const telegram = info.bindings.find((b) => b.frontend === TELEGRAM); + const note = info.bindings.find((b) => b.frontend === MARKDOWN && b.visible); + const remembers = info.flags.memory !== false; + const changed = () => scope.onChanged(); + const actions: MenuAction[] = []; + + if (!scope.current) { + actions.push({ + icon: ArrowRightIcon, + label: "Open", + run: () => scope.onOpen(info.id), + }); + } + if (note && host.openNote) { + actions.push({ + icon: FileTextIcon, + label: "Open note", + run: () => host.openNote?.(note.external_id), + }); + } + actions.push({ + disabled: !open, + gap: actions.length > 0, + icon: GitBranchIcon, + label: "Branch…", + run: scope.onBranch, + }); + actions.push({ + checked: remembers, + gap: true, + icon: BrainIcon, + label: "Remember on close", + run: () => + attempt( + async () => { + await client.setFlags(info.id, { memory: !remembers }); + changed(); + }, + remembers + ? "Memory off: closing only merges" + : "Memory on: closing writes a digest" + ), + }); + if (info.kind === "branch") { + if (telegram?.visible) { + actions.push({ + icon: EyeOffIcon, + label: "Hide from Telegram", + run: () => + attempt(async () => { + await client.bind(info.id, TELEGRAM, telegram.external_id, false); + changed(); + }, "Hidden from Telegram"), + }); + } else { + actions.push({ + icon: SendIcon, + label: "Return to Telegram", + run: () => + attempt(async () => { + await client.bind( + info.id, + TELEGRAM, + telegram?.external_id ?? null, + true + ); + changed(); + }, "Back in Telegram"), + }); + } + } + actions.push({ + gap: true, + icon: PencilIcon, + label: "Rename…", + run: scope.onRename, + }); + actions.push({ + icon: CopyIcon, + label: "Copy id", + run: () => copyId(info.id), + }); + if (info.parent && open) { + actions.push({ + gap: true, + icon: GitMergeIcon, + label: "Merge into parent", + run: () => + attempt(async () => { + await client.merge(info.id); + changed(); + }, "Merged into the parent"), + }); + } + if (info.kind === "deep" && open) { + actions.push({ + gap: !(info.parent && open), + icon: BookCheckIcon, + label: "Close with digest", + run: () => + attempt(async () => { + const result = await client.close(info.id); + changed(); + if (result.error) { + toast.warning(`Digest: ${result.error}`); + } else if (result.digest) { + toast.message(`Digest: ${result.digest}`); + } + }, "Chat closed"), + }); + } + if (open) { + actions.push({ + gap: !(info.parent || info.kind === "deep"), + icon: CircleXIcon, + label: "Close", + run: () => + attempt(async () => { + await client.update(info.id, { status: "closed" }); + changed(); + }, "Closed"), + }); + actions.push({ + danger: true, + icon: ArchiveIcon, + label: "Archive", + run: () => + attempt(async () => { + await client.update(info.id, { status: "archived" }); + changed(); + }, "Archived"), + }); + } else { + actions.push({ + gap: true, + icon: RotateCcwIcon, + label: "Reopen", + run: () => + attempt(async () => { + await client.update(info.id, { status: "open" }); + changed(); + }, "Reopened"), + }); + } + return actions; +} diff --git a/ui/src/lib/panel/branch-dialog.svelte b/ui/src/lib/panel/branch-dialog.svelte new file mode 100644 index 0000000..8b0e814 --- /dev/null +++ b/ui/src/lib/panel/branch-dialog.svelte @@ -0,0 +1,131 @@ + + + + + + Branch off {parentName} + + A branch gets its own window and session and ends with a merge back into + this thread. + + +
+
+ + { + seed = value; + }} + type="single" + value={seed} + > + + {current.label} + - {current.hint} + + + {#each SEEDS as option (option.value)} + + + {option.label} + + {option.hint} + + + + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + +
+
diff --git a/ui/src/lib/panel/chat-view.svelte b/ui/src/lib/panel/chat-view.svelte index 4bb87fd..cc0252e 100644 --- a/ui/src/lib/panel/chat-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -124,7 +124,7 @@
{ pinned = nearBottom(); }} diff --git a/ui/src/lib/panel/composer.svelte b/ui/src/lib/panel/composer.svelte index 55bd49b..68506bd 100644 --- a/ui/src/lib/panel/composer.svelte +++ b/ui/src/lib/panel/composer.svelte @@ -111,7 +111,7 @@ {/each} -
diff --git a/ui/src/lib/panel/conversation-header.svelte b/ui/src/lib/panel/conversation-header.svelte index ab2c5c3..191ecee 100644 --- a/ui/src/lib/panel/conversation-header.svelte +++ b/ui/src/lib/panel/conversation-header.svelte @@ -1,8 +1,5 @@ -
+
- -

- {info.title || `${info.kind} ${shortId(info.id)}`} -

+ {#if showTitle} + +

+ {info.title || `${info.kind} ${shortId(info.id)}`} +

+ {/if} {#if info.pending_question} question pending {/if}
- +
+
- {info.agent} - via {info.origin} + + {#if info.parent} - - parent {shortId(info.parent)} - + {#if href} + + parent {shortId(info.parent)} + + {:else} + + {/if} {/if} - {shortId(info.id)} - + + @@ -225,7 +131,7 @@
{#if windows.length > 0}
{#each windows as window (window.frontend + window.external_id)} @@ -237,97 +143,4 @@ {/if}
- - - - Branch off this conversation - - A branch gets its own window and session and ends with a merge back into - this thread. - - -
-
- - { - seed = value; - }} - type="single" - value={seed} - > - - {SEEDS.find((s) => s.value === seed)?.label} - - - {#each SEEDS as option (option.value)} - - {/each} - - -
-
- - -
-
- - -
-
- - - - -
-
- - - - - Rename - - - - - - - - + diff --git a/ui/src/lib/panel/conversation-menu.svelte b/ui/src/lib/panel/conversation-menu.svelte new file mode 100644 index 0000000..6102613 --- /dev/null +++ b/ui/src/lib/panel/conversation-menu.svelte @@ -0,0 +1,132 @@ + + +{#snippet body(kit: MenuKit)} + {#if loaded} + + {:else if failure} +

{failure}

+ {:else} +
+ + + +
+ {/if} +{/snippet} + +{#if mode === "context"} + + + {@render children?.()} + + + {@render body(ContextMenu)} + + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + {@render body(DropdownMenu)} + + +{/if} + +{#if loaded} + + +{/if} diff --git a/ui/src/lib/panel/conversation-picker.svelte b/ui/src/lib/panel/conversation-picker.svelte new file mode 100644 index 0000000..95c433f --- /dev/null +++ b/ui/src/lib/panel/conversation-picker.svelte @@ -0,0 +1,219 @@ + + + +
+
+ + + {#if showFollow} + + {/if} +
+
+ {#if index.live.state === "failed"} +
+ index.start()} + /> +
+ {:else if !index.loaded} +
+ + + +
+ {:else if rows.length === 0} +
+ +
+ {:else} + {#each groups as group (group.kind)} +
+

+ {group.label} + + {group.rows.length} + +

+
    + {#each group.rows as row (row.id)} +
  • + +
  • + {/each} +
+
+ {/each} + {/if} +
+
diff --git a/ui/src/lib/panel/conversation-row.svelte b/ui/src/lib/panel/conversation-row.svelte new file mode 100644 index 0000000..0b09583 --- /dev/null +++ b/ui/src/lib/panel/conversation-row.svelte @@ -0,0 +1,91 @@ + + + +
+ + +
+
diff --git a/ui/src/lib/panel/conversation-view.svelte b/ui/src/lib/panel/conversation-view.svelte index 43a5f14..5ca9f7e 100644 --- a/ui/src/lib/panel/conversation-view.svelte +++ b/ui/src/lib/panel/conversation-view.svelte @@ -16,13 +16,16 @@ let { client, id, - href, + href = null, onOpen, + showTitle = true, }: { client: ApiClient; id: string; - href: (id: string) => string; + href?: ((id: string) => string) | null; onOpen: (id: string) => void; + // Off when a switcher above the thread already names it. + showTitle?: boolean; } = $props(); const feed = new ConversationFeed( @@ -54,7 +57,7 @@ }); -
+
{#if feed.error && !info}
{:else if !info} @@ -71,15 +74,18 @@ liveState={feed.live.state} onChanged={refresh} {onOpen} + {showTitle} />
- - Chat - Activity - Raw - Meta - +
+ + Chat + Activity + Raw + Meta + +
{@render meta()} diff --git a/ui/src/lib/panel/host.ts b/ui/src/lib/panel/host.ts new file mode 100644 index 0000000..ae02c80 --- /dev/null +++ b/ui/src/lib/panel/host.ts @@ -0,0 +1,51 @@ +import { getContext, setContext } from "svelte"; + +export type LinkKind = "internal" | "external"; + +// What the panel needs from whoever mounts it. The browser admin renders +// markdown itself and hands [[links]] to the obsidian:// scheme; the +// Obsidian plugin renders through MarkdownRenderer and opens notes in +// place. Anything the host leaves undefined falls back to the browser way. +export interface PanelHost { + // Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify. + markdown?: (node: HTMLElement, text: string) => (() => void) | undefined; + name: "browser" | "obsidian"; + openLink: (target: string, kind: LinkKind) => void; + // Opens the note a conversation lives in (its markdown binding). + openNote?: (path: string) => void; + // Hover-less device: row actions stay visible instead of appearing on hover. + touch: boolean; +} + +const KEY = Symbol("beaver-panel-host"); + +export function providePanelHost(host: PanelHost): PanelHost { + setContext(KEY, host); + return host; +} + +export function usePanelHost(): PanelHost { + return getContext(KEY) ?? browserHost(); +} + +let browser: PanelHost | null = null; + +export function browserHost(): PanelHost { + if (browser) { + return browser; + } + browser = { + name: "browser", + openLink(target, kind) { + if (kind === "internal") { + window.open(`obsidian://open?file=${encodeURIComponent(target)}`); + return; + } + window.open(target, "_blank", "noopener"); + }, + touch: + typeof window !== "undefined" && + window.matchMedia("(hover: none)").matches, + }; + return browser; +} diff --git a/ui/src/lib/panel/index.svelte.ts b/ui/src/lib/panel/index.svelte.ts new file mode 100644 index 0000000..39af635 --- /dev/null +++ b/ui/src/lib/panel/index.svelte.ts @@ -0,0 +1,106 @@ +import type { ApiClient } from "$lib/api/client"; +import { LiveStream } from "$lib/api/live.svelte"; +import type { BusEvent, ConversationSummary } from "$lib/api/types"; + +const RELOAD_DEBOUNCE_MS = 1500; +const PAGE = 500; + +// The conversation index kept fresh by ``/api/events``: rows land as +// ``conversation.*`` events arrive, turn markers flip ``running_turn`` +// without a refetch, and anything that moves the queue reloads once. +// Shared by the admin's rail and the panel's picker. +export class ConversationIndex { + conversations = $state([]); + loaded = $state(false); + readonly live: LiveStream; + protected readonly client: () => ApiClient | null; + private reloadTimer: ReturnType | null = null; + + constructor(client: () => ApiClient | null) { + this.client = client; + this.live = new LiveStream(client, "/api/events", { + onEvent: (event) => this.apply(event), + prepare: () => this.load(), + }); + } + + get running(): ConversationSummary[] { + return this.conversations.filter((row) => row.running_turn); + } + + get open(): ConversationSummary[] { + return this.conversations.filter((row) => row.status === "open"); + } + + byId(id: string): ConversationSummary | undefined { + return this.conversations.find((row) => row.id === id); + } + + async load(): Promise { + const client = this.client(); + if (!client) { + return; + } + const list = await client.conversations({ limit: PAGE }); + this.conversations = list.conversations; + this.loaded = true; + } + + apply(event: BusEvent): void { + switch (event.type) { + case "conversation.created": + case "conversation.updated": { + if (typeof event.id === "string") { + this.upsert(event as unknown as ConversationSummary); + } + return; + } + case "turn.start": + case "turn.end": { + const row = event.conversation_id + ? this.byId(event.conversation_id) + : undefined; + if (row) { + row.running_turn = + event.type === "turn.start" ? (event.turn_id ?? null) : null; + row.last_activity_at = event.ts; + } + return; + } + case "message.queued": + case "inject.queued": + case "reply": { + this.reloadSoon(); + return; + } + default: + } + } + + start(): void { + this.live.start(); + } + + stop(): void { + this.live.stop(); + } + + private upsert(row: ConversationSummary): void { + const index = this.conversations.findIndex((c) => c.id === row.id); + if (index >= 0) { + this.conversations[index] = { ...this.conversations[index], ...row }; + } else { + this.conversations.unshift(row); + } + } + + private reloadSoon(): void { + if (this.reloadTimer) { + return; + } + this.reloadTimer = setTimeout(() => { + this.reloadTimer = null; + this.load().catch(() => undefined); + }, RELOAD_DEBOUNCE_MS); + } +} diff --git a/ui/src/lib/panel/markdown.svelte b/ui/src/lib/panel/markdown.svelte index 20355ad..565ecd7 100644 --- a/ui/src/lib/panel/markdown.svelte +++ b/ui/src/lib/panel/markdown.svelte @@ -1,14 +1,64 @@ -
- - {@html html} -
+{#if host.markdown} +
+{:else} +
+ + {@html html} +
+{/if} diff --git a/ui/src/lib/panel/markdown.ts b/ui/src/lib/panel/markdown.ts index 22e9c9b..888467b 100644 --- a/ui/src/lib/panel/markdown.ts +++ b/ui/src/lib/panel/markdown.ts @@ -1,18 +1,75 @@ import DOMPurify from "dompurify"; -import { marked } from "marked"; +import { marked, type TokenizerAndRendererExtension } from "marked"; -marked.use({ breaks: true, gfm: true }); +const WIKILINK = /^\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/; +const ESCAPES: Record = { + '"': """, + "&": "&", + "<": "<", + ">": ">", +}; +const ESCAPABLE = /[&<>"]/g; + +function escapeHtml(text: string): string { + return text.replace(ESCAPABLE, (ch) => ESCAPES[ch]); +} + +// ``[[note]]`` and ``[[note|alias]]`` render like Obsidian's own internal +// links, so one click handler serves both renderers. +const wikilink: TokenizerAndRendererExtension = { + level: "inline", + name: "wikilink", + renderer(token) { + const target = String(token.target); + const text = String(token.text); + return `${escapeHtml(text)}`; + }, + start(src) { + return src.indexOf("[["); + }, + tokenizer(src) { + const match = WIKILINK.exec(src); + if (!match) { + return; + } + const target = match[1].trim(); + return { + raw: match[0], + target, + text: (match[2] ?? target).trim(), + type: "wikilink", + }; + }, +}; + +marked.use({ breaks: true, extensions: [wikilink], gfm: true }); DOMPurify.addHook("afterSanitizeAttributes", (node) => { - if (node.tagName === "A") { + if (node.tagName === "A" && !node.classList.contains("internal-link")) { node.setAttribute("target", "_blank"); node.setAttribute("rel", "noopener"); } }); -// The one place chat text becomes HTML. The Obsidian panel swaps this for -// Obsidian's own MarkdownRenderer; the admin uses marked + DOMPurify. +// The browser's markdown path; the Obsidian panel renders through the +// host's MarkdownRenderer instead (see host.ts). export function renderMarkdown(text: string): string { const html = marked.parse(text, { async: false }); return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] }); } + +// Resolves the clicked anchor, if any, to what the host should open. +export function linkOf( + event: Event +): { target: string; kind: "internal" | "external" } | null { + const anchor = (event.target as HTMLElement | null)?.closest("a"); + if (!anchor) { + return null; + } + if (anchor.classList.contains("internal-link")) { + const target = anchor.dataset.href ?? anchor.getAttribute("href") ?? ""; + return target && target !== "#" ? { kind: "internal", target } : null; + } + const href = anchor.getAttribute("href") ?? ""; + return href ? { kind: "external", target: href } : null; +} diff --git a/ui/src/lib/panel/menu-items.svelte b/ui/src/lib/panel/menu-items.svelte new file mode 100644 index 0000000..ad841d8 --- /dev/null +++ b/ui/src/lib/panel/menu-items.svelte @@ -0,0 +1,49 @@ + + + + +{#each actions as action, index (action.label)} + {#if action.gap && index > 0} + + {/if} + {#if action.checked === undefined} + action.run()} + variant={action.danger ? "destructive" : "default"} + > + {#if action.icon} + + {/if} + {action.label} + + {:else} + action.run()} + > + {#if action.icon} + + {/if} + {action.label} + + {/if} +{/each} diff --git a/ui/src/lib/panel/panel-bar.svelte b/ui/src/lib/panel/panel-bar.svelte new file mode 100644 index 0000000..0085ecc --- /dev/null +++ b/ui/src/lib/panel/panel-bar.svelte @@ -0,0 +1,119 @@ + + +
+ + + {#snippet child({ props })} + + {/snippet} + + +
+ { + switcherOpen = false; + onOpen(id); + }} + {selected} + /> +
+
+
+ + {#if showFollow} + + {/if} +
diff --git a/ui/src/lib/panel/panel-shell.svelte b/ui/src/lib/panel/panel-shell.svelte new file mode 100644 index 0000000..0393da4 --- /dev/null +++ b/ui/src/lib/panel/panel-shell.svelte @@ -0,0 +1,106 @@ + + + +
+
+ {#if wide} + + {/if} +
+ {#if !wide} + + {/if} + {#if selected} + {#key selected} + + {/key} + {:else} +
+ +
+ {/if} +
+
+
diff --git a/ui/src/lib/panel/panel.css b/ui/src/lib/panel/panel.css new file mode 100644 index 0000000..3bd943d --- /dev/null +++ b/ui/src/lib/panel/panel.css @@ -0,0 +1,70 @@ +/* Palette-neutral vocabulary shared by the browser admin and the Obsidian + panel: kind and outcome hues resolve to whatever the host theme sets, + the utilities are the ledger grammar, the two animations are the only + motion the panel owns. */ + +@theme inline { + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --color-kind-master: var(--signal); + --color-kind-branch: var(--status-reply); + --color-kind-deep: var(--status-meeting); + --color-kind-job: var(--status-work); + --color-kind-fork: var(--status-skip); + --color-ok: var(--status-done); + --color-warn: var(--warn); + --color-link: var(--link); + --animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes pulse-dot { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); + } +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: none; + } +} + +@utility tabular { + font-variant-numeric: tabular-nums; +} + +@utility touch-shown { + @media (hover: none) { + opacity: 1; + } +} + +@utility scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +@utility row-hover { + transition: background-color 150ms ease-out; + &:hover { + background: color-mix(in oklab, var(--foreground) 4%, transparent); + } +} + +@utility ledger-grid { + display: grid; + column-gap: 0.75rem; + align-items: center; +} diff --git a/ui/src/lib/panel/question-card.svelte b/ui/src/lib/panel/question-card.svelte index 77e69c5..77d4e10 100644 --- a/ui/src/lib/panel/question-card.svelte +++ b/ui/src/lib/panel/question-card.svelte @@ -1,4 +1,5 @@ + + + + + Rename + +
{ + event.preventDefault(); + save(); + }} + > + + + + + +
+
+
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 1fc1fbf..ef5e8e2 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -17,17 +17,20 @@ let { children } = $props(); const isLogin = $derived(page.url.pathname === `${base}/login`); + const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`)); const inConversations = $derived( page.url.pathname.startsWith(`${base}/conversations`) ); let navBefore: boolean | null = null; onMount(() => { - session.check(); + if (!isPanel) { + session.check(); + } }); $effect(() => { - if (!session.ready) { + if (!session.ready || isPanel) { return; } if (!(session.user || isLogin)) { @@ -74,7 +77,7 @@ - {#if isLogin} + {#if isLogin || isPanel} {@render children()} {:else if session.ready && session.user}
diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css index 04d6475..3d8ad84 100644 --- a/ui/src/routes/layout.css +++ b/ui/src/routes/layout.css @@ -2,6 +2,7 @@ @import "tw-animate-css"; @import "shadcn-svelte/tailwind.css"; @import "@fontsource-variable/inter"; +@import "../lib/panel/panel.css"; @plugin "@tailwindcss/forms"; @plugin "@tailwindcss/typography"; @@ -178,16 +179,6 @@ } } -@utility touch-shown { - @media (hover: none) { - opacity: 1; - } -} - -@utility tabular { - font-variant-numeric: tabular-nums; -} - @utility hue-chip { color: oklch(0.45 0.13 var(--hue)); background: oklch(0.6 0.09 var(--hue) / 0.12); @@ -197,49 +188,6 @@ } } -@utility scrollbar-none { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } -} - -@theme inline { - --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - --color-kind-master: var(--signal); - --color-kind-branch: var(--status-reply); - --color-kind-deep: var(--status-meeting); - --color-kind-job: var(--status-work); - --color-kind-fork: var(--status-skip); - --color-ok: var(--status-done); - --color-warn: var(--warn); - --color-link: var(--link); - --animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; - --animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - -@keyframes pulse-dot { - 0%, - 100% { - box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); - } - 50% { - box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); - } -} - -@keyframes rise { - from { - opacity: 0; - transform: translateY(3px); - } - to { - opacity: 1; - transform: none; - } -} - @layer base { * { scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent) @@ -281,19 +229,6 @@ } } -@utility row-hover { - transition: background-color 150ms ease-out; - &:hover { - background: color-mix(in oklab, var(--foreground) 4%, transparent); - } -} - -@utility ledger-grid { - display: grid; - column-gap: 0.75rem; - align-items: center; -} - @utility hairline { border-color: color-mix(in oklab, var(--border) 100%, transparent); } diff --git a/ui/src/routes/panel/+page.svelte b/ui/src/routes/panel/+page.svelte new file mode 100644 index 0000000..40f887b --- /dev/null +++ b/ui/src/routes/panel/+page.svelte @@ -0,0 +1,125 @@ + + +Panel · Beaver + + + + + {#if client && index} +
+ +
+ {:else} +
+
{ + event.preventDefault(); + submit(); + }} + style="box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.25)" + > +
+ + Beaver panel +
+

+ Paste a token with the api scope. It stays in this + browser; open the page with ?token=… to hand it over. +

+
+ + +
+ {#if failure} + + {/if} + +
+
+ {/if} +